Compare commits

..
Author SHA1 Message Date
grenadeandClaude Opus 5.5 cdcd5bb092 fix(bench): chat scenarios skip decision models (#341)
CI / Config consistency (push) Successful in 5s
CI / Classify changes (push) Successful in 12s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m37s
CI / Test (push) Successful in 8m14s
CI / CUDA type-check (push) Successful in 20m9s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
CI / Config consistency (pull_request) Successful in 9s
CI / Classify changes (pull_request) Successful in 12s
CI / Format (pull_request) Successful in 8s
CI / Web (lint + typecheck + i18n + build) (pull_request) Successful in 2m40s
CI / Clippy (pull_request) Successful in 3m48s
CI / Test (pull_request) Successful in 8m42s
CI / CUDA type-check (pull_request) Successful in 19m1s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
helexa-bench runs its chat scenarios against every warm model except
image generators. Laya (capabilities ["decision"]) is now resident on
quadbrat and answers /v1/chat/completions with 422 wrong_modality, so
every sweep recorded failed chat runs against it and logged a rejection
on the neuron. The default scenario gate now excludes every non-chat
modality -- image and decision -- through one serves_chat() check.

Mutation-checked: restoring the image-only gate fails the new test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtaCXFAY23HmwX5Cyc6jhC
2026-09-29 22:17:56 +03:00
grenade c7505a9344 Merge pull request 'deploy(laya): Laya takes quadbrat's residency; helexa/one (part of #341)' (#351) from feat/341-laya-residency into main
build-prerelease / Resolve version stamps + change detection (push) Successful in 15s
build-prerelease / Lint (fmt + clippy) (push) Skipped
build-prerelease / Test (push) Skipped
build-prerelease / Build cortex binary (push) Skipped
build-prerelease / Build helexa-bench binary (push) Skipped
build-prerelease / Build helexa-upstream binary (push) Skipped
build-prerelease / Build helexa-angels binary (push) Skipped
build-prerelease / Build helexa-router binary (push) Skipped
build-prerelease / Build helexa-tools binary (push) Skipped
build-prerelease / Build neuron-blackwell (push) Skipped
build-prerelease / Build neuron-ampere (push) Skipped
build-prerelease / Build neuron-ada (push) Skipped
build-prerelease / Package cortex RPM (push) Skipped
build-prerelease / Package helexa-bench RPM (push) Skipped
build-prerelease / Package helexa-upstream RPM (push) Skipped
build-prerelease / Package helexa-angels RPM (push) Skipped
build-prerelease / Package helexa-router RPM (push) Skipped
build-prerelease / Package helexa-tools RPM (push) Skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Skipped
2026-09-29 19:06:08 +00:00
grenade 8ed8d04d3a Merge pull request 'ci(neuron): flash-attn on the ampere flavour, and decision/image validation (part of #341)' (#350) from feat/341-laya-rollout into main
build-prerelease / Resolve version stamps + change detection (push) Successful in 10s
build-prerelease / Build cortex binary (push) Successful in 2m35s
build-prerelease / Build neuron-ada (push) Successful in 2m30s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m31s
build-prerelease / Package cortex RPM (push) Successful in 49s
build-prerelease / Build helexa-bench binary (push) Successful in 2m18s
build-prerelease / Package helexa-bench RPM (push) Successful in 29s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m42s
build-prerelease / Build helexa-tools binary (push) Successful in 1m37s
build-prerelease / Build helexa-router binary (push) Successful in 2m5s
build-prerelease / Package helexa-upstream RPM (push) Successful in 29s
build-prerelease / Package helexa-tools RPM (push) Successful in 26s
build-prerelease / Package helexa-router RPM (push) Successful in 26s
build-prerelease / Build helexa-angels binary (push) Successful in 3m31s
build-prerelease / Package helexa-angels RPM (push) Successful in 33s
build-prerelease / Test (push) Successful in 10m58s
build-prerelease / Build neuron-blackwell (push) Successful in 16m4s
build-prerelease / Build neuron-ampere (push) Successful in 22m30s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m6s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m46s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m9s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 30s
2026-09-29 17:52:04 +00:00
grenade 6a0d4fbb0c Merge pull request 'perf(laya): mixed precision, fused norms, mask elision, flash-attn varlen (#337)' (#349) from feat/337-laya-perf into main
build-prerelease / Lint (fmt + clippy) (push) Canceled after 0s
build-prerelease / Test (push) Canceled after 0s
build-prerelease / Resolve version stamps + change detection (push) Canceled after 15s
build-prerelease / Build cortex binary (push) Canceled after 0s
build-prerelease / Build helexa-bench binary (push) Canceled after 0s
build-prerelease / Build helexa-upstream binary (push) Canceled after 0s
build-prerelease / Build helexa-router binary (push) Canceled after 0s
build-prerelease / Build helexa-angels binary (push) Canceled after 0s
build-prerelease / Build helexa-tools binary (push) Canceled after 0s
build-prerelease / Build neuron-blackwell (push) Canceled after 0s
build-prerelease / Build neuron-ampere (push) Canceled after 0s
build-prerelease / Build neuron-ada (push) Canceled after 0s
build-prerelease / Package cortex RPM (push) Canceled after 0s
build-prerelease / Package helexa-bench RPM (push) Canceled after 0s
build-prerelease / Package helexa-upstream RPM (push) Canceled after 0s
build-prerelease / Package helexa-angels RPM (push) Canceled after 0s
build-prerelease / Package helexa-router RPM (push) Canceled after 0s
build-prerelease / Package helexa-tools RPM (push) Canceled after 0s
build-prerelease / Package helexa-neuron-ada RPM (push) Canceled after 0s
build-prerelease / Package helexa-neuron-ampere RPM (push) Canceled after 0s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Canceled after 0s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Canceled after 0s
2026-09-29 17:51:25 +00:00
grenadeandClaude Opus 5.5 73a376e57c ci(deploy): take benjy out of the neuron deploy matrix while it is down
build-prerelease / Resolve version stamps + change detection (push) Successful in 13s
build-prerelease / Lint (fmt + clippy) (push) Skipped
build-prerelease / Test (push) Skipped
build-prerelease / Build cortex binary (push) Skipped
build-prerelease / Build helexa-bench binary (push) Skipped
build-prerelease / Build helexa-upstream binary (push) Skipped
build-prerelease / Build helexa-angels binary (push) Skipped
build-prerelease / Build helexa-router binary (push) Skipped
build-prerelease / Build helexa-tools binary (push) Skipped
build-prerelease / Build neuron-blackwell (push) Skipped
build-prerelease / Build neuron-ampere (push) Skipped
build-prerelease / Build neuron-ada (push) Skipped
build-prerelease / Package cortex RPM (push) Skipped
build-prerelease / Package helexa-bench RPM (push) Skipped
build-prerelease / Package helexa-upstream RPM (push) Skipped
build-prerelease / Package helexa-angels RPM (push) Skipped
build-prerelease / Package helexa-router RPM (push) Skipped
build-prerelease / Package helexa-tools RPM (push) Skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Skipped
benjy's boot NVMe died on 2026-09-29 and the host is off the mesh, so
every deploy run failed its deploy-neurons (ada, benjy) job at SSH init
-- a red run about something CI cannot act on, which hides real
failures. The entry is commented out, not deleted; restore it when the
host is rebuilt. The ada build flavour keeps building.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtaCXFAY23HmwX5Cyc6jhC
2026-09-29 20:28:23 +03:00
grenadeandClaude Opus 5.5 06cf081c47 deploy(laya): Laya takes quadbrat's residency; helexa/one (#341)
CI / Classify changes (push) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Config consistency (push) Successful in 7s
CI / Format (push) Successful in 9s
CI / Clippy (push) Successful in 3m31s
CI / Test (push) Successful in 14m1s
CI / CUDA type-check (push) Successful in 21m15s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
CI / Config consistency (pull_request) Successful in 6s
CI / Classify changes (pull_request) Successful in 13s
CI / Format (pull_request) Successful in 13s
CI / Web (lint + typecheck + i18n + build) (pull_request) Successful in 2m39s
CI / Clippy (pull_request) Successful in 2m51s
CI / Test (pull_request) Successful in 10m49s
CI / CUDA type-check (pull_request) Successful in 21m0s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
quadbrat pre-warms convaiinnovations/laya in place of Qwen3-1.7B
(operator decision: the 1.7B saw almost no traffic, Laya is expected to
see a lot). The catalogue gains Laya pinned to quadbrat -- priority
above anything a cold load could bring there, so helexa/small can still
cold-load the 1.7B beside it but never evict it -- metered like
TypeSafe Jev, input tokens only, at Jev's $0.042 per million.

helexa/one, the System-One decision class, is added to cortex's and the
router's aliases. It is also what /v1/systemone falls back to for a
request with no model or one naming a service the fleet does not serve.

quadbrat's admission is widened (4 in flight, a 64-deep queue with a
10 s deadline): a decision is one forward of tens of milliseconds, and
the default one-slot, 8-deep queue would reject ordinary bursts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtaCXFAY23HmwX5Cyc6jhC
2026-09-29 16:21:19 +03:00
grenadeandClaude Opus 5.5 0a122fb26f ci(neuron): flash-attn on the ampere flavour, and decision/image validation (#341)
CI / Config consistency (push) Successful in 5s
CI / Classify changes (push) Successful in 13s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Format (push) Successful in 17s
CI / Clippy (push) Successful in 3m10s
CI / Test (push) Successful in 14m31s
CI / CUDA type-check (push) Successful in 23m18s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
CI / Config consistency (pull_request) Successful in 10s
CI / Classify changes (pull_request) Successful in 16s
CI / Web (lint + typecheck + i18n + build) (pull_request) Successful in 2m51s
CI / Format (pull_request) Successful in 10s
CI / Clippy (pull_request) Successful in 2m40s
CI / Test (pull_request) Successful in 7m52s
CI / CUDA type-check (pull_request) Successful in 19m23s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
The ampere (sm_86) neuron flavour now builds with flash-attn. Laya's
encoder runs every attention through flash_attn_varlen_windowed, which
brings it to the PyTorch reference's speed on an RTX 3060 (36 vs 39 ms
at 512 tokens, 32 seq/s batched) where the eager path is 1.4-1.7x
slower (#337). It also moves the flavour's other flash-capable paths --
qwen3_5 full attention and Z-Image -- onto flash, so both are validated
on the new build; NEURON_FLASH_ATTN=0 is the runtime rollback.

script/validate-decision.py replays the recorded Laya reference corpus
against any /v1/systemone endpoint (neuron, cortex or the public
router): which checkpoint answered and the usage must match exactly,
the chosen answer must match except on a reference near-tie, and
probabilities must be within a tolerance sized for a bf16 GPU path.

script/validate-image.sh takes an optional quant (a 12 GB card needs
q8_0), evicts every co-resident model rather than only the first, and
restores them on every exit path, failures included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtaCXFAY23HmwX5Cyc6jhC
2026-09-29 16:20:23 +03:00
grenadeandClaude Opus 5.5 e3248cc186 perf(laya): fused norms, mask elision, and a flash-attn varlen path (#337)
CI / Config consistency (push) Successful in 6s
CI / Classify changes (push) Successful in 12s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m36s
CI / Test (push) Successful in 10m4s
CI / CUDA type-check (push) Successful in 22m23s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
CI / Config consistency (pull_request) Successful in 7s
CI / Classify changes (pull_request) Successful in 20s
CI / Format (pull_request) Successful in 9s
CI / Web (lint + typecheck + i18n + build) (pull_request) Successful in 2m5s
CI / Clippy (pull_request) Successful in 2m57s
CI / Test (pull_request) Successful in 12m5s
CI / CUDA type-check (pull_request) Successful in 21m45s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
Measured on an RTX 3060 (sm_86), bf16, forward only, median of 10.
The PyTorch reference's end-to-end numbers are the bar.

                      ref    start  +norms  +masks/scale  +flash-attn
  short, 1 question   16      10.7    8.5       10.2           7.7 ms
  short, 5 questions  25      25.6   20.5       20.7          17.0 ms
  512 tok, 1 q        39      74.3   61.8       54.3          36.2 ms
  512 tok, 5 q       152     352    292        254           162 ms
  512 tok, x64        -      14.5   17         20             32 seq/s

The levers, in the order a profile ranked them:

- Fused LayerNorm. candle_nn::LayerNorm only takes its fused kernel
  when it has a bias; ModernBERT's norms have none, so each fell back
  to six elementwise kernels, a quarter of GPU time. An explicit zero
  bias gives the same result in one kernel.
- No mask when nothing is masked. An unpadded batch (every single-
  question request) skips the global mask, and rows the window cannot
  exceed skip the sliding one; the scale is applied to q rather than to
  the (b, h, l, l) scores. Each saves a bandwidth-bound pass.
- Flash attention, behind the flash-attn feature, on CUDA in reduced
  precision: the batch is packed without padding (total, d) and every
  attention -- sliding layers windowed at local_attention/2 each side,
  global and head layers unwindowed -- runs in flash_attn_varlen. Linear
  layers then do no work for padding either. NEURON_FLASH_ATTN=0 falls
  back to the eager path at runtime, as for the text architectures.

Parity, bf16 against the f32 reference, centred drift (mean / max),
next to the reference's own bf16 GPU run:

  English       0.029 / 0.229   (reference 0.034 / 0.264)
  multilingual  0.030 / 0.089   (reference 0.028 / 0.067)
  argmax 33/33 throughout; f32 unchanged (<1e-4).

The released-weights test now gates on that comparison: mean centred
drift no worse than twice the reference's own bf16 drift, plus 0.01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtaCXFAY23HmwX5Cyc6jhC
2026-09-29 16:13:08 +03:00
grenade 057cb72806 Merge pull request 'feat(neuron): serve /v1/systemone from a Laya decision family (#335, #338, #339)' (#348) from feat/338-neuron-systemone into main
build-prerelease / Build helexa-upstream binary (push) Successful in 3m0s
build-prerelease / Resolve version stamps + change detection (push) Successful in 19s
build-prerelease / Build helexa-tools binary (push) Successful in 1m55s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m7s
build-prerelease / Package helexa-tools RPM (push) Successful in 38s
build-prerelease / Package helexa-upstream RPM (push) Successful in 42s
build-prerelease / Build cortex binary (push) Successful in 2m20s
build-prerelease / Build helexa-bench binary (push) Successful in 2m14s
build-prerelease / Build helexa-router binary (push) Successful in 1m36s
build-prerelease / Package helexa-bench RPM (push) Successful in 33s
build-prerelease / Package cortex RPM (push) Successful in 34s
build-prerelease / Package helexa-router RPM (push) Successful in 21s
build-prerelease / Build helexa-angels binary (push) Successful in 3m32s
build-prerelease / Package helexa-angels RPM (push) Successful in 33s
build-prerelease / Test (push) Successful in 9m30s
build-prerelease / Build neuron-ada (push) Successful in 2m56s
build-prerelease / Build neuron-blackwell (push) Successful in 16m17s
build-prerelease / Build neuron-ampere (push) Successful in 2m16s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m9s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m8s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m10s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 24s
2026-09-29 16:01:50 +03:00
grenadeandClaude Opus 5.5 5264f78b2d perf(laya): autocast-style mixed precision on the GPU (#337)
CI / Classify changes (push) Successful in 28s
CI / Config consistency (push) Successful in 13s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Format (push) Successful in 13s
CI / Clippy (push) Successful in 2m58s
CI / Test (push) Successful in 8m45s
CI / Build cortex SRPM (push) Canceled after 0s
CI / Build neuron SRPM (push) Canceled after 0s
CI / Publish cortex to COPR (push) Canceled after 0s
CI / CUDA type-check (push) Canceled after 7m43s
CI / Publish neuron to COPR (push) Canceled after 0s
CI / Bump version in source (push) Canceled after 0s
A bf16 load now keeps what PyTorch autocast keeps in f32 -- the
residual stream, the embeddings, every LayerNorm -- and runs only the
linear layers in bf16, casting in before each and out after. Attention
scores are taken in f32 end to end: a bf16 q.k^T is rounded to 8
mantissa bits before the softmax sees it, and the reference's SDPA
kernels never do that.

Measured on an RTX 3060 against the f32 CPU reference, worst case over
the fixture corpus (argmax 33/33 throughout):

  all-bf16 (before)     English 0.43+   (aborted at the first failure)
  this change           English 0.094   multilingual 0.255
  reference's own bf16  English 0.313   multilingual 0.074

so the engine now drifts no more than upstream's own GPU path does.
Beyond this point bf16 drift is chaotic per case: also computing the
rotary embedding in f32 moved English to 0.375 and multilingual to
0.138, so that variant is not kept.

reference_cuda_bf16.json records the reference on that 3060 under its
default bf16 autocast, as the envelope reduced-precision results should
be judged against. Latency is unchanged by this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtaCXFAY23HmwX5Cyc6jhC
2026-09-29 15:39:18 +03:00
grenade 2cbfd06e40 Merge pull request 'feat(neuron): Laya decision-model engine (#336)' (#346) from feat/336-laya-engine into main
build-prerelease / Resolve version stamps + change detection (push) Successful in 25s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m58s
build-prerelease / Build helexa-bench binary (push) Successful in 1m49s
build-prerelease / Package helexa-bench RPM (push) Successful in 29s
build-prerelease / Build cortex binary (push) Successful in 2m50s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m25s
build-prerelease / Package cortex RPM (push) Successful in 34s
build-prerelease / Package helexa-upstream RPM (push) Successful in 29s
build-prerelease / Build helexa-angels binary (push) Successful in 3m2s
build-prerelease / Build helexa-tools binary (push) Successful in 1m48s
build-prerelease / Build helexa-router binary (push) Successful in 1m54s
build-prerelease / Package helexa-angels RPM (push) Successful in 40s
build-prerelease / Package helexa-tools RPM (push) Successful in 35s
build-prerelease / Package helexa-router RPM (push) Successful in 39s
build-prerelease / Test (push) Successful in 9m34s
build-prerelease / Build neuron-blackwell (push) Successful in 16m22s
build-prerelease / Build neuron-ampere (push) Successful in 2m42s
build-prerelease / Package helexa-neuron-ada RPM (push) Canceled after 0s
build-prerelease / Package helexa-neuron-ampere RPM (push) Canceled after 0s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Canceled after 0s
build-prerelease / Build neuron-ada (push) Canceled after 1m8s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Canceled after 0s
2026-09-29 12:35:10 +00:00
grenadeandClaude Opus 5.5 816c8dc96f feat(neuron): serve /v1/systemone from a Laya decision family (#338, #339)
CI / Classify changes (push) Successful in 25s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Config consistency (push) Successful in 11s
CI / Format (push) Successful in 9s
CI / Clippy (push) Successful in 2m36s
CI / Test (push) Successful in 11m25s
CI / CUDA type-check (push) Successful in 22m44s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
CI / Config consistency (pull_request) Successful in 6s
CI / Classify changes (pull_request) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (pull_request) Skipped
CI / Format (pull_request) Successful in 16s
CI / Clippy (pull_request) Successful in 3m9s
CI / Test (pull_request) Successful in 10m25s
CI / CUDA type-check (pull_request) Successful in 21m50s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
The neuron half of the decision endpoint, wiring together the prompt
builder (#335), the engine (#336) and the language detector (#339).

CandleHarness::systemone picks the served model -- the gateway's
x-helexa-decision-model header, else the body's model if loaded, else
the node's decision model -- then routes to a checkpoint of the family
exactly as the reference server does: a checkpoint the body's `model`
pins (multilingual, typed-decisions, their published ids and aliases),
else by the detected script and language, else English. The routing
block, reason strings included, matches the reference byte for byte on
every recorded case. A routed-to checkpoint the repo lacks falls back to
the root and says so; a pinned one it lacks is a 404.

Questions are validated and tokenised before admission, scored in one
forward pass per request, and calibrated with the detected language, as
upstream's Router does. Errors carry the #63 envelope plus the FastAPI
`detail` field Jev and laya clients read.

Loading now also fetches the family's multilingual/ and typed-decisions/
checkpoints when the repo has them, and each checkpoint carries its
calibration config and decision tokenizer. The language detector takes
the order-preserving Json state, since an object state's value order
decides what the detector reads first.

Replaying every reference case through systemone with the released
weights (f32, CPU): routing, usage and model identical, chosen answers
identical, probabilities and confidences within 1e-4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtaCXFAY23HmwX5Cyc6jhC
2026-09-29 15:25:47 +03:00
grenade 042d708da3 Merge pull request 'feat(laya): port the language/script detector (part of #339)' (#345) from feat/339-laya-lang-detect into main
build-prerelease / Build helexa-bench binary (push) Successful in 2m26s
build-prerelease / Resolve version stamps + change detection (push) Successful in 21s
build-prerelease / Package helexa-bench RPM (push) Successful in 33s
build-prerelease / Build cortex binary (push) Successful in 2m34s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m50s
build-prerelease / Package helexa-upstream RPM (push) Successful in 40s
build-prerelease / Package cortex RPM (push) Successful in 38s
build-prerelease / Build helexa-angels binary (push) Successful in 3m47s
build-prerelease / Build helexa-router binary (push) Successful in 2m6s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m20s
build-prerelease / Package helexa-angels RPM (push) Successful in 52s
build-prerelease / Package helexa-router RPM (push) Successful in 18s
build-prerelease / Build helexa-tools binary (push) Successful in 1m32s
build-prerelease / Test (push) Successful in 11m21s
build-prerelease / Package helexa-tools RPM (push) Successful in 25s
build-prerelease / Build neuron-ampere (push) Canceled after 0s
build-prerelease / Build neuron-ada (push) Canceled after 0s
build-prerelease / Package helexa-neuron-ada RPM (push) Canceled after 0s
build-prerelease / Package helexa-neuron-ampere RPM (push) Canceled after 0s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Canceled after 0s
build-prerelease / Build neuron-blackwell (push) Canceled after 3m38s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Canceled after 0s
2026-09-29 12:16:49 +00:00
grenade 421bf55697 Merge remote-tracking branch 'origin/feat/339-laya-lang-detect' into feat/338-neuron-systemone 2026-09-29 15:04:52 +03:00
grenade e242514121 Merge remote-tracking branch 'origin/feat/336-laya-engine' into feat/338-neuron-systemone
# Conflicts:
#	crates/neuron/src/harness/mod.rs
2026-09-29 15:04:52 +03:00
grenadeandClaude Opus 5.5 1ba160491c test(laya): device/precision-parameterised parity and a forward timing bench (#337)
The released-weights parity test now runs on the device and precision
LAYA_DEVICE (cpu | cuda:N) and LAYA_DTYPE (f32 | bf16 | f16) select,
defaulting to f32 on the CPU. Reduced precision is judged on argmax
agreement with a loose logit bound that only catches a broken kernel.

laya_forward_timing times the four workloads the PyTorch reference was
measured on (a short state and a 512-token state, one and five
questions) plus 16- and 64-sequence batches for throughput, using real
sequences from reference.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtaCXFAY23HmwX5Cyc6jhC
2026-09-29 15:04:46 +03:00
grenade 047005203e Merge remote-tracking branch 'origin/feat/335-laya-prompt-builder' into feat/338-neuron-systemone 2026-09-29 15:04:46 +03:00
grenadeandClaude Opus 5.5 86a6f25a6b feat(laya): prompt builder, calibration and answer shaping (#335)
CI / Classify changes (push) Successful in 25s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Format (push) Successful in 9s
CI / Config consistency (push) Successful in 15s
CI / Clippy (push) Successful in 2m57s
CI / Test (push) Successful in 10m46s
CI / CUDA type-check (push) Successful in 22m24s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
CI / Config consistency (pull_request) Successful in 17s
CI / Classify changes (pull_request) Successful in 19s
CI / Web (lint + typecheck + i18n + build) (pull_request) Skipped
CI / Format (pull_request) Successful in 12s
CI / Clippy (pull_request) Successful in 2m54s
CI / Test (pull_request) Successful in 7m55s
CI / CUDA type-check (pull_request) Successful in 22m33s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
The tensor-free half of serving a Laya decision model: everything
between a /v1/systemone request and the model's input rows, and between
the per-option logits and the typed answers. Reproduces the laya SDK
(0.3.21) token for token.

cortex-core/src/decisions.rs — wire types, plus `Json`: a JSON value as
Python's json.loads builds it. The reference re-serializes the state
with json.dumps before tokenizing it, and a choice question's criteria
order is its option order, so the request cannot pass through a sorted
serde_json::Value (this workspace does not enable preserve_order). Json
keeps key order and Python's semantics (arbitrary-precision ints,
correctly rounded floats, NaN/Infinity, duplicate keys last-wins at the
first position), and renders json.dumps / str() / repr() / dict-key
forms. SystemOneRequest::parse applies the reference server's checks in
its order, with its status codes and detail text.

neuron/src/harness/decision/ — question validation with the SDK's
messages, option rendering, sequence assembly under the token budgets
(48-token option cap, even shrinking below 16 tokens of head room, the
8-token instruction floor, left truncation for conversations, markers
past max_len dropped and the question refused), option-collapse stats,
clamped per-bucket and per-language temperatures, and answer shaping.

Calibration follows numpy's float32 evaluation order: the temperature is
rounded to float32 before dividing, and every reduction is numpy's
pairwise sum. numpy's SIMD expf differs from libm's by one ulp on about
a third of inputs; that is not reproduced (it never changes a rounded
fixture value), and the steps either side of it are pinned bit for bit
against numpy.

Tests replay every reference fixture: token ids and markers exactly,
and answers + usage as identical JSON (key order included) from the
reference's own logits — 19 English cases and 1 typed-decisions case
always, the 8 multilingual cases when LAYA_ML_TOKENIZER_DIR points at
that checkpoint's 34 MB tokenizer. Mutation-checked: each budget rule,
the calibration order, the temperature clamp and buckets, the state
serialization, and object key order (all objects, and the state alone)
fail at least one test when broken.

The English tokenizer (3.5 MB) is committed as test data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtaCXFAY23HmwX5Cyc6jhC
2026-09-29 15:04:05 +03:00
grenadeandClaude Opus 5.5 ec315a7f58 feat(neuron): Laya decision-model engine (#336)
CI / Classify changes (push) Successful in 25s
CI / Config consistency (push) Successful in 20s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Format (push) Successful in 17s
CI / Clippy (push) Successful in 3m31s
CI / Test (push) Successful in 16m25s
CI / CUDA type-check (push) Successful in 22m1s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
CI / Config consistency (pull_request) Successful in 9s
CI / Classify changes (pull_request) Successful in 19s
CI / Web (lint + typecheck + i18n + build) (pull_request) Skipped
CI / Format (pull_request) Successful in 13s
CI / Clippy (pull_request) Successful in 2m47s
CI / Test (pull_request) Successful in 10m19s
CI / CUDA type-check (pull_request) Successful in 21m49s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
A candle-native implementation of the Laya decision model: a ModernBERT
encoder plus a decision head that scores the options of a typed question
in one forward pass. Takes assembled sequences (token ids, [MASK] marker
positions, question type) and returns raw per-option scores and the act
head's distribution; calibration and answer shaping happen afterwards.

arch/laya/
- config: rl_agent_config.json + encoder/config.json. Reads the
  transformers-5 `rope_parameters` block (mmBERT's sliding layers use
  theta 160k, not the 10k flat default) and refuses ModernBERT variants
  it does not implement rather than approximating them.
- backbone: ModernBERT, following candle-transformers' module but
  vendored, because that module only runs in f32 (its masks are always
  F32, and it computes rotary angles in the model dtype, which in f16 is
  a quarter-radian of phase error by position 500). Rotary tables are
  built in f32, scores are softmaxed in f32 against finite masks, and
  attn_norm is required on every layer but the first.
- head: type embedding, PyTorch TransformerEncoderLayer (norm_first,
  fused in_proj, ReLU), the marker scorer, and the act head.
- model: right-padded batching; padding does not change a row's result.

Serving plumbing: LoadDecision/Decide/DropDecision device-worker jobs
with their own slab (the worker falls back to f32 on a CPU device and
reports the dtype it used); a DecisionModel source format recognised
from the repo layout, refused with tp>1 or quant; a "decision"
capability; LoadedHandle::Decision; and CandleHarness::decision_model
for the HTTP handler to come. Only the repo-root checkpoint is loaded;
subfolder checkpoints are resolvable but routing between them is
separate work.

Parity with the reference SDK's DecisionModel:
- testdata/laya/tiny: a toy checkpoint scored by the SDK itself
  (script/generate_laya_fixture.py), run in CI: max |diff| < 1e-5 on
  logits and act probabilities. Mutation-checked: ReLU->GELU in the
  head, dropping the type embedding, swapping the rotary bases, a
  window off-by-one, no sliding window, and an unmasked head each fail
  it.
- the released checkpoints over testdata/laya/reference.json (ignored
  unless LAYA_REFERENCE_DIR points at a snapshot), f32 on CPU: max
  |logit diff| 1.6e-4 (English), 1.1e-5 (multilingual), 1.7e-6
  (typed-decisions); argmax agrees on all 33 questions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtaCXFAY23HmwX5Cyc6jhC
2026-09-29 14:52:23 +03:00
grenade 7c17a9fcd4 Merge pull request 'feat(systemone): route /v1/systemone through cortex and helexa-router (part of #338)' (#344) from feat/338-systemone-surface into main
build-prerelease / Resolve version stamps + change detection (push) Successful in 27s
build-prerelease / Build helexa-angels binary (push) Skipped
build-prerelease / Build helexa-tools binary (push) Skipped
build-prerelease / Package helexa-angels RPM (push) Skipped
build-prerelease / Package helexa-tools RPM (push) Skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m41s
build-prerelease / Build helexa-upstream binary (push) Successful in 3m10s
build-prerelease / Build cortex binary (push) Successful in 3m35s
build-prerelease / Build helexa-bench binary (push) Successful in 3m59s
build-prerelease / Package helexa-upstream RPM (push) Successful in 22s
build-prerelease / Package cortex RPM (push) Successful in 29s
build-prerelease / Test (push) Successful in 10m5s
build-prerelease / Build helexa-router binary (push) Successful in 1m51s
build-prerelease / Package helexa-router RPM (push) Successful in 24s
build-prerelease / Build neuron-blackwell (push) Canceled after 0s
build-prerelease / Build neuron-ampere (push) Canceled after 0s
build-prerelease / Package helexa-bench RPM (push) Canceled after 8m52s
build-prerelease / Package helexa-neuron-ada RPM (push) Canceled after 0s
build-prerelease / Package helexa-neuron-ampere RPM (push) Canceled after 0s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Canceled after 0s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Canceled after 0s
build-prerelease / Build neuron-ada (push) Canceled after 13m44s
2026-09-29 11:51:35 +00:00
grenadeandClaude Opus 5.5 c6502e1519 feat(laya): port the language/script detector that picks the checkpoint (#339)
CI / Config consistency (push) Successful in 12s
CI / Classify changes (push) Successful in 19s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Format (push) Successful in 12s
CI / Clippy (push) Successful in 2m58s
CI / Test (push) Successful in 10m5s
CI / CUDA type-check (push) Successful in 22m34s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
CI / Classify changes (pull_request) Successful in 23s
CI / Config consistency (pull_request) Successful in 10s
CI / Format (pull_request) Successful in 13s
CI / Web (lint + typecheck + i18n + build) (pull_request) Successful in 2m18s
CI / Clippy (pull_request) Successful in 3m30s
CI / Test (pull_request) Successful in 10m8s
CI / CUDA type-check (pull_request) Successful in 22m7s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
Laya ships an English checkpoint and a multilingual one, and the
English one is confidently wrong outside English (0.000 accuracy at
0.952 confidence on Khmer). The SDK routes between them with
`laya.lang.analyse`; this is that function in Rust, rule for rule:
script counts over letters, a function-word vote with margins for
Latin-script text, a diacritic fallback for Latin languages with no
word list, the English rescue for loanwords, and the line-by-line and
field-by-field rescan that stops a long English part outvoting a short
foreign message. `Analysis` serialises to the same JSON the SDK's
`routing.detection` block carries.

The heuristics sit on Python's Unicode semantics, and each place Rust's
defaults differ is replicated explicitly (documented in the module):
`isalpha` is General_Category L* (not Alphabetic, which admits marks),
`re`'s word class excludes combining marks, `str.split` treats
U+001C..U+001F as whitespace, `unicodedata.combining` is the canonical
combining class, `str.isupper` is "no lowercase or titlecase, at least
one uppercase", and budgets count code points. The identifier regex
uses a lookbehind the regex crate lacks and is a hand-written scanner.
Tables (script ranges, word lists, diacritics) were generated from the
SDK source, not retyped.

Parity: script/laya-lang-reference.py records the SDK over 924 cases
(every script range, each word-list language accented and
ASCII-stripped, code and identifiers, multi-line and structured
states, the 4000-character budgets and depth limit, Unicode edge cases,
and a seeded fuzz), all matching exactly. Mutation-checked: every
behaviour-changing mutation of the Unicode handling and of the rules
above fails at least one case. The one surviving mutant, half-away
rounding of the non-Latin letter count, is equivalent: the two
rounding rules only disagree on ties that land on the same side of the
10-letter threshold.

Caveat for callers: an object state's value order decides what is read
first. Python keeps insertion order; serde_json without
`preserve_order` iterates keys sorted, so multi-field states can differ
from the SDK until the request JSON keeps order. The corpus uses
sorted keys so it tests the detector, not the JSON reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtaCXFAY23HmwX5Cyc6jhC
2026-09-29 14:49:53 +03:00
grenade 21a1083308 Merge pull request 'feat(laya): reference fixtures and their recorder (#334)' (#343) from feat/334-laya-reference-fixtures into main
build-prerelease / Resolve version stamps + change detection (push) Successful in 15s
build-prerelease / Build cortex binary (push) Skipped
build-prerelease / Build helexa-bench binary (push) Skipped
build-prerelease / Build helexa-upstream binary (push) Skipped
build-prerelease / Build helexa-angels binary (push) Skipped
build-prerelease / Build helexa-router binary (push) Skipped
build-prerelease / Build helexa-tools binary (push) Skipped
build-prerelease / Package cortex RPM (push) Skipped
build-prerelease / Package helexa-bench RPM (push) Skipped
build-prerelease / Package helexa-upstream RPM (push) Skipped
build-prerelease / Package helexa-angels RPM (push) Skipped
build-prerelease / Package helexa-router RPM (push) Skipped
build-prerelease / Package helexa-tools RPM (push) Skipped
build-prerelease / Build neuron-ampere (push) Successful in 2m51s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m3s
build-prerelease / Package helexa-neuron-ada RPM (push) Canceled after 0s
build-prerelease / Build neuron-ada (push) Canceled after 1m57s
build-prerelease / Package helexa-neuron-ampere RPM (push) Canceled after 0s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Canceled after 0s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Canceled after 0s
build-prerelease / Build neuron-blackwell (push) Canceled after 2m3s
build-prerelease / Test (push) Canceled after 5m42s
2026-09-29 11:44:48 +00:00
grenadeandClaude Opus 5.5 83cd621eee feat(systemone): route /v1/systemone through cortex and helexa-router (#338)
CI / Config consistency (push) Successful in 9s
CI / Classify changes (push) Successful in 17s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Format (push) Successful in 13s
CI / Clippy (push) Successful in 3m14s
CI / Test (push) Successful in 9m12s
CI / CUDA type-check (push) Successful in 22m14s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
CI / Config consistency (pull_request) Successful in 10s
CI / Classify changes (pull_request) Successful in 15s
CI / Web (lint + typecheck + i18n + build) (pull_request) Skipped
CI / Format (pull_request) Successful in 10s
CI / Clippy (pull_request) Successful in 3m7s
CI / Test (pull_request) Successful in 15m38s
CI / CUDA type-check (pull_request) Successful in 22m32s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
Adds the TypeSafe Jev decision endpoint to both gateway hops. Routing
matches the other inference paths with one deliberate difference: a
missing or unknown `model` resolves to the `helexa/one` alias instead
of 404ing. Jev clients put the name of *their* service in `model`, and
Laya's own server treats any unrecognised value as "let the router
choose". The fallback is scoped to this endpoint; a chat request for an
unknown model is still a 404, and a decision request naming a model the
fleet does serve (say, a text model) routes there and gets that node's
wrong_modality answer rather than a silent substitution.

The body is forwarded untouched, since its `model` may pin a checkpoint
inside the served family (`multilingual`, `typed-decisions`). cortex
states where it routed in the `x-helexa-decision-model` header.

Metering follows Jev: input tokens only. The reservation is the exact
ceiling (questions x max_len; one sequence per question) and settles to
the node's `usage.input_tokens`. Server-Timing / X-Inference-Time-Ms
pass through.

The alias itself is operator config (models.toml / helexa-router.toml
[aliases]); it is not set here because no neuron serves a decision
model yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtaCXFAY23HmwX5Cyc6jhC
2026-09-29 14:26:50 +03:00
grenadeandClaude Opus 5.5 c68d1e225b feat(laya): reference fixtures and their recorder (#334)
CI / Classify changes (push) Successful in 13s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Config consistency (push) Successful in 6s
CI / Format (push) Successful in 8s
CI / Clippy (push) Successful in 2m59s
CI / Test (push) Successful in 8m31s
CI / CUDA type-check (push) Successful in 21m32s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
CI / Config consistency (pull_request) Successful in 9s
CI / Classify changes (pull_request) Successful in 25s
CI / Format (pull_request) Successful in 11s
CI / Web (lint + typecheck + i18n + build) (pull_request) Successful in 2m24s
CI / Clippy (pull_request) Successful in 3m2s
CI / Test (pull_request) Successful in 10m51s
CI / CUDA type-check (pull_request) Successful in 21m43s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
script/laya-reference.py runs the upstream laya SDK (the reference
implementation; the rl_common.py in the model repo has diverged) over a
fixed corpus on CPU in float32, and records per question the input ids,
[MASK] marker positions, option-collapse stats, raw scorer logits and
act probabilities, plus the final /v1/systemone response. A second file
records laya.lang.analyse over a language corpus for the router port.

The corpus covers every question type and temperature bucket, the
option/instruction/state budget rules, literal [MASK] text, structured
criteria, custom noul labels, a genuinely collapsed option pair, the
three checkpoints, and a foreign (Jev) model name.

Mutation-checked: raising the SDK's per-option 48-token cap to 64
changes the input ids of exactly the case built to exercise it.

Recorded against laya 0.3.21, model revision 55cf4c4e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtaCXFAY23HmwX5Cyc6jhC
2026-09-29 14:19:42 +03:00
grenade 65dc1103f1 Merge pull request 'feat(tp): multi-position logits, the MTP draft head, and the measurement harness (#96)' (#332) from feat/96-tp-multi-position into main
build-prerelease / Resolve version stamps + change detection (push) Successful in 23s
build-prerelease / Build cortex binary (push) Skipped
build-prerelease / Build helexa-bench binary (push) Skipped
build-prerelease / Build helexa-upstream binary (push) Skipped
build-prerelease / Build helexa-angels binary (push) Skipped
build-prerelease / Build helexa-router binary (push) Skipped
build-prerelease / Build helexa-tools binary (push) Skipped
build-prerelease / Package cortex RPM (push) Skipped
build-prerelease / Package helexa-bench RPM (push) Skipped
build-prerelease / Package helexa-upstream RPM (push) Skipped
build-prerelease / Package helexa-angels RPM (push) Skipped
build-prerelease / Package helexa-router RPM (push) Skipped
build-prerelease / Package helexa-tools RPM (push) Skipped
build-prerelease / Build neuron-ampere (push) Successful in 3m12s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m9s
build-prerelease / Build neuron-ada (push) Successful in 3m11s
build-prerelease / Test (push) Successful in 8m35s
build-prerelease / Build neuron-blackwell (push) Successful in 15m15s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m18s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m23s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m22s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 35s
2026-09-23 15:29:37 +00:00
grenadeandClaude Opus 5 ac150fa61c feat(mtp-probe): sample the verify cost, and trace the leader (#96)
CI / Config consistency (push) Successful in 7s
CI / Classify changes (push) Successful in 17s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Format (push) Successful in 10s
CI / Classify changes (pull_request) Successful in 19s
CI / Web (lint + typecheck + i18n + build) (pull_request) Skipped
CI / Clippy (push) Successful in 2m48s
CI / Config consistency (pull_request) Successful in 7s
CI / Test (push) Successful in 7m23s
CI / Format (pull_request) Successful in 13s
CI / Clippy (pull_request) Successful in 2m44s
CI / Test (pull_request) Successful in 10m39s
CI / CUDA type-check (push) Successful in 21m56s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
CI / CUDA type-check (pull_request) Successful in 22m39s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
Two instrument defects, both of which made the measurement unreadable
rather than wrong-by-a-little.

**The verify cost was one sample.** It moved 57.3 -> 50.1 ms between
otherwise identical runs — ±7 ms against a 45 ms decode — which is wide
enough to decide the >=2x gate by itself. It is now the median of
`--verify-samples` (default 9), reported with min and max so the spread
is visible rather than implied.

Moving it after the measurement loop also deletes machinery: each sample
advances the target's cache by K+1, which is harmless once scoring is
done, so the loop no longer has to clear the caches and re-prefill a
23k-token prompt to undo a single timing.

**The leader was invisible.** The probe initialised no tracing
subscriber, so only the subprocess ranks — which set up their own — ever
logged. Every diagnosis of a leader-side failure was therefore made from
the one line in the returned error. With `tracing_subscriber` installed
the leader's per-layer load trace and its `quant=Some(Q6K)` construction
line appear, which is what finally settled that quantisation had been
applied all along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XViS3RVfGTRaRSopLN68MA
2026-09-23 17:53:39 +03:00
grenadeandClaude Opus 5 4466d5cbfc feat(tp): MTP draft head on the leader, and a TP probe mode (#96)
CI / Classify changes (push) Successful in 46s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Config consistency (push) Successful in 7s
CI / Format (push) Successful in 11s
CI / Clippy (push) Successful in 3m42s
CI / CUDA type-check (push) Successful in 19m5s
CI / Test (push) Successful in 11m27s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
The 27B is the model #96 is actually about, and nothing could measure
it: 54 GB in bf16 does not fit one 5090, and quantisation is reachable
only through the TP loader. This makes it measurable.

The head is loaded **unsharded, on the leader only**, which is correct
rather than lazy. After each row-parallel AllReduce every rank holds the
same hidden state, and lm_head is replicated — so a head fed the
leader's hidden produces exactly what a sharded one would. It also
issues no collectives, which means drafting cannot leave a rank waiting,
and the subprocess ranks are spared 810 MB apiece for weights they would
never run.

    TpQwen3_5ForCausalLM::{load_mtp_head, mtp_prefill_chunk,
                           mtp_draft, mtp_advance}
    Job::{TpLoadMtpHead, TpMtpPrefillChunk, TpMtpDraft, TpMtpAdvance}
    DeviceWorkerHandle::tp_mtp_* (leader-only; no RPC, no fan-out)

`mtp_draft` owns its snapshot/restore pair so a caller cannot forget it,
and restores even when a draft step fails — a half-walked head is worse
than no draft, because every later round inherits it.

The model now retains the last forward's hidden state (one Arc clone per
forward, no copy): the head runs after the target's step, outside the
collective, so it cannot recompute it.

The probe grows --tensor-parallel/--quant/--worker-binary. It spawns the
`neuron` binary for the worker ranks, since the probe implements no
--worker of its own.

CUDA-only throughout, so every step was type-checked with
`cargo check --features cuda` on a CUDA host before pushing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XViS3RVfGTRaRSopLN68MA
2026-09-23 14:44:09 +03:00
grenadeandClaude Opus 5 ec0dd319e8 feat(tp): multi-position logits across every rank (#96, S2 mirror)
CI / Config consistency (push) Successful in 12s
CI / Classify changes (push) Successful in 16s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 3m24s
CI / Test (push) Successful in 8m17s
CI / CUDA type-check (push) Successful in 19m6s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
The TP half of `forward_multi`. Production serves Qwen3.8-27B TP-2, so
without this the speculative verify pass — and the measurement that
decides #96 — can only run on models small enough for one card.

    TpQwen3_5ForCausalLM::forward_multi     (B, L, vocab)
    TpLeaderModel::forward_multi            arch dispatch
    WorkerRequest::GenerateStepMulti        fan-out to ranks 1..N
    WorkerModel::forward_multi              worker-side, logits dropped
    Job::TpForwardLogitsMulti               leader, via the device worker
    WorkerPool::generate_step_multi         fan-out / leader / drain

Every rank runs the same forward because the row-parallel `o_proj` and
`down_proj` issue `AllReduce`s that only complete when all ranks arrive;
the subprocess ranks discard their logits, and the leader's are the
whole answer because `lm_head` is replicated. The pool method keeps the
shape `generate_step` established, including the watchdog around the
leader's forward and the unconditional drain — skipping the drain on a
leader error leaves stale replies that poison the next request's recv.

The older dense `qwen3` arch has no multi-position path and says so
rather than quietly returning the last row.

CUDA-only code, so `cargo check --features cuda` was run on a CUDA host
before pushing rather than discovered in CI. Two wrapper enums needed
the method forwarded, which a CPU build cannot see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XViS3RVfGTRaRSopLN68MA
2026-09-23 13:51:15 +03:00
grenadeandClaude Opus 5 744c99fc22 fix(mtp-probe): chunk the prefill, like the serving path does
CI / Config consistency (push) Successful in 6s
CI / Classify changes (push) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m55s
CI / Test (push) Successful in 8m43s
CI / CUDA type-check (push) Successful in 19m40s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
A one-shot prefill builds an L x L causal mask — 2.5 GB at 25k tokens —
and OOMs a 12 GB card before a single weight is touched. That is also
not the shape of work the serving path does, which chunks.

The head trails the target by one token (at position i it takes the
embedding of token i+1 with the target's hidden state at i), so each
chunk reaches one token into the next; the final prompt position has no
successor until the first token is generated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XViS3RVfGTRaRSopLN68MA
2026-09-23 12:22:15 +03:00
grenadeandClaude Opus 5 ee5f84af7b feat(neuron): MTP draft-cost and acceptance probe (#96, S3/S4)
CI / Config consistency (push) Successful in 6s
CI / Classify changes (push) Successful in 16s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m47s
CI / Test (push) Successful in 7m14s
CI / CUDA type-check (push) Canceled after 22m3s
CI / Build cortex SRPM (push) Canceled after 0s
CI / Build neuron SRPM (push) Canceled after 0s
CI / Publish cortex to COPR (push) Canceled after 0s
CI / Publish neuron to COPR (push) Canceled after 0s
CI / Bump version in source (push) Canceled after 0s
The two numbers that decide #96 cannot be taken from anyone else's
benchmark: what a draft step costs against a target decode step on our
hardware, and how many drafted tokens the target actually agrees with on
our workloads. Every published acceptance figure is short-form (GSM8K,
MATH-500, HumanEval, <=4k output); helexa serves 15-50k-token agentic
contexts full of replayed reasoning.

`neuron-mtp-probe` measures both against a real checkpoint. It commits
nothing and changes no serving behaviour: at each decode step it
snapshots the head's KV, drafts K tokens by chaining the head, restores
the snapshot, and lets the target continue exactly as it would have.
The target's output is never influenced by the draft, which is what
makes this an observer rather than half an implementation.

Also measured, because it costs nothing once the observer exists: how
often the target's raw argmax differs from its argmax *after* the
repeat penalty neuron applies when serving. That gap is what a
distribution-preserving rejection scheme would have to reconcile at
temperature > 0, and this fleet serves this family at 0.6 — so the
question is live rather than hypothetical.

`MtpHead` gains `snapshot_kv`/`restore_kv` (pass-throughs to the layer's
existing pair) and `Qwen3_5ForCausalLM` gains `lm_head`, since draft and
target logits come from the same projection.

Not shipped in the RPM. The 27B needs the TP mirror before it can be
measured this way: 54 GB in bf16 does not fit one 5090 and the
single-GPU qwen3_5 path is bf16-only, so quantisation is reachable only
through the TP loader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XViS3RVfGTRaRSopLN68MA
2026-09-23 11:58:55 +03:00
grenade f72c1e4c05 Merge pull request 'feat(neuron): MTP draft head + multi-position logits (#96, S0–S2)' (#331) from feat/96-qwen3_5-mtp-head into main
build-prerelease / Resolve version stamps + change detection (push) Successful in 14s
build-prerelease / Build cortex binary (push) Skipped
build-prerelease / Build helexa-bench binary (push) Skipped
build-prerelease / Build helexa-upstream binary (push) Skipped
build-prerelease / Build helexa-angels binary (push) Skipped
build-prerelease / Build helexa-router binary (push) Skipped
build-prerelease / Build helexa-tools binary (push) Skipped
build-prerelease / Package cortex RPM (push) Skipped
build-prerelease / Package helexa-bench RPM (push) Skipped
build-prerelease / Package helexa-upstream RPM (push) Skipped
build-prerelease / Package helexa-angels RPM (push) Skipped
build-prerelease / Package helexa-router RPM (push) Skipped
build-prerelease / Package helexa-tools RPM (push) Skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m46s
build-prerelease / Build neuron-ada (push) Successful in 2m33s
build-prerelease / Build neuron-ampere (push) Successful in 2m34s
build-prerelease / Test (push) Successful in 7m34s
build-prerelease / Build neuron-blackwell (push) Successful in 22m24s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m12s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m16s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m21s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 33s
2026-09-23 08:39:24 +00:00
grenadeandClaude Opus 5 912f7a574b test(qwen3_5): compare forward_multi to forward by tolerance, not bits
CI / Classify changes (push) Successful in 25s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Format (push) Successful in 11s
CI / Config consistency (pull_request) Successful in 9s
CI / Config consistency (push) Successful in 8s
CI / Classify changes (pull_request) Successful in 18s
CI / Web (lint + typecheck + i18n + build) (pull_request) Skipped
CI / Format (pull_request) Successful in 18s
CI / Clippy (push) Successful in 3m21s
CI / Test (push) Successful in 9m33s
CI / Clippy (pull_request) Failing after 7s
CI / Test (pull_request) Failing after 7s
CI / CUDA type-check (pull_request) Successful in 19m50s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
CI / CUDA type-check (push) Successful in 27m37s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
CI caught this and local runs did not, which is the useful part.

`forward` slices the last position *before* the LM head and multiplies
`(1, 1, H) x (H, vocab)`; `forward_multi` multiplies `(1, L, H) x (H,
vocab)` and slices after. Different GEMM shapes accumulate in a
different order, so the two differ in the last ULP or two — on this
fixture, 0.0016435327 against 0.0016435329. `assert_eq!` on the raw
f32s therefore asserted something about one CPU's BLAS blocking rather
than about the model: it passed here and failed on the runner.

Now: max absolute difference under 1e-5, plus an argmax assertion,
which is the property the verify pass actually depends on — it compares
tokens, not floats. The row-distinctness check stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XViS3RVfGTRaRSopLN68MA
2026-09-23 11:06:25 +03:00
grenadeandClaude Opus 5 268387ae3f feat(neuron): logits at every position of a multi-token forward (#96, S2)
CI / Classify changes (push) Successful in 17s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Format (push) Successful in 15s
CI / Config consistency (push) Successful in 10s
CI / Config consistency (pull_request) Successful in 14s
CI / Classify changes (pull_request) Successful in 22s
CI / Web (lint + typecheck + i18n + build) (pull_request) Skipped
CI / Format (pull_request) Successful in 13s
CI / Clippy (push) Successful in 2m50s
CI / Test (pull_request) Failing after 11m41s
CI / Test (push) Failing after 11m32s
CI / Clippy (pull_request) Successful in 2m46s
CI / CUDA type-check (pull_request) Successful in 18m41s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
CI / CUDA type-check (push) Successful in 27m43s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
A speculative verify pass forwards the drafted block in one pass and
needs the target's own token at each drafted position. The forward
already computes all of them — chunked prefill depends on it — and one
slice throws them away:

    hidden.i((.., l - 1.., ..))?.apply(&self.lm_head)

So this adds the path that declines to, end to end and additive: nothing
existing changes shape or call site.

    Qwen3_5ForCausalLM::forward_multi   (B, L, vocab)
    ModelArch::forward_multi            dispatch, no squeeze_to_vocab
    Job::ForwardLogitsMulti             one CPU row per position
    DeviceWorkerHandle::forward_logits_multi

`squeeze_to_vocab` is deliberately not applied: it bails on a
non-singleton leading dim, which is exactly the axis this exists to
keep.

The equivalence test is the point of the stage. `forward_multi`'s last
row must be bit-identical to what `forward` returns for the same tokens
at the same offset — the whole verify scheme rests on those logits being
the ones the model would have produced anyway — and its first row must
differ from its last, so a head that returned one row L times cannot
pass.

Per-position rows cross the channel as `Vec<Vec<f32>>`: at K+1 = 5 and a
248k vocabulary that is ~5 MB a round. Fine for a measurement path,
recorded in the dispatch doc as the first thing to replace with a
device-side argmax if the loop ever runs in production.

TP (`WorkerRequest::GenerateStepMulti`) is not here — single-GPU first,
which is also where S3/S4 will run, since `Qwen/Qwen3.5-0.8B` ships the
same MTP head at 39 MB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XViS3RVfGTRaRSopLN68MA
2026-09-23 10:32:53 +03:00
grenadeandClaude Opus 5 8fa665b428 feat(qwen3_5): load and run the MTP draft head (#96, S0/S1)
CI / Classify changes (push) Successful in 18s
CI / Web (lint + typecheck + i18n + build) (push) Skipped
CI / Config consistency (push) Successful in 7s
CI / Config consistency (pull_request) Successful in 7s
CI / Classify changes (pull_request) Successful in 26s
CI / Web (lint + typecheck + i18n + build) (pull_request) Skipped
CI / Format (push) Successful in 15s
CI / Format (pull_request) Successful in 11s
CI / Clippy (push) Successful in 2m35s
CI / Clippy (pull_request) Successful in 2m37s
CI / Test (push) Successful in 14m6s
CI / Test (pull_request) Successful in 11m59s
CI / Build cortex SRPM (pull_request) Canceled after 0s
CI / Build cortex SRPM (push) Canceled after 0s
CI / Build neuron SRPM (pull_request) Canceled after 0s
CI / Publish cortex to COPR (pull_request) Canceled after 0s
CI / CUDA type-check (push) Canceled after 14m14s
CI / Build neuron SRPM (push) Canceled after 0s
CI / Publish cortex to COPR (push) Canceled after 0s
CI / CUDA type-check (pull_request) Canceled after 13m24s
CI / Publish neuron to COPR (pull_request) Canceled after 0s
CI / Publish neuron to COPR (push) Canceled after 0s
CI / Bump version in source (pull_request) Canceled after 0s
CI / Bump version in source (push) Canceled after 0s
`Qwen/Qwen3.8-27B` ships a complete extra decoder layer under `mtp.*`
that nothing here has ever loaded — 15 tensors of 1199, 810 MB BF16 —
and `Qwen/Qwen3.5-0.8B` ships the same head at 39 MB. It is the drafter
speculative decoding needs, already inside the weights we download,
store and ignore. This loads it and runs one draft step. Nothing calls
it on the serving path yet.

Shapes, read from the deployed checkpoint rather than assumed:

    mtp.fc.weight                     [5120, 10240]   Linear(2H -> H)
    mtp.layers.0.self_attn.q_proj     [12288, 5120]   output-gated
    mtp.layers.0.self_attn.{k,v}_proj [1024, 5120]    GQA 24/4
    mtp.layers.0.self_attn.o_proj     [5120, 6144]
    mtp.layers.0.mlp.{gate,up,down}   17408 wide, dense
    + input/post_attention/q/k norms, mtp.norm, two pre_fc norms

So the head is an ordinary full-attention block plus one projection,
and `Qwen3_5DecoderLayer` loads it as-is. It gains `load_typed`, which
states the flavour instead of looking it up: `mtp.layers.0` sits outside
the `layer_types` list that indexes the target's layers, and asking that
list about it answers for a different layer that happens to share the
index.

Three things the tensor shapes cannot settle were read from vLLM's
`qwen3_next_mtp.py`, not inferred:

- the concatenation is `cat[embedding, hidden]`, and since both halves
  are `hidden_size` wide the reversed reading loads, runs and returns
  finite logits that are a different model. Pinned by value in a test
  that fails on the swap.
- each half is normalised by its own norm before the concatenation,
  never once over the joined 2H.
- the hidden state is the target's post-final-norm output — the tensor
  its `lm_head` consumes — because `Qwen3NextModel::forward` returns
  `self.norm(...)` and the runner hands that to the head. The sibling
  head in `arch/qwen4_exp/mtp.rs` takes its pre-mixer stream instead;
  the two do not generalise to each other.

`forward_with_hidden` returns logits alongside that hidden state, and
`embed_tokens` exposes the embedding the head borrows (the checkpoint
carries neither an `mtp.lm_head` nor an `mtp.embed_tokens`, and
`mtp_use_dedicated_embeddings` is false).

Next, per the staging on #96: multi-position logits (S2), the draft-cost
probe (S3), then the acceptance observer (S4) — which is the go/no-go,
measures acceptance on replayed agentic sessions rather than GSM8K, and
needs no rollback machinery because it commits nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XViS3RVfGTRaRSopLN68MA
2026-09-23 10:15:46 +03:00
grenade e62cf12b92 Merge pull request 'fix(responses): let the model's template decide reasoning replay (#329)' (#330) from fix/329-reasoning-replay-follows-the-template into main
build-prerelease / Resolve version stamps + change detection (push) Successful in 17s
build-prerelease / Build cortex binary (push) Skipped
build-prerelease / Build helexa-bench binary (push) Skipped
build-prerelease / Build helexa-upstream binary (push) Skipped
build-prerelease / Build helexa-angels binary (push) Skipped
build-prerelease / Build helexa-router binary (push) Skipped
build-prerelease / Build helexa-tools binary (push) Skipped
build-prerelease / Package cortex RPM (push) Skipped
build-prerelease / Package helexa-bench RPM (push) Skipped
build-prerelease / Package helexa-upstream RPM (push) Skipped
build-prerelease / Package helexa-angels RPM (push) Skipped
build-prerelease / Package helexa-router RPM (push) Skipped
build-prerelease / Package helexa-tools RPM (push) Skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m6s
build-prerelease / Test (push) Successful in 8m19s
build-prerelease / Build neuron-ada (push) Successful in 2m32s
build-prerelease / Build neuron-ampere (push) Successful in 2m37s
build-prerelease / Build neuron-blackwell (push) Successful in 22m10s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m8s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m8s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m22s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 40s
2026-09-23 05:23:04 +00:00
grenadeandClaude Opus 5 016c803c48 fix(responses): let the model's template decide reasoning replay (#329)
CI / Classify changes (push) Successful in 20s
CI / Config consistency (push) Successful in 7s
CI / Format (push) Successful in 10s
CI / Web (lint + typecheck + i18n + build) (push) Successful in 1m54s
CI / Clippy (push) Successful in 4m23s
CI / Test (push) Successful in 9m41s
CI / CUDA type-check (push) Successful in 28m28s
CI / Build cortex SRPM (push) Skipped
CI / Build neuron SRPM (push) Skipped
CI / Publish cortex to COPR (push) Skipped
CI / Publish neuron to COPR (push) Skipped
CI / Bump version in source (push) Skipped
CI / Classify changes (pull_request) Successful in 17s
CI / Format (pull_request) Successful in 9s
CI / Config consistency (pull_request) Successful in 7s
CI / Web (lint + typecheck + i18n + build) (pull_request) Successful in 2m45s
CI / Clippy (pull_request) Successful in 2m39s
CI / Test (pull_request) Successful in 9m56s
CI / CUDA type-check (pull_request) Successful in 19m34s
CI / Build cortex SRPM (pull_request) Skipped
CI / Build neuron SRPM (pull_request) Skipped
CI / Publish cortex to COPR (pull_request) Skipped
CI / Publish neuron to COPR (pull_request) Skipped
CI / Bump version in source (pull_request) Skipped
`prune_stale_reasoning` deleted prior turns' `reasoning_content` in the
translator, unconditionally, before the template could see it. That was
a third policy — ours — that no model vendor implements: every vendor's
rule is tool-loop aware, keeping thinking within the current tool-calling
episode and dropping it across user turns, which is exactly what Qwen's
`last_query_index` does and what Anthropic requires inside a tool-use
turn. Ours kept one turn regardless of episode; for a 12-step tool loop
that is 1 turn where every vendor keeps 12.

It also made the prompt non-append-only. The think block the model
generated into the KV cache was deleted on the next render, so turn N's
token sequence stopped being a prefix of turn N+1's. Measured on beast
across the merge that shipped it, prefix-cache reuse on this surface
went 53.9% -> 0.5% and stayed there; a 6-hour agentic run prefilled
6,666,401 prompt tokens with 1,649 cached (0.02%), about 82 minutes of
recomputation.

The premise it was written on has also expired. H7 argued that "Qwen's
own multi-turn convention strips prior think blocks" — true for Qwen3
and 3.5, whose cards carry "No Thinking Content in History" and whose
templates have no knob. That item is absent from the 3.6 and 3.8 cards:
3.6 is "additionally trained to preserve and leverage thinking traces
from historical messages", and 3.8 defaults to replay and advertises
improved KV-cache utilisation. We were stripping what this model was
post-trained to consume.

Policy now lives where it is expressible — the template, via
`chat_template_kwargs.preserve_thinking`, precedence request > operator >
template default. Models whose template has no knob (Qwen3, 3.5) strip
as before; Qwen3.8 replays, as its authors intended. Clients can set it:
pi forwards arbitrary body keys verbatim through `samplingParams`.

Deliberately NOT included, tracked in #329: the `reasoning.context`
enum alias, the Anthropic `thinking` -> `reasoning_content` arm that
currently mangles replayed blocks into raw JSON, and the A/B that
measures whether replay helps this model rather than only caching
better.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XViS3RVfGTRaRSopLN68MA
2026-09-23 07:41:55 +03:00
grenade 230d243678 Merge branch 'fix/324-qsa-visible-set'
build-prerelease / Resolve version stamps + change detection (push) Successful in 15s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m37s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 2m33s
build-prerelease / Build neuron-ampere (push) Successful in 2m34s
build-prerelease / Test (push) Successful in 8m10s
build-prerelease / Build neuron-blackwell (push) Successful in 14m40s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m3s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m8s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m8s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 25s
2026-09-08 23:24:16 +03:00
grenadeandClaude Opus 5 0086a5437c fix(qwen4_exp): cut QSA blocks from the visible set, not the position axis
CI / Config consistency (push) Successful in 8s
CI / Classify changes (push) Successful in 15s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m32s
CI / Test (push) Successful in 8m37s
CI / CUDA type-check (push) Successful in 19m6s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
`BlockSelector` derived block extents arithmetically from a count: block
`b` covered positions `b * block_size ..`, and the tail ran to
`n_visible`. That is correct only while the positions a query may attend
are the contiguous range `0..n_visible`. The reference makes no such
assumption -- it takes `nonzero` of the attention mask and chunks
whichever positions survive, in order.

Under a plain causal mask the two agree exactly, which is why full-model
parity passes and always would have. Under left padding or a ragged
batch they diverge completely: the query is handed positions that are
not merely the wrong block but ones the mask forbids it from seeing, and
the result is a plausible answer computed against the wrong past.

`VisibleSet` is now what selection takes. `Contiguous(n)` covers the
causal case without materialising anything -- the set reaches the
context window in size and is rebuilt per query, so a `Vec` per query
would cost more than the selection it feeds -- and `Sparse` carries an
explicit list for the general one. Both go through the same arithmetic,
which is the point: blocks are cut at *set* offsets and mapped back to
absolute positions, and those coincide only when the set starts at zero
and has no holes.

The issue proposed asserting contiguity instead, as the cheaper first
move. Two things changed that. An assertion turns a silent wrong answer
into a loud failure but leaves the model unbatchable, so it defers the
work rather than doing it. And the assertion already existed: `advance`
enforces `total == past_len + seq_len`, which is the same invariant one
level up -- a guard added here was unreachable. Its message now names
the contiguity assumption it protects, since that is the consequence a
reader needs, rather than only the cache divergence that is the symptom.

Tests drive the sparse path directly (blocks cut from a visible set with
holes land on the right absolute positions) and pin that the two
representations agree wherever both are valid, so `Contiguous` cannot
drift from the path that is checked against the reference. Mutations
verified to fail: index arithmetic in `select`, index arithmetic in
`block_starts`, and removing the guard -- which returns
`[[[0, 1, 2, 3, 4, 5]]]` for a cache that disagrees with `past_len`,
the exact plausible-looking wrong answer this is about.

Not fixed here, and larger: `past_len` is a single scalar for the whole
batch, so ragged sequences need per-sequence offsets before any of this
can be exercised. Filed separately rather than left implied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015v1FQqShiRp9rrJEEcgNER
2026-09-08 23:03:35 +03:00
grenade eb403ef941 Merge branch 'feat/310-ple-random-advice'
build-prerelease / Resolve version stamps + change detection (push) Successful in 15s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m37s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 2m30s
build-prerelease / Build neuron-ampere (push) Successful in 2m33s
build-prerelease / Test (push) Successful in 8m56s
build-prerelease / Build neuron-blackwell (push) Successful in 14m55s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m3s
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m8s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m58s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 30s
2026-09-08 21:37:07 +03:00
grenadeandClaude Opus 5 f4b3379818 perf(qwen4_exp): tell the kernel the n-gram table is read randomly
CI / Classify changes (push) Successful in 18s
CI / CUDA type-check (push) Successful in 22m57s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Config consistency (push) Successful in 10s
CI / Format (push) Successful in 13s
CI / Clippy (push) Successful in 2m39s
CI / Test (push) Successful in 8m19s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Every access through this mapping is a 320-byte row picked by a hash:
sixteen unrelated rows per token out of 320,001,536. The kernel's
default readahead assumes sequential access and faults a whole window
per touch, which is pure amplification for this pattern -- and the
table is 95 GiB on a host that is simultaneously holding the routed
experts (#318) in RAM, so every page read speculatively evicts one a
later gather wanted.

Measured on beast at `0343ae08`, the gather is the dominant decode
cost: 458.8 ms/token cold against 0.027 ms warm, a 17,000x spread, with
all 48 layers costing 86 ms and everything else (embed 31 us, lm_head
0.9 ms, sampling 0.2 ms/token) rounding to nothing.

This is as much a hypothesis test as an optimisation. 458.8 ms over
sixteen rows is 28.7 ms per 320-byte row, roughly 350x an NVMe random
read, so the cost cannot be raw read time -- it is in how the fault is
serviced. If readahead amplification and the reclaim pressure it causes
are most of that, this recovers it for twenty lines. If it is the
footprint itself, this barely moves and the table has to get smaller
(160 dims is exactly five Q4_0 blocks, so a row would be 90 bytes
against 320, and 95 GiB becomes ~27 GiB). Measuring the cheap change
first is what says which of those we would be paying for.

The advice is advisory: a failure changes performance, never
correctness, so it is logged rather than propagated, and a kernel or
filesystem that ignores MADV_RANDOM simply keeps the old behaviour.
The mmap-versus-resident oracle test still agrees row for row, which is
what establishes this changed no answers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015v1FQqShiRp9rrJEEcgNER
2026-09-08 21:04:00 +03:00
grenade 0343ae08a1 Merge branch 'feat/neuron-phase-timing'
build-prerelease / Resolve version stamps + change detection (push) Successful in 18s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m39s
build-prerelease / Build helexa-router binary (push) Successful in 1m49s
build-prerelease / Build cortex binary (push) Successful in 2m15s
build-prerelease / Build helexa-bench binary (push) Successful in 2m19s
build-prerelease / Build neuron-ampere (push) Successful in 2m36s
build-prerelease / Build neuron-ada (push) Successful in 2m35s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m38s
build-prerelease / Build helexa-angels binary (push) Successful in 3m37s
build-prerelease / Test (push) Successful in 7m47s
build-prerelease / Build neuron-blackwell (push) Successful in 14m52s
build-prerelease / Package helexa-router RPM (push) Successful in 23s
build-prerelease / Package helexa-bench RPM (push) Successful in 27s
build-prerelease / Package helexa-upstream RPM (push) Successful in 26s
build-prerelease / Package cortex RPM (push) Successful in 29s
build-prerelease / Package helexa-angels RPM (push) Successful in 30s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m11s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m20s
build-prerelease / Build helexa-tools binary (push) Successful in 1m28s
build-prerelease / Package helexa-tools RPM (push) Successful in 26s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m17s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 31s
2026-09-08 17:43:33 +03:00
grenadeandClaude Opus 5 69cbfac6b8 fix(neuron): pass the model id into the worker streaming path
CI / Config consistency (push) Successful in 10s
CI / Classify changes (push) Successful in 20s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 12s
CI / Clippy (push) Successful in 2m35s
CI / Test (push) Successful in 7m13s
CI / CUDA type-check (push) Successful in 19m13s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The metrics call added in the previous commit referenced `model_id` in
`stream_inference_via_worker`, which has no such binding: that function
gets the model name from its tracing span, and a span field is not a
value. Caught by the CUDA type-check, which is the only job that
compiles either streaming path — both are `#[cfg(feature = "cuda")]`
and neither builds locally.

Threaded through as an owned `String` (the function runs in a spawned
task) following the `_inner` clone pattern the call site already uses
for the other captured state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015v1FQqShiRp9rrJEEcgNER
2026-09-08 17:22:05 +03:00
grenadeandClaude Opus 5 5458b285ab feat(neuron): serve /metrics, with the decode phases as histograms
CI / Config consistency (push) Successful in 10s
CI / Classify changes (push) Successful in 22s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 12s
CI / Clippy (push) Successful in 2m34s
CI / Test (push) Successful in 8m15s
CI / CUDA type-check (push) Failing after 19m5s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
neuron had no metrics surface. Every signal it produced reached
Prometheus only by cortex polling `/health` every ~10s and republishing
it as gauges — the right shape for routing decisions, the wrong shape
for asking where a request's time went. An EMA sampled at 10s cannot
see inside a one-second forward, and a gauge carries no distribution.

`neuron_decode_phase_seconds{model,phase}` is the point of this: decode
time by phase, summed per request, so a dashboard can ratio one phase
against the whole without needing per-step resolution.

  sum(rate(neuron_decode_phase_seconds_sum{phase="forward"}[5m]))
    / sum(rate(neuron_decode_phase_seconds_sum[5m]))

`residual` — decode minus the three measured phases — is emitted as its
own labelled series. A phase split that quietly absorbs whatever it does
not name cannot show you the thing it failed to bracket, and unbracketed
time is exactly what a first instrument gets wrong.

Served from the existing API port rather than a second listener: 13131
is already open for the control plane, so unlike the cortex scrape this
needs no new firewalld rule. A missing recorder answers 503 rather than
an empty 200, because an empty scrape is indistinguishable from a
healthy process with nothing to report.

Histograms get explicit buckets or `metrics-exporter-prometheus` renders
them as summaries: no `_bucket` series for `histogram_quantile()`,
quantiles that cannot be aggregated across hosts, and decay to `0` when
idle so "no traffic" reads as "instant". cortex's exporter documents
this trap; the fix has to be repeated per metric.

A model whose serving path does not measure phases emits no phase series
at all rather than zeros — absent means "nobody looked", not "free".

Also corrects the monitoring assets, which asserted in three places that
neuron has no /metrics and that cortex is the only Prometheus target.
Left alone those are confidently wrong instructions for whoever wires
this up next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015v1FQqShiRp9rrJEEcgNER
2026-09-08 16:58:20 +03:00
grenadeandClaude Opus 5 0c1ddd38af feat(neuron): opt-in phase timing inside the forward pass
CI / Test (push) Blocked by required conditions
CI / CUDA type-check (push) Blocked by required conditions
CI / Config consistency (push) Successful in 10s
CI / Classify changes (push) Successful in 25s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 12s
CI / Clippy (push) Successful in 2m37s
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
The request-level split says whether a decode step's time went into the
forward, the sampler or the emit. When the answer is "the forward", it
cannot say where in the forward. This adds that: `PhaseProbe` marks
named phases within a forward pass, and qwen4_exp reports embed / ngram
/ layers / head.

Each mark synchronises the device first, which is the whole reason this
is a separate mechanism rather than a few `Instant`s. candle's CUDA ops
are asynchronous on the stream, so an `Instant` taken around GPU work
measures how fast kernels were queued, and whichever phase happens to
contain the next real synchronisation point absorbs everyone else's
cost. That produces plausible numbers attributed to the wrong phase,
which is worse than none.

Synchronising is only affordable while the phases dwarf a sync -- a few
tens of microseconds is noise against a step of hundreds of
milliseconds and material against one of single milliseconds. That
ratio is a property of how slow a model currently is rather than
something safe to assume, so the probe is off unless
`NEURON_FORWARD_PHASES` is set.

`with_enabled` exists because `new` reads the environment through a
`OnceLock` that a test cannot toggle; without it the enabled path is
unreachable from a test and the suite would only ever prove the probe
stays quiet. A failed synchronise records the phase as `UNSYNCED`
rather than reporting an elapsed time that is not the phase's own.

Both qwen4_exp parity fixtures still match the reference trace, which
is what establishes the probe is inert when disabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015v1FQqShiRp9rrJEEcgNER
2026-09-08 16:50:51 +03:00
grenadeandClaude Opus 5 e129765918 feat(neuron): split the decode loop's wall-clock into phases
CI / Config consistency (push) Successful in 9s
CI / Classify changes (push) Successful in 23s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 11s
CI / Clippy (push) Successful in 2m42s
CI / Test (push) Has started running
CI / CUDA type-check (push) Has been cancelled
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
The finest time split anywhere in the stack was prefill vs decode.
Everything a decode step actually does -- the model forward, the
device-to-host logits copy, sampling, detokenising, handing the delta to
the consumer -- landed in one opaque `decode_ms`, so "decode is slow"
could not be narrowed without reading log timestamps by hand.

`FinishTiming` gains an optional `PhaseTiming { forward_ms, sample_ms,
emit_ms }`, accumulated across a request's steps and surfaced on
`usage.helexa_timing` and the terminal journal line. Instrumented on
both streaming paths that own a decode loop: the device-worker path and
the TP path. Two archs sharing one loop, one sampler and a 248,320
vocabulary means a phase that is cheap on one and expensive on the other
is a property of the arch rather than of the loop.

Every boundary is a point the loop already synchronises at -- the
forward hands back CPU-side logits -- so this adds no device
synchronisation and measures elapsed work rather than kernel-launch
time. That is the difference between a phase timer that means something
on a CUDA stream and one that reports how fast kernels were queued.

Three deliberate choices about what the numbers claim:

- The field is an `Option`. "This path does not measure phases" has to
  stay distinguishable from "measured, and they were zero", or a
  consumer reads an uninstrumented path as an idle one. The test
  asserts the serialised form, since `skip_serializing_if` is what
  enforces it and a field comparison cannot see it.
- `emit_ms`, not `detok_ms`. The send awaits a bounded channel, so a
  slow consumer is charged there. It is emit cost, and detokenisation
  is not separated from backpressure.
- The TP path's per-step VRAM query is charged to no phase and falls
  into the residual between the three and `decode_ms`. A phase split
  that quietly absorbs whatever it does not name cannot show you the
  thing it failed to bracket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015v1FQqShiRp9rrJEEcgNER
2026-09-08 16:46:29 +03:00
grenade 76c93a0304 Merge branch 'fix/qwen4-ple-conv-launch-bound'
build-prerelease / Resolve version stamps + change detection (push) Successful in 23s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m40s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 2m32s
build-prerelease / Build neuron-ampere (push) Successful in 2m36s
build-prerelease / Test (push) Successful in 9m20s
build-prerelease / Build neuron-blackwell (push) Successful in 14m29s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m11s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m11s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m57s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 34s
2026-09-08 15:19:13 +03:00
90 changed files with 322717 additions and 169 deletions
+10 -3
View File
@@ -525,7 +525,14 @@ jobs:
cuda_home: /usr/local/cuda-13.0
build_jobs: 8
nvcc_threads: 4
cargo_features: "cuda cudnn"
# flash-attn on ampere (#337): Laya's encoder runs every
# attention through flash_attn_varlen_windowed, matching the
# PyTorch reference on a 3060 (36 vs 39 ms at 512 tokens) where
# the eager path is 1.4-1.7x slower. This also moves the
# flavour's other flash-capable paths onto flash -- qwen3_5
# full attention and Z-Image -- which validate-neuron.sh and
# validate-image.sh gate. NEURON_FLASH_ATTN=0 rolls back.
cargo_features: "cuda cudnn flash-attn"
- flavour: ada
compute_cap: "89"
runner: cuda-13.0
@@ -540,8 +547,8 @@ jobs:
build_jobs: 8
nvcc_threads: 4
# flash-attn on blackwell first (#95): beast carries the
# agentic prefill pain; ada/ampere follow once the win is
# measured. NEURON_FLASH_ATTN=0 is the runtime rollback.
# agentic prefill pain. NEURON_FLASH_ATTN=0 is the runtime
# rollback.
cargo_features: "cuda cudnn flash-attn"
runs-on: ${{ matrix.runner }}
env:
+8 -4
View File
@@ -244,10 +244,14 @@ jobs:
flavour: blackwell
load_timeout: 900
max_prompt_tokens: 131072
- host: benjy.hanzalova.internal
flavour: ada
load_timeout: 300
max_prompt_tokens: 16384
# benjy is out of the matrix while its boot NVMe is replaced
# (dead 2026-09-29): every deploy failed at SSH init against a
# host that is not there. Restore these four lines when it is
# back on the mesh.
# - host: benjy.hanzalova.internal
# flavour: ada
# load_timeout: 300
# max_prompt_tokens: 16384
- host: quadbrat.hanzalova.internal
flavour: ampere
load_timeout: 300
Generated
+4
View File
@@ -3149,6 +3149,8 @@ dependencies = [
"image",
"libc",
"memmap2",
"metrics",
"metrics-exporter-prometheus",
"minijinja",
"minijinja-contrib",
"rayon",
@@ -3164,6 +3166,8 @@ dependencies = [
"toml",
"tracing",
"tracing-subscriber",
"unicode-normalization",
"unicode-properties",
]
[[package]]
+12 -4
View File
@@ -6,10 +6,18 @@ bench UI shows.
## Topology
- **cortex** runs on `hanzalova.internal` (10.6.0.46) and is the **only**
Prometheus target — it exposes every `cortex_*` metric on `:31314`, already
labelled by `{node,model}` / `{node,device}` from its neuron poller. neuron
has no `/metrics` endpoint.
- **cortex** runs on `hanzalova.internal` (10.6.0.46) and exposes every
`cortex_*` metric on `:31314`, already labelled by `{node,model}` /
`{node,device}` from its neuron poller. This is the fleet-shaped view:
who is loaded where, how busy, how much VRAM.
- **neuron** additionally serves `/metrics` on its own API port
(`:13131`) on each GPU host. This is the request-shaped view — where a
request's time actually went, as histograms rather than polled gauges.
It exists because cortex's numbers are a ~10s poll of `/health`
republished as gauges, which cannot see inside a one-second forward and
carries no distribution. Scrape job: `prometheus-neuron.scrape.yml`.
**No firewalld rule needed** — 13131 is already open for the control
plane (`data/neuron-firewalld.xml`).
- **Prometheus + Grafana** run on `golgafrinchans.kosherinata.internal` as
podman quadlets. Host ports (from the registered band — 9090 is Cockpit,
3000 was avoided): **Prometheus `:26559`** (container config bind-mounted at
@@ -4,9 +4,12 @@
# Cockpit on that box): curl -X POST http://localhost:26559/-/reload
#
# cortex runs on hanzalova.internal (10.6.0.46) and exposes ALL fleet metrics
# on :31314 — it is the only Prometheus target in the stack (neuron has no
# /metrics; cortex's poller already relabels everything by {node,model} /
# {node,device}). Reaching it cross-DC (kosherinata -> hanzalova) requires the
# on :31314; cortex's poller already relabels everything by {node,model} /
# {node,device}. It is no longer the only target — neuron now serves its own
# /metrics on :13131 with the per-request phase histograms cortex's ~10s
# gauge poll cannot express (see prometheus-neuron.scrape.yml); the two are
# complementary, not redundant.
# Reaching cortex cross-DC (kosherinata -> hanzalova) requires the
# firewalld rule in asset/monitoring/firewalld-cortex-metrics.sh, which opens
# :31314 to this host's mesh IP (10.3.101.4) only.
#
@@ -0,0 +1,40 @@
# helexa neuron scrape job — append into the `scrape_configs:` list of
# /etc/prometheus/prometheus.yml on golgafrinchans.kosherinata.internal,
# then reload: curl -X POST http://localhost:26559/-/reload
#
# WHY A SECOND TARGET, when cortex already publishes fleet metrics.
#
# cortex's numbers come from polling each neuron's /health every ~10s and
# republishing it as gauges. That is the right shape for routing decisions
# and the wrong shape for asking where a request's time went: an EMA
# sampled at 10s cannot see inside a one-second forward, and gauges carry
# no distribution. The finest split cortex can express is prefill vs
# decode.
#
# neuron serves /metrics on its own API port (13131) — the port is already
# open in data/neuron-firewalld.xml for the cortex control plane, so unlike
# the cortex scrape this needs NO new firewalld rule.
#
# What is here that cortex cannot give you:
# neuron_decode_phase_seconds{model,phase=forward|sample|emit|residual}
# Decode time by phase, summed per request, as a real histogram.
# The `_sum` series is the point — ratio a phase against the whole to
# see where decode goes:
# sum(rate(neuron_decode_phase_seconds_sum{phase="forward"}[5m]))
# / sum(rate(neuron_decode_phase_seconds_sum[5m]))
# `residual` is decode_ms minus the three measured phases, emitted as
# its own series so unbracketed time is visible rather than divided
# silently among the phases that were measured.
# neuron_prefill_seconds / neuron_decode_seconds (histograms)
# neuron_completion_tokens_total (the per-token denominator)
#
# A model whose serving path does not measure phases emits NO phase series
# at all rather than zeros — absent means "nobody looked", not "free".
- job_name: neuron
static_configs:
- targets:
- 'beast.hanzalova.internal:13131'
- 'benjy.hanzalova.internal:13131'
- 'quadbrat.hanzalova.internal:13131'
labels:
fleet: hanzalova
+16 -4
View File
@@ -7,9 +7,10 @@
# ─────────────────────────────────────────────────────────────────────
# neuron.toml for quadbrat.hanzalova.internal
#
# 1x RTX 3060 (12 GB) — small / quantised tier. Pre-warms Qwen3-1.7B
# (bf16, ~4 GB), leaving ~7 GB for KV cache so long contexts on a small
# model still have plenty of room.
# 1x RTX 3060 (12 GB). Resident: Laya, the System-One decision model
# (#333, ~2.3 GB bf16 for its three checkpoints). It replaced the
# Qwen3-1.7B pre-warm (operator decision 2026-09-29, #341); the 1.7B is
# still cold-loadable beside it for helexa/small.
#
# Synced to /etc/neuron/neuron.toml by script/infra-setup.sh.
@@ -21,10 +22,21 @@ name = "candle"
[harness.candle]
[[default_models]]
model_id = "Qwen/Qwen3-1.7B"
model_id = "convaiinnovations/laya"
harness = "candle"
devices = [0]
# A decision request is one forward of tens of milliseconds (36 ms for
# one question over a 512-token state, 162 ms for five), so a one-slot
# queue of 8 rejects ordinary bursts. Admit
# several at once -- the device worker still runs forwards one at a
# time -- and let a deep, short-deadline queue absorb the rest. Applies
# to every model on this host, including a cold-loaded helexa/small.
[harness.candle.admission]
max_in_flight = 4
max_queue_depth = 64
max_wait_secs = 10
# Text-to-image (#204): the 12 GB tier serves the q8_0-quantized DiT
# (8.7 GB resident incl. VAE). Ceiling is 768² — naive f32 attention
# transients OOM 1024² beside the resident model; lifts to 1024² if
+21
View File
@@ -107,6 +107,27 @@ pub const DEFAULT_RESIDENCY_PRIORITY: u32 = 100;
/// grant on its own.
pub const PINNED_RESIDENCY_PRIORITY: u32 = 1000;
/// The alias a `/v1/systemone` decision request routes to when its
/// `model` is absent or names nothing this fleet serves.
///
/// Decision clients written against TypeSafe's Jev API send a `model`
/// naming *their* service (e.g. `jev-1`), and Laya's own server treats
/// any unrecognised value as "let the router choose". Every hop of the
/// serving chain applies the same rule, scoped to the decision endpoint
/// only — a chat request for an unknown model is still a 404. The
/// operator points the alias at a decision model in `[aliases]`.
pub const DECISION_DEFAULT_ALIAS: &str = "helexa/one";
/// Header carrying the concrete decision model a request was routed to.
///
/// A decision request's `model` field can name a checkpoint *inside* a
/// served model family (`multilingual`, `typed-decisions`) or a foreign
/// service, so the gateway forwards the body untouched and states the
/// routing outcome here instead of rewriting `model`. The serving node
/// uses it to pick the model and still reads `model` for a checkpoint
/// pin.
pub const DECISION_MODEL_HEADER: &str = "x-helexa-decision-model";
/// The full model catalogue.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ModelCatalogue {
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,6 +2,7 @@ pub mod anthropic;
pub mod build_info;
pub mod catalogue;
pub mod config;
pub mod decisions;
pub mod discovery;
pub mod entitlements;
pub mod error_envelope;
+13
View File
@@ -210,6 +210,19 @@ pub struct HelexaTiming {
pub prefill_ms: u64,
pub decode_ms: u64,
pub prefill_tokens: u64,
/// Decode-loop phase split, when the serving path measured it.
/// Absent (rather than zero) on paths that do not, so a consumer
/// can tell "not measured" from "measured as free". Together these
/// should roughly account for `decode_ms`; the residual is time in
/// the loop that no bracket covers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub forward_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sample_ms: Option<u64>,
/// Detokenise + hand to the consumer. Includes bounded-channel
/// backpressure, so a slow reader is charged here.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub emit_ms: Option<u64>,
}
/// Sub-counts of `Usage::completion_tokens`.
+187
View File
@@ -24,6 +24,7 @@ pub fn api_routes() -> Router<Arc<CortexState>> {
.route("/v1/completions", post(completions))
.route("/v1/responses", post(responses))
.route("/v1/images/generations", post(images_generations))
.route("/v1/systemone", post(systemone))
.route("/v1/models", get(list_models))
.route("/v1/messages", post(anthropic_messages))
.route("/health", get(health))
@@ -349,6 +350,192 @@ async fn images_generations(
})
}
/// `POST /v1/systemone` — typed decisions (TypeSafe Jev wire protocol)
/// from a decision model such as Laya (#338).
///
/// Routing differs from the other inference paths in one way: a
/// missing or unknown `model` resolves to
/// [`DECISION_DEFAULT_ALIAS`](cortex_core::catalogue::DECISION_DEFAULT_ALIAS)
/// instead of 404ing, because Jev clients name their own service there.
/// A `model` that *does* resolve — an alias, or a model this fleet
/// knows — routes as usual, so asking a text model for a decision
/// reaches neuron and gets its `wrong_modality` answer rather than a
/// silent substitution.
///
/// The body is forwarded untouched (its `model` may pin a checkpoint
/// inside the served family); the routed model travels in
/// [`DECISION_MODEL_HEADER`](cortex_core::catalogue::DECISION_MODEL_HEADER).
///
/// Metered like Jev: input tokens only. The reservation is the exact
/// upper bound — questions × the sequence cap — and settles to
/// `usage.input_tokens` from the response.
async fn systemone(
State(fleet): State<Arc<CortexState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
use cortex_core::catalogue::{DECISION_DEFAULT_ALIAS, DECISION_MODEL_HEADER};
log_inbound("systemone", "/v1/systemone", &body);
let requested = extract_model(&body);
let route = match requested.as_deref() {
Some(model) => match router::resolve(&fleet, model).await {
Err(router::RouteError::ModelNotFound(_)) => {
tracing::debug!(
requested = model,
default = DECISION_DEFAULT_ALIAS,
"unknown decision model; using the default"
);
router::resolve(&fleet, DECISION_DEFAULT_ALIAS).await
}
other => other,
},
None => router::resolve(&fleet, DECISION_DEFAULT_ALIAS).await,
};
let route = match route {
Ok(r) => r,
Err(e) => {
tracing::warn!(
handler = "systemone",
requested = requested.as_deref().unwrap_or("<none>"),
error = %e,
"route resolve failed"
);
return route_error_response(&e);
}
};
touch_model(&fleet, &route.node_name, &route.resolved_model_id).await;
let labels = [
("model", route.resolved_model_id.clone()),
("node", route.node_name.clone()),
];
metrics::counter!("cortex_requests_total", &labels).increment(1);
metrics::counter!("cortex_decision_requests_total", &labels).increment(1);
if route.cold_start {
metrics::counter!("cortex_cold_starts_total", &labels).increment(1);
}
let principal = crate::metering::principal_from_headers(&headers);
let bound = crate::metering::decision_reservation_tokens(&body);
let guard = match &principal {
Some(principal) => {
match crate::metering::reserve_or_reject(
Arc::clone(&fleet.entitlements),
principal,
bound,
)
.await
{
Ok(guard) => Some(guard),
Err(env) => return crate::error::envelope_response(env),
}
}
None => None,
};
let mut forward_headers = strip_hop_headers(&headers);
match axum::http::HeaderValue::from_str(&route.resolved_model_id) {
Ok(v) => {
forward_headers.insert(DECISION_MODEL_HEADER, v);
}
Err(_) => {
// A model id that is not a valid header value cannot have
// come from the catalogue or a neuron's model list.
return error_response(500, "api_error", "invalid_model_id", "internal error");
}
}
let url = format!("{}/v1/systemone", route.endpoint);
let start = Instant::now();
let upstream = fleet
.http_client
.post(&url)
.headers(forward_headers)
.header("content-type", "application/json")
.body(body)
.send()
.await;
let upstream = match upstream {
Ok(r) => r,
Err(e) => {
metrics::counter!("cortex_request_errors_total", &labels).increment(1);
tracing::warn!(node = %route.node_name, url = %url, error = %e, "systemone proxy failed");
return error_response(
502,
"api_error",
"upstream_unreachable",
"failed to reach the serving node",
);
}
};
let status = upstream.status();
let passthrough: Vec<(axum::http::HeaderName, axum::http::HeaderValue)> = upstream
.headers()
.iter()
.filter(|(n, _)| {
matches!(
n.as_str(),
"server-timing" | "x-inference-time-ms" | "retry-after"
)
})
.map(|(n, v)| (n.clone(), v.clone()))
.collect();
let resp_bytes = match upstream.bytes().await {
Ok(b) => b,
Err(e) => {
metrics::counter!("cortex_request_errors_total", &labels).increment(1);
tracing::warn!(node = %route.node_name, error = %e, "systemone response read failed");
return error_response(
502,
"api_error",
"upstream_read_failed",
"failed to read the serving node's response",
);
}
};
metrics::histogram!("cortex_request_duration_seconds", &labels)
.record(start.elapsed().as_secs_f64());
if status.is_success() {
let input_tokens = serde_json::from_slice::<Value>(&resp_bytes)
.ok()
.and_then(|v| v.get("usage")?.get("input_tokens")?.as_u64());
if let Some(n) = input_tokens {
metrics::counter!("cortex_decision_input_tokens_total", &labels).increment(n);
}
if let (Some(principal), Some(guard)) = (principal, guard) {
// No usage block means the node answered outside the
// contract; settle at the reserved bound rather than free.
let spent = input_tokens.unwrap_or_else(|| {
tracing::warn!(node = %route.node_name, "systemone response carried no usage");
bound
});
crate::metering::record_spend(&principal, spent, 0);
fleet
.served_usage
.add(&principal.account_id, &principal.key_id, spent);
guard.settle(spent);
}
} else {
metrics::counter!("cortex_request_errors_total", &labels).increment(1);
// guard drops here -> reservation released, no spend recorded.
}
let mut response = axum::response::Response::builder()
.status(status)
.header(axum::http::header::CONTENT_TYPE, "application/json");
for (name, value) in passthrough {
response = response.header(name, value);
}
response
.body(axum::body::Body::from(resp_bytes))
.unwrap_or_else(|_| {
error_response(500, "api_error", "response_build_failed", "internal error")
})
}
/// Forward client headers minus hop-by-hop and host headers, keeping
/// the cortex-stamped principal headers intact for neuron's fair-share
/// admission (#54).
+56
View File
@@ -239,6 +239,35 @@ fn budget_error_to_envelope(err: BudgetError) -> OpenAiError {
}
}
/// Sequence cap assumed for a decision request that does not set
/// `max_len`: the largest default any shipped Laya checkpoint uses.
const DECISION_DEFAULT_MAX_LEN: u64 = 1024;
/// The ceiling a decision request may raise `max_len` to (Laya's own
/// server caps the budget at the same value).
const DECISION_MAX_LEN_CAP: u64 = 8192;
/// Upper-bound input tokens for a `/v1/systemone` request (#338/#340).
///
/// A decision model encodes one sequence per question, each at most
/// `max_len` tokens, and bills input tokens only — so questions ×
/// `max_len` is a true ceiling, not an estimate. The node reports the
/// exact count in `usage.input_tokens` and settle corrects to it.
pub fn decision_reservation_tokens(body: &[u8]) -> u64 {
let v: serde_json::Value = serde_json::from_slice(body).unwrap_or_default();
let questions = match v.get("questions") {
Some(serde_json::Value::Object(m)) => m.len() as u64,
Some(serde_json::Value::Array(a)) => a.len() as u64,
_ => 1,
};
let max_len = v
.get("max_len")
.and_then(serde_json::Value::as_u64)
.unwrap_or(DECISION_DEFAULT_MAX_LEN)
.min(DECISION_MAX_LEN_CAP);
questions.max(1).saturating_mul(max_len.max(1))
}
/// Upper-bound tokens to reserve for a request (#52): an over-estimate of
/// the prompt plus the maximum output. `advertised_output` is the model's
/// `limit.output` (#62), used when the request omits `max_(completion_)tokens`.
@@ -271,6 +300,33 @@ fn estimate_prompt_tokens(body: &[u8]) -> u64 {
mod tests {
use super::*;
#[test]
fn decision_reservation_is_questions_times_sequence_cap() {
let body = br#"{"state":"x","questions":{"a":{},"b":{},"c":{}}}"#;
assert_eq!(
decision_reservation_tokens(body),
3 * DECISION_DEFAULT_MAX_LEN
);
let body = br#"{"state":"x","questions":{"a":{},"b":{}},"max_len":256}"#;
assert_eq!(decision_reservation_tokens(body), 512);
}
#[test]
fn decision_reservation_caps_a_raised_max_len() {
let body = br#"{"questions":{"a":{}},"max_len":1000000}"#;
assert_eq!(decision_reservation_tokens(body), DECISION_MAX_LEN_CAP);
}
#[test]
fn decision_reservation_never_zero() {
assert_eq!(
decision_reservation_tokens(b"not json"),
DECISION_DEFAULT_MAX_LEN
);
let body = br#"{"questions":{},"max_len":0}"#;
assert_eq!(decision_reservation_tokens(body), 1);
}
#[test]
fn requested_max_output_prefers_max_completion_tokens() {
let body = br#"{"model":"m","max_completion_tokens":256,"max_tokens":99}"#;
+274
View File
@@ -0,0 +1,274 @@
//! `/v1/systemone` decision proxy tests (#338).
//!
//! The mock neuron echoes what it received — the `model` field and the
//! routed-model header — so each test can assert both halves of the
//! contract: *where* cortex sent the request, and that it did not
//! rewrite the body on the way.
use axum::Router;
use axum::http::HeaderMap;
use axum::response::{IntoResponse, Json};
use axum::routing::{get, post};
use cortex_core::catalogue::{DECISION_DEFAULT_ALIAS, DECISION_MODEL_HEADER};
use cortex_core::config::{
ApiKeyConfig, EntitlementsConfig, EvictionSettings, EvictionStrategy, GatewayConfig,
GatewaySettings, NeuronEndpoint,
};
use cortex_core::entitlements::CapWindow;
use cortex_core::node::{ModelEntry, ModelStatus};
use cortex_gateway::state::CortexState;
use serde_json::{Value, json};
use std::sync::Arc;
use tokio::net::TcpListener;
const DECISION_MODEL: &str = "convaiinnovations/laya";
const TEXT_MODEL: &str = "Qwen/Qwen3-1.7B";
/// Mock neuron: `/v1/systemone` answers with a Jev-shaped body that
/// echoes the forwarded `model` and routed-model header, and reports
/// `usage.input_tokens` = 41 per question.
async fn spawn_decision_mock_neuron() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let base_url = format!("http://{addr}");
let inference_url = base_url.clone();
let app = Router::new()
.route(
"/models/{*rest}",
get(move || {
let url = inference_url.clone();
async move { Json(json!({"url": url})) }
}),
)
.route(
"/v1/systemone",
post(|headers: HeaderMap, Json(body): Json<Value>| async move {
let routed = headers
.get(DECISION_MODEL_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let questions = body
.get("questions")
.and_then(Value::as_object)
.map(|m| m.len() as u64)
.unwrap_or(0);
(
[("server-timing", "inference;dur=12.50")],
Json(json!({
"model": "laya-rl-agent",
"answers": {},
"usage": {"input_tokens": 41 * questions, "output_tokens": 0},
"echo_model": body.get("model").cloned().unwrap_or(Value::Null),
"echo_routed": routed,
})),
)
.into_response()
}),
);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
base_url
}
fn entry(id: &str, capabilities: &[&str]) -> ModelEntry {
ModelEntry {
id: id.into(),
status: ModelStatus::Loaded,
last_accessed: None,
vram_estimate_mb: Some(1000),
capabilities: capabilities.iter().map(|c| c.to_string()).collect(),
tool_call: false,
reasoning: false,
limit: None,
servable: None,
reasoning_budget: Vec::new(),
}
}
/// A gateway fronting the mock with one decision and one text model
/// loaded. `default_alias` controls whether `helexa/one` is configured;
/// `hard_cap` switches on keyed auth with that balance.
async fn spawn_gateway(neuron_url: &str, default_alias: bool, hard_cap: Option<u64>) -> String {
let entitlements = match hard_cap {
Some(cap) => EntitlementsConfig {
require_auth: true,
keys: vec![ApiKeyConfig {
key: "sk-dec".into(),
account_id: "acct-dec".into(),
key_id: Some("key-dec".into()),
hard_cap: Some(cap),
window: CapWindow::Balance,
}],
},
None => EntitlementsConfig::default(),
};
let config = GatewayConfig {
gateway: GatewaySettings {
listen: "127.0.0.1:0".into(),
metrics_listen: "127.0.0.1:0".into(),
},
eviction: EvictionSettings {
strategy: EvictionStrategy::Lru,
defrag_after_cycles: 0,
},
neurons: vec![NeuronEndpoint {
name: "mock-node".into(),
endpoint: neuron_url.to_string(),
}],
models_config: "/dev/null".into(),
entitlements,
upstream: Default::default(),
};
let mut state = CortexState::from_config(&config);
if default_alias {
state
.catalogue
.aliases
.insert(DECISION_DEFAULT_ALIAS.into(), DECISION_MODEL.into());
}
let fleet = Arc::new(state);
{
let mut nodes = fleet.nodes.write().await;
let node = nodes.get_mut("mock-node").unwrap();
node.healthy = true;
node.models
.insert(DECISION_MODEL.into(), entry(DECISION_MODEL, &["decision"]));
node.models
.insert(TEXT_MODEL.into(), entry(TEXT_MODEL, &[]));
}
let app = cortex_gateway::build_app(Arc::clone(&fleet));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
async fn send(gateway: &str, body: Value) -> (u16, HeaderMap, Value) {
let resp = reqwest::Client::new()
.post(format!("{gateway}/v1/systemone"))
.json(&body)
.send()
.await
.expect("request should reach the gateway");
let status = resp.status().as_u16();
let mut headers = HeaderMap::new();
for (k, v) in resp.headers() {
headers.insert(k.clone(), v.clone());
}
(status, headers, resp.json().await.unwrap())
}
fn request(model: Option<&str>) -> Value {
let mut body = json!({
"state": "I was charged twice this month",
"questions": {"refund": {"type": "noul", "instructions": "Refund request?"}},
});
if let Some(m) = model {
body["model"] = json!(m);
}
body
}
#[tokio::test]
async fn test_explicit_decision_model_routes_and_body_is_untouched() {
let gateway = spawn_gateway(&spawn_decision_mock_neuron().await, true, None).await;
let (status, headers, body) = send(&gateway, request(Some(DECISION_MODEL))).await;
assert_eq!(status, 200, "{body}");
assert_eq!(body["echo_routed"], DECISION_MODEL);
assert_eq!(body["echo_model"], DECISION_MODEL);
assert_eq!(body["usage"]["input_tokens"], 41);
// Upstream timing headers survive the hop.
assert_eq!(
headers.get("server-timing").and_then(|v| v.to_str().ok()),
Some("inference;dur=12.50")
);
}
#[tokio::test]
async fn test_foreign_model_name_falls_back_to_default_alias() {
// A Jev client names its own service. The request must still be
// served — routed via helexa/one — and the client's value must reach
// the node unchanged (it may be a checkpoint pin the node honours).
let gateway = spawn_gateway(&spawn_decision_mock_neuron().await, true, None).await;
let (status, _, body) = send(&gateway, request(Some("jev-1"))).await;
assert_eq!(status, 200, "{body}");
assert_eq!(body["echo_routed"], DECISION_MODEL);
assert_eq!(body["echo_model"], "jev-1");
}
#[tokio::test]
async fn test_missing_model_falls_back_to_default_alias() {
let gateway = spawn_gateway(&spawn_decision_mock_neuron().await, true, None).await;
let (status, _, body) = send(&gateway, request(None)).await;
assert_eq!(status, 200, "{body}");
assert_eq!(body["echo_routed"], DECISION_MODEL);
assert_eq!(body["echo_model"], Value::Null);
}
#[tokio::test]
async fn test_default_alias_itself_resolves() {
let gateway = spawn_gateway(&spawn_decision_mock_neuron().await, true, None).await;
let (status, _, body) = send(&gateway, request(Some(DECISION_DEFAULT_ALIAS))).await;
assert_eq!(status, 200, "{body}");
assert_eq!(body["echo_routed"], DECISION_MODEL);
}
#[tokio::test]
async fn test_known_non_decision_model_is_not_substituted() {
// A model the fleet does serve routes to itself; the node owns the
// wrong_modality answer. Silently swapping in the decision model
// would hide the client's mistake.
let gateway = spawn_gateway(&spawn_decision_mock_neuron().await, true, None).await;
let (status, _, body) = send(&gateway, request(Some(TEXT_MODEL))).await;
assert_eq!(status, 200, "{body}");
assert_eq!(body["echo_routed"], TEXT_MODEL);
}
#[tokio::test]
async fn test_unknown_model_without_default_alias_is_404() {
let gateway = spawn_gateway(&spawn_decision_mock_neuron().await, false, None).await;
let (status, _, body) = send(&gateway, request(Some("jev-1"))).await;
assert_eq!(status, 404, "{body}");
assert_eq!(body["error"]["code"], "model_not_found");
}
#[tokio::test]
async fn test_budget_below_reservation_rejected_before_dispatch() {
// One question reserves one full sequence (1024 tokens by default);
// a 100-token balance cannot cover it.
let gateway = spawn_gateway(&spawn_decision_mock_neuron().await, true, Some(100)).await;
let resp = reqwest::Client::new()
.post(format!("{gateway}/v1/systemone"))
.bearer_auth("sk-dec")
.json(&request(Some(DECISION_MODEL)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 429);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["error"]["code"], "insufficient_quota");
}
#[tokio::test]
async fn test_spend_settles_to_reported_input_tokens() {
// Balance for exactly two one-question requests at the reserved
// bound (2 × 1024). Settling to the reported 41 tokens leaves room
// for many more; settling at the bound would exhaust it on the third.
let gateway = spawn_gateway(&spawn_decision_mock_neuron().await, true, Some(2048)).await;
let client = reqwest::Client::new();
for i in 0..5 {
let resp = client
.post(format!("{gateway}/v1/systemone"))
.bearer_auth("sk-dec")
.json(&request(Some(DECISION_MODEL)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "request {i} should be affordable");
// Settlement is spawned; let it land before the next reservation.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
+44 -4
View File
@@ -153,17 +153,29 @@ pub trait Scenario: Send + Sync {
fn prompt_size(&self) -> u32;
/// Whether this scenario should run against the given model. The
/// default runs against everything except image-generation models
/// (a chat request at an image model is a 422 `wrong_modality`);
/// modality-specific scenarios override this.
/// default runs against chat models only: an image-generation model
/// or a decision model (#333) answers a chat request with a 422
/// `wrong_modality`. Modality-specific scenarios override this.
fn applies_to(&self, model: &ModelInfo) -> bool {
!model.capabilities.iter().any(|c| c == "image")
serves_chat(model)
}
/// Issue one shaped request and measure it.
async fn run(&self, ctx: &RunCtx) -> Result<ScenarioMetrics>;
}
/// Capabilities that mark a model as serving an endpoint other than
/// chat completions.
const NON_CHAT_MODALITIES: [&str; 2] = ["image", "decision"];
/// Whether a model answers `/v1/chat/completions`.
pub fn serves_chat(model: &ModelInfo) -> bool {
!model
.capabilities
.iter()
.any(|c| NON_CHAT_MODALITIES.contains(&c.as_str()))
}
/// Fixed-seed image-latency scenario (#203): one 9-step generation at a
/// configured square size against `/v1/images/generations`, recording
/// the server-side phase timing (`usage.helexa_timing`) and the metered
@@ -883,6 +895,34 @@ async fn stream_and_measure_inner(
mod tests {
use super::*;
fn model(capabilities: &[&str]) -> ModelInfo {
serde_json::from_value(serde_json::json!({
"id": "m",
"harness": "candle",
"status": "loaded",
"devices": [0],
"vram_used_mb": null,
"capabilities": capabilities,
}))
.expect("model info")
}
/// Chat scenarios skip every model that answers chat with
/// `wrong_modality`: image generators and decision models alike.
#[test]
fn chat_scenarios_skip_image_and_decision_models() {
assert!(serves_chat(&model(&[])));
assert!(serves_chat(&model(&["text", "vision"])));
assert!(!serves_chat(&model(&["image"])));
assert!(!serves_chat(&model(&["decision"])));
let image = ImageLatencyScenario {
id: "image:512".into(),
side_px: 512,
};
assert!(image.applies_to(&model(&["image"])));
assert!(!image.applies_to(&model(&["decision"])));
}
/// A burst must report server-side phase timing, or `queue_wait_ms`
/// (TTFT minus server prefill, #85) cannot be computed for the very
/// cells it was designed for, and the prefill-tok/s panel stays
+73 -17
View File
@@ -149,25 +149,81 @@ pub async fn dispatch(
let candidates = match select_cortexes(state, &model).await {
Selection::Candidates(c) => c,
Selection::UnknownModel => {
return envelope_response(
OpenAiError::new(
404,
"invalid_request_error",
"model_not_found",
format!("no operator serves model '{model}'"),
)
.with_param("model"),
);
}
Selection::NoReachableCapacity => {
return envelope_response(OpenAiError::service_unavailable(
format!("model '{model}' is temporarily unavailable on all operators"),
Some(RETRY_AFTER_SECS),
));
}
other => return selection_error(other, &model),
};
forward(state, path, headers, body, &model, candidates).await
}
/// Proxy a `/v1/systemone` decision request (#338).
///
/// Same as [`dispatch`], except that a missing `model`, or one no
/// operator serves, selects capacity for
/// [`DECISION_DEFAULT_ALIAS`](cortex_core::catalogue::DECISION_DEFAULT_ALIAS)
/// instead of 404ing: decision clients written against the Jev API name
/// their own service in `model`. The body is forwarded with the client's
/// `model` intact — cortex applies the same fallback and the serving node
/// may read it as a checkpoint pin.
pub async fn dispatch_decision(
state: &RouterState,
path: &str,
headers: HeaderMap,
body: Bytes,
) -> Response {
use cortex_core::catalogue::DECISION_DEFAULT_ALIAS;
let requested = extract_model(&body);
let (mut model, body) = match requested.as_deref() {
Some(m) => match state.aliases.get(m) {
Some(real) => {
tracing::debug!(alias = m, model = %real, "resolving tier alias");
(real.clone(), rewrite_model_in_body(&body, real))
}
None => (m.to_string(), body),
},
None => (DECISION_DEFAULT_ALIAS.to_string(), body),
};
let mut selection = select_cortexes(state, &model).await;
if selection == Selection::UnknownModel
&& let Some(default) = state.aliases.get(DECISION_DEFAULT_ALIAS)
{
tracing::debug!(requested = %model, default = %default, "unknown decision model; using the default");
model = default.clone();
selection = select_cortexes(state, &model).await;
}
match selection {
Selection::Candidates(c) => forward(state, path, headers, body, &model, c).await,
other => selection_error(other, &model),
}
}
/// The #63 envelope for a selection that produced no candidates.
fn selection_error(selection: Selection, model: &str) -> Response {
match selection {
Selection::Candidates(_) | Selection::UnknownModel => envelope_response(
OpenAiError::new(
404,
"invalid_request_error",
"model_not_found",
format!("no operator serves model '{model}'"),
)
.with_param("model"),
),
Selection::NoReachableCapacity => envelope_response(OpenAiError::service_unavailable(
format!("model '{model}' is temporarily unavailable on all operators"),
Some(RETRY_AFTER_SECS),
)),
}
}
/// Try `candidates` in order, failing over only on transport errors.
async fn forward(
state: &RouterState,
path: &str,
headers: HeaderMap,
body: Bytes,
model: &str,
candidates: Vec<CortexEndpoint>,
) -> Response {
// Try candidates in order, failing over only on transport errors. A
// genuine HTTP response (any status — including cortex's #63 429/400)
// is returned verbatim and never retried away.
+12
View File
@@ -14,6 +14,7 @@ pub fn api_routes() -> Router<Arc<RouterState>> {
.route("/v1/chat/completions", post(chat_completions))
.route("/v1/completions", post(completions))
.route("/v1/images/generations", post(images_generations))
.route("/v1/systemone", post(systemone))
.route("/v1/responses", post(responses))
.route("/v1/messages", post(messages))
.route("/v1/models", get(list_models))
@@ -63,6 +64,17 @@ async fn images_generations(
dispatch::dispatch(&state, "/v1/images/generations", headers, body).await
}
/// `POST /v1/systemone` (#338): typed decisions (TypeSafe Jev wire
/// protocol) from a decision model. Unlike the other paths an unknown or
/// missing `model` is not a 404 — see [`dispatch::dispatch_decision`].
async fn systemone(
State(state): State<Arc<RouterState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
dispatch::dispatch_decision(&state, "/v1/systemone", headers, body).await
}
async fn messages(
State(state): State<Arc<RouterState>>,
headers: HeaderMap,
+74 -1
View File
@@ -14,7 +14,7 @@ use axum::routing::post;
use axum::{Json, Router};
use cortex_core::node::CortexModelEntry;
use helexa_router::config::{CortexEndpoint, RouterConfig};
use helexa_router::dispatch::{Selection, dispatch, select_cortexes};
use helexa_router::dispatch::{Selection, dispatch, dispatch_decision, select_cortexes};
use helexa_router::state::{CortexTopology, RouterState};
use serde_json::{Value, json};
use std::collections::HashMap;
@@ -87,6 +87,7 @@ async fn mock_handler(State(m): State<MockCortex>, headers: HeaderMap, body: Byt
async fn spawn_cortex(mock: MockCortex) -> String {
let app = Router::new()
.route("/v1/chat/completions", post(mock_handler))
.route("/v1/systemone", post(mock_handler))
.with_state(mock);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
@@ -349,3 +350,75 @@ async fn ranking_prefers_loaded_then_region_then_headroom() {
let names: Vec<&str> = order.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["warm-eu", "warm-us", "cold-eu"]);
}
// ── /v1/systemone default-model fallback (#338) ──────────────────────
/// Router state with c1 serving MODEL, and `helexa/one` pointing at it
/// when `with_default` is set.
async fn decision_state(with_default: bool) -> RouterState {
let url = spawn_cortex(ok_cortex("c1")).await;
let mut state = state_with(vec![ep("c1", &url, None)], None);
if with_default {
state.aliases.insert(
cortex_core::catalogue::DECISION_DEFAULT_ALIAS.to_string(),
MODEL.to_string(),
);
}
set_topology(&state, "c1", true, true, true, 2).await;
state
}
#[tokio::test]
async fn decision_foreign_model_selects_default_and_keeps_body_model() {
// A Jev client names its own service. Capacity is chosen for the
// default decision model, and the client's `model` is forwarded
// untouched for cortex (and the node) to interpret.
let state = decision_state(true).await;
let body = Bytes::from_static(b"{\"model\":\"jev-1\",\"questions\":{}}");
let resp = dispatch_decision(&state, "/v1/systemone", HeaderMap::new(), body).await;
let (status, body) = body_json(resp).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["served_by"], "c1");
assert_eq!(body["model_seen"], "jev-1");
}
#[tokio::test]
async fn decision_missing_model_selects_default() {
let state = decision_state(true).await;
let body = Bytes::from_static(b"{\"questions\":{}}");
let resp = dispatch_decision(&state, "/v1/systemone", HeaderMap::new(), body).await;
let (status, body) = body_json(resp).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["served_by"], "c1");
assert_eq!(body["model_seen"], "");
}
#[tokio::test]
async fn decision_default_alias_is_rewritten_like_any_alias() {
let state = decision_state(true).await;
let body = Bytes::from_static(b"{\"model\":\"helexa/one\",\"questions\":{}}");
let resp = dispatch_decision(&state, "/v1/systemone", HeaderMap::new(), body).await;
let (status, body) = body_json(resp).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["model_seen"], MODEL);
}
#[tokio::test]
async fn decision_unknown_model_without_default_is_404() {
let state = decision_state(false).await;
let body = Bytes::from_static(b"{\"model\":\"jev-1\",\"questions\":{}}");
let resp = dispatch_decision(&state, "/v1/systemone", HeaderMap::new(), body).await;
let (status, body) = body_json(resp).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["error"]["code"], "model_not_found");
}
#[tokio::test]
async fn chat_unknown_model_does_not_take_the_decision_fallback() {
// The fallback is scoped to the decision endpoint.
let state = decision_state(true).await;
let body = Bytes::from_static(b"{\"model\":\"jev-1\",\"stream\":false}");
let resp = dispatch(&state, "/v1/chat/completions", HeaderMap::new(), body).await;
let (status, _) = body_json(resp).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
+16
View File
@@ -12,6 +12,14 @@ path = "src/lib.rs"
name = "neuron"
path = "src/main.rs"
# Measurement harness for the MTP speculative-decoding stages (#96).
# Not shipped in the RPM: it loads a checkpoint directly and reports
# numbers, and exists so the go/no-go gate is a measurement rather than
# an argument.
[[bin]]
name = "neuron-mtp-probe"
path = "src/bin/mtp_probe.rs"
[features]
default = []
# Enables CUDA acceleration in candle and the cudarc/nccl bindings the
@@ -47,6 +55,8 @@ cuda-integration = ["cuda"]
cortex-core.workspace = true
tokio.workspace = true
axum.workspace = true
metrics.workspace = true
metrics-exporter-prometheus.workspace = true
serde.workspace = true
serde_json.workspace = true
reqwest.workspace = true
@@ -66,6 +76,12 @@ toml.workspace = true
# transitively via candle-core.
rayon = "1"
# Unicode tables for the decision-model language detector (#339), which
# must classify characters exactly as Python's `str.isalpha` / `re` and
# `unicodedata.combining` do. Both already in the tree via tokenizers.
unicode-normalization = "0.1"
unicode-properties = "0.1"
# candle for in-process inference. CUDA support is gated behind the
# crate's `cuda` feature (default off) so the workspace builds on
# non-CUDA hosts and CI runners.
+104
View File
@@ -10,6 +10,7 @@ use crate::wire::{openai_chat, openai_responses};
use axum::Router;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::http::header;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Json};
use axum::routing::{get, post};
@@ -43,6 +44,7 @@ pub struct NeuronState {
/// Build the neuron API router.
pub fn neuron_routes() -> Router<Arc<NeuronState>> {
Router::new()
.route("/metrics", get(metrics_handler))
.route("/version", get(version_handler))
.route("/discovery", get(discovery_handler))
.route("/health", get(health_handler))
@@ -53,6 +55,7 @@ pub fn neuron_routes() -> Router<Arc<NeuronState>> {
.route("/models/{model_id}/capabilities", get(model_capabilities))
.route("/v1/chat/completions", post(chat_completions))
.route("/v1/images/generations", post(images_generations))
.route("/v1/systemone", post(systemone))
.route("/v1/responses", post(responses))
}
@@ -64,6 +67,29 @@ async fn version_handler() -> Json<cortex_core::build_info::BuildInfo> {
Json(crate::version::build_info())
}
/// `GET /metrics` — Prometheus text format.
///
/// Served from the API port rather than a second listener, so a scrape
/// target needs no extra firewall rule. A missing recorder is a 503
/// rather than an empty 200: an empty scrape looks exactly like a
/// healthy process with nothing to say, and would leave the fleet
/// silently unmonitored.
async fn metrics_handler() -> impl IntoResponse {
match crate::metrics::render() {
Some(body) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "text/plain; version=0.0.4")],
body,
)
.into_response(),
None => (
StatusCode::SERVICE_UNAVAILABLE,
"metrics recorder not installed",
)
.into_response(),
}
}
async fn discovery_handler(State(state): State<Arc<NeuronState>>) -> Json<DiscoveryResponse> {
Json(state.discovery.clone())
}
@@ -164,6 +190,7 @@ fn preflight_kind(err: &PreflightError) -> &'static str {
PreflightError::EmptyRepo { .. } => "empty_repo",
PreflightError::TpRequiresSafetensors { .. } => "tp_requires_safetensors",
PreflightError::QuantNotFound { .. } => "quant_not_found",
PreflightError::Unsupported { .. } => "unsupported_load_option",
}
}
@@ -643,6 +670,83 @@ async fn images_generations(
}
}
/// `POST /v1/systemone` (#338): typed decisions over the TypeSafe Jev
/// wire protocol, from a loaded decision model (Laya). Parsing and the
/// transport-level limits happen here; routing, validation, admission,
/// scoring and shaping in `CandleHarness::systemone`.
///
/// Errors carry the #63 envelope *and* the reference server's top-level
/// `detail` string, so OpenAI-style and Jev/laya clients both read them.
async fn systemone(
State(state): State<Arc<NeuronState>>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> axum::response::Response {
use crate::harness::candle::SystemOneError;
use cortex_core::decisions::{SystemOneRequest, limits};
let Some(candle) = state.candle.as_ref().map(Arc::clone) else {
return decision_error_response(503, "candle harness not enabled on this neuron");
};
if body.len() > limits::MAX_BODY_BYTES {
return decision_error_response(413, "request body too large");
}
let request = match SystemOneRequest::parse(&body, limits::DEFAULT_MAX_TOKEN_BUDGET) {
Ok(r) => r,
Err(e) => return decision_error_response(e.status, &e.detail),
};
let served = headers
.get(cortex_core::catalogue::DECISION_MODEL_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let principal = principal_key(&headers);
let started = std::time::Instant::now();
match candle
.systemone(served.as_deref(), request, principal.as_deref())
.await
{
Ok(response) => {
let ms = started.elapsed().as_secs_f64() * 1e3;
let mut out = Json(response).into_response();
for (name, value) in [
("server-timing", format!("inference;dur={ms:.2}")),
("x-inference-time-ms", format!("{ms:.2}")),
] {
if let Ok(v) = axum::http::HeaderValue::from_str(&value) {
out.headers_mut().insert(name, v);
}
}
out
}
Err(SystemOneError::Request(e)) => decision_error_response(e.status, &e.detail),
Err(SystemOneError::Inference(e)) => {
tracing::warn!(error = %e, "systemone request failed");
inference_error_response(e)
}
}
}
/// A rejected decision request: the #63 envelope, plus the `detail` field
/// the reference (FastAPI) server answers with.
fn decision_error_response(status: u16, detail: &str) -> axum::response::Response {
use cortex_core::error_envelope::OpenAiError;
let (kind, code) = match status {
404 => ("invalid_request_error", "model_not_found"),
413 => ("invalid_request_error", "request_too_large"),
422 => ("invalid_request_error", "invalid_decision_request"),
400..=499 => ("invalid_request_error", "invalid_request"),
_ => ("api_error", "service_unavailable"),
};
let env = OpenAiError::new(status, kind, code, detail.to_string());
let mut body = env.body();
if let Some(obj) = body.as_object_mut() {
obj.insert("detail".into(), json!(detail));
}
let status = StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_REQUEST);
(status, Json(body)).into_response()
}
fn finish_reason_from_str(s: &str) -> crate::wire::FinishReason {
use crate::wire::FinishReason;
match s {
+730
View File
@@ -0,0 +1,730 @@
//! MTP speculative-decoding measurement harness (#96, stages S3/S4).
//!
//! Two numbers decide whether speculative decoding is worth building
//! into neuron, and neither can be taken from anyone else's benchmark:
//!
//! - **`c`** — what a draft step costs relative to a target decode
//! step. Published figures are for a different drafter on different
//! hardware. Ours reads the target's own `lm_head` (~1.03 GB at q6k
//! on the 27B) on top of the head's weights, so the vocabulary, not
//! the head, may dominate.
//! - **acceptance** — how many drafted tokens the target actually
//! agrees with, *on our workloads*. Every published figure is
//! short-form (GSM8K, MATH-500, HumanEval, ≤4k output); helexa serves
//! 15–50k-token agentic contexts full of replayed reasoning, and
//! acceptance is workload-dependent. A poor rate makes speculation a
//! net loss.
//!
//! This binary answers both by running a real checkpoint. It commits
//! nothing, changes no serving behaviour, and is not shipped in the
//! RPM.
//!
//! ## What it does
//!
//! Prefills the target over a prompt, then generates greedily. At each
//! step it snapshots the draft head's KV, drafts `K` tokens by chaining
//! the head, restores the snapshot, and lets the target continue
//! normally. When the target has produced the next `K` true tokens, the
//! draft is scored against them: the accepted length is the number of
//! leading matches.
//!
//! The head is *not* the thing generating — the target's own output is
//! never influenced by the draft, which is what makes this an observer
//! rather than an implementation.
//!
//! ## Two logits, two argmaxes
//!
//! Acceptance is reported against the target's raw argmax **and**
//! against its argmax after the repeat penalty neuron applies when
//! serving. The two differ, and the gap is exactly what a
//! distribution-preserving (rejection-sampling) scheme would have to
//! reconcile at `temperature > 0` — this fleet serves this family at
//! 0.6. Measuring it here costs nothing and makes that decision an
//! informed one.
//!
//! ## Usage
//!
//! ```sh
//! neuron-mtp-probe --model /path/to/Qwen3.5-0.8B --device cuda:0 \
//! --draft-len 4 --steps 64 --prompt-file prompt.txt
//! ```
use anyhow::{Context, Result};
use candle_core::{DType, Device, IndexOp, Tensor};
use clap::Parser;
use std::path::PathBuf;
use std::time::Instant;
use neuron::harness::arch::qwen3_5::mtp::MtpHead;
use neuron::harness::arch::qwen3_5::rope::RotaryEmbedding;
use neuron::harness::arch::qwen3_5::{Config, Qwen3_5ForCausalLM};
#[derive(Parser, Debug)]
#[command(about = "Measure MTP draft cost and acceptance rate (#96)")]
struct Args {
/// Checkpoint directory: config.json, *.safetensors, tokenizer.json.
#[arg(long)]
model: PathBuf,
/// `cpu` or `cuda:N`.
#[arg(long, default_value = "cpu")]
device: String,
/// Draft tokens per round (K).
#[arg(long, default_value_t = 4)]
draft_len: usize,
/// Decode steps to measure over.
#[arg(long, default_value_t = 64)]
steps: usize,
/// Prompt file. Without one, a short built-in prompt is used — fine
/// for `c`, useless for acceptance, which is the whole point of
/// measuring on a replayed session instead.
#[arg(long)]
prompt_file: Option<PathBuf>,
/// Prefill chunk size. The mask for a one-shot prefill is L x L —
/// 2.5 GB at 25k tokens — so the probe chunks like the serving path
/// does.
#[arg(long, default_value_t = 512)]
prefill_chunk: usize,
/// Timed-but-discarded rounds before measurement starts.
#[arg(long, default_value_t = 3)]
warmup: usize,
/// Repeat penalty applied to the target's logits when scoring the
/// second argmax. Mirrors the serving default.
#[arg(long, default_value_t = 1.05)]
repeat_penalty: f32,
/// How many recent tokens the repeat penalty considers.
#[arg(long, default_value_t = 64)]
repeat_last_n: usize,
/// Tensor-parallel degree. 1 loads the model on one card; 2+ uses
/// the worker pool, which is the only way to reach a model too big
/// for a single GPU — the 27B is 54 GB in bf16 and the single-GPU
/// path is bf16-only.
#[arg(long, default_value_t = 1)]
tensor_parallel: u32,
/// Quantisation for the TP load (`q6k`, `q8_0`, …). Only the TP
/// loader applies it.
#[arg(long)]
quant: Option<String>,
/// The binary the worker ranks are spawned from. Defaults to
/// `neuron` beside this executable — the probe itself does not
/// implement `--worker`.
#[arg(long)]
worker_binary: Option<PathBuf>,
/// How many verify-shaped forwards to time after the measurement
/// loop. A single sample moved 57.3 -> 50.1 ms between otherwise
/// identical runs — ±7 ms on a 45 ms baseline, enough to move the
/// predicted speedup across the gate on its own.
#[arg(long, default_value_t = 9)]
verify_samples: usize,
/// Write the summary as JSON here as well as to stdout.
#[arg(long)]
json: Option<PathBuf>,
}
fn pick_device(spec: &str) -> Result<Device> {
if spec == "cpu" {
return Ok(Device::Cpu);
}
let idx: usize = spec
.strip_prefix("cuda:")
.ok_or_else(|| anyhow::anyhow!("--device must be 'cpu' or 'cuda:N', got '{spec}'"))?
.parse()
.context("parse cuda device index")?;
#[cfg(feature = "cuda")]
{
Ok(Device::new_cuda(idx)?)
}
#[cfg(not(feature = "cuda"))]
{
let _ = idx;
anyhow::bail!("this binary was built without the `cuda` feature")
}
}
fn argmax(row: &[f32]) -> u32 {
let mut best = 0usize;
for (i, v) in row.iter().enumerate() {
if v > &row[best] {
best = i;
}
}
best as u32
}
/// The target's argmax after the repeat penalty neuron applies when
/// serving. Same shape of penalty as `sample_with_penalty`: recent
/// tokens are divided (positive logits) or multiplied (negative ones).
fn argmax_with_penalty(row: &[f32], recent: &[u32], penalty: f32) -> u32 {
if penalty == 1.0 {
return argmax(row);
}
let mut adjusted = row.to_vec();
for &t in recent {
let i = t as usize;
if i < adjusted.len() {
adjusted[i] = if adjusted[i] > 0.0 {
adjusted[i] / penalty
} else {
adjusted[i] * penalty
};
}
}
argmax(&adjusted)
}
/// Additive causal mask, `(1, 1, l, l + offset)`.
fn causal_mask(l: usize, offset: usize, dtype: DType, dev: &Device) -> Result<Tensor> {
let total = l + offset;
let mut data = vec![0f32; l * total];
for i in 0..l {
for j in 0..total {
if j > i + offset {
data[i * total + j] = f32::NEG_INFINITY;
}
}
}
Ok(Tensor::from_vec(data, (1, 1, l, total), dev)?.to_dtype(dtype)?)
}
/// Walk the prompt through target and head, in chunks.
///
/// Two reasons this is chunked rather than one forward: the causal mask
/// is `L x L` — 2.5 GB at 25k tokens, which OOMs a 12 GB card before a
/// single weight is touched — and the serving path chunks too, so this
/// keeps the measurement on the same shape of work.
///
/// The head trails the target by one token: at position `i` it takes
/// the embedding of token `i+1` with the target's hidden state at `i`.
/// Each chunk therefore reaches one token into the next. The final
/// prompt position has no successor yet — the first generated token
/// fills that slot.
///
/// Returns the target's logits and hidden state at the last position.
#[allow(clippy::too_many_arguments)]
fn prefill(
model: &mut Qwen3_5ForCausalLM,
head: &mut MtpHead,
rotary: &RotaryEmbedding,
ids: &[u32],
chunk: usize,
dtype: DType,
dev: &Device,
sync: &dyn Fn(&Device) -> Result<()>,
) -> Result<(Tensor, Tensor)> {
let n = ids.len();
let mut last: Option<(Tensor, Tensor)> = None;
let mut at = 0usize;
while at < n {
let end = (at + chunk).min(n);
let block = Tensor::new(&ids[at..end], dev)?.unsqueeze(0)?;
let (logits, hidden) = model.forward_with_hidden(&block, at)?;
let l = end - at;
let head_end = end.min(n - 1);
if head_end > at {
let hl = head_end - at;
let shifted = Tensor::new(&ids[at + 1..head_end + 1], dev)?.unsqueeze(0)?;
let embeds = model.embed_tokens(&shifted)?;
let hid = hidden.i((.., ..hl, ..))?;
let mask = causal_mask(hl, at, dtype, dev)?;
let positions: Vec<usize> = (at..head_end).collect();
let (cos, sin) = rotary.cos_sin_at(&positions)?;
head.forward(&embeds, &hid, Some(&mask), &cos, &sin)?;
}
last = Some((logits, hidden.i((.., l - 1.., ..))?));
at = end;
sync(dev)?;
}
last.context("empty prompt")
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
if args.tensor_parallel > 1 {
return run_tp(args).await;
}
let dev = pick_device(&args.device)?;
let dtype = if matches!(dev, Device::Cpu) {
DType::F32
} else {
DType::BF16
};
// ── load ────────────────────────────────────────────────────────
let cfg_raw =
std::fs::read_to_string(args.model.join("config.json")).context("read config.json")?;
let config: Config = Config::from_config_json(&cfg_raw).context("parse config.json")?;
let text_cfg = config.text_config.clone();
anyhow::ensure!(
MtpHead::present_in(&text_cfg),
"this checkpoint declares no MTP head (mtp_num_hidden_layers = 0)"
);
let mut shards: Vec<PathBuf> = std::fs::read_dir(&args.model)
.context("read model dir")?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "safetensors"))
.collect();
shards.sort();
anyhow::ensure!(!shards.is_empty(), "no .safetensors in {:?}", args.model);
eprintln!(
"loading {} shard(s) as {dtype:?} on {:?}…",
shards.len(),
dev
);
// SAFETY: mmaps checkpoint files the operator pointed us at.
let vb =
unsafe { candle_nn::var_builder::ShardedSafeTensors::var_builder(&shards, dtype, &dev)? };
let mut model = Qwen3_5ForCausalLM::new(config, vb.clone()).context("load target model")?;
let rotary = std::sync::Arc::new(RotaryEmbedding::new(dtype, &text_cfg, &dev)?);
let mut head = MtpHead::load(&text_cfg, rotary.clone(), &vb).context("load MTP head")?;
let tok = tokenizers::Tokenizer::from_file(args.model.join("tokenizer.json"))
.map_err(|e| anyhow::anyhow!("load tokenizer: {e}"))?;
let prompt = match &args.prompt_file {
Some(p) => std::fs::read_to_string(p).context("read prompt file")?,
None => "Explain, step by step, how a bicycle derailleur shifts gears.".into(),
};
let ids: Vec<u32> = tok
.encode(prompt.as_str(), false)
.map_err(|e| anyhow::anyhow!("tokenize: {e}"))?
.get_ids()
.to_vec();
anyhow::ensure!(ids.len() >= 2, "prompt is too short to prefill");
eprintln!("prompt: {} tokens", ids.len());
let sync = |d: &Device| -> Result<()> {
#[cfg(feature = "cuda")]
{
d.synchronize()?;
}
let _ = d;
Ok(())
};
// ── prefill ─────────────────────────────────────────────────────
let n = ids.len();
let t0 = Instant::now();
let (logits, hidden) = prefill(
&mut model,
&mut head,
&rotary,
&ids,
args.prefill_chunk,
dtype,
&dev,
&sync,
)?;
eprintln!("prefill ({n} tokens): {:.2} s", t0.elapsed().as_secs_f64());
// ── the loop ────────────────────────────────────────────────────
let vocab = text_cfg.vocab_size;
let mut last_logits: Vec<f32> = logits.i((0, 0, ..))?.to_dtype(DType::F32)?.to_vec1()?;
let mut hidden_cur = hidden;
let mut produced: Vec<u32> = Vec::new();
// (step at which it was drafted, the drafted tokens)
let mut pending: Vec<(usize, Vec<u32>)> = Vec::new();
let mut accepted_hist = vec![0usize; args.draft_len + 1];
let mut accepted_hist_penalised = vec![0usize; args.draft_len + 1];
let mut penalty_disagreements = 0usize;
let mut draft_ms_total = 0f64;
let mut draft_rounds = 0usize;
let mut target_ms_total = 0f64;
let mut target_steps = 0usize;
let total_rounds = args.warmup + args.steps;
for round in 0..total_rounds {
let measuring = round >= args.warmup;
let pos = n + produced.len(); // position of the token about to be drafted from
let next = argmax(&last_logits);
let next_penalised = argmax_with_penalty(
&last_logits,
&produced[produced.len().saturating_sub(args.repeat_last_n)..],
args.repeat_penalty,
);
if next != next_penalised {
penalty_disagreements += 1;
}
produced.push(next);
// Resolve any draft whose K true tokens have now been produced.
pending.retain(|(at, drafted)| {
let have = produced.len() - at;
if have < drafted.len() {
return true;
}
let truth = &produced[*at..*at + drafted.len()];
let acc = drafted
.iter()
.zip(truth.iter())
.take_while(|(d, t)| d == t)
.count();
accepted_hist[acc] += 1;
false
});
// ── draft, then rewind ──────────────────────────────────────
let snap = head.snapshot_kv()?;
let t0 = Instant::now();
let mut drafted = Vec::with_capacity(args.draft_len);
let mut cur_token = next;
let mut cur_hidden = hidden_cur.clone();
for k in 0..args.draft_len {
let tok_t = Tensor::new(&[cur_token], &dev)?.unsqueeze(0)?;
let emb = model.embed_tokens(&tok_t)?;
let (c, s) = rotary.cos_sin_at(&[pos + k])?;
let out = head.forward(&emb, &cur_hidden, None, &c, &s)?;
let row: Vec<f32> = model
.lm_head(&out)?
.i((0, 0, ..))?
.to_dtype(DType::F32)?
.to_vec1()?;
anyhow::ensure!(row.len() == vocab, "draft logits width {}", row.len());
cur_token = argmax(&row);
drafted.push(cur_token);
cur_hidden = out;
}
sync(&dev)?;
let draft_elapsed = t0.elapsed().as_secs_f64() * 1000.0;
head.restore_kv(&snap)?;
// The head must still advance over the *true* token, or its
// cache falls behind the target's.
let tok_t = Tensor::new(&[next], &dev)?.unsqueeze(0)?;
let emb = model.embed_tokens(&tok_t)?;
let (c, s) = rotary.cos_sin_at(&[pos])?;
head.forward(&emb, &hidden_cur, None, &c, &s)?;
// ── the target's own step ───────────────────────────────────
let t0 = Instant::now();
let (logits, h) = model.forward_with_hidden(&tok_t, pos)?;
sync(&dev)?;
let target_elapsed = t0.elapsed().as_secs_f64() * 1000.0;
last_logits = logits.i((0, 0, ..))?.to_dtype(DType::F32)?.to_vec1()?;
hidden_cur = h;
if measuring {
pending.push((produced.len(), drafted));
draft_ms_total += draft_elapsed;
draft_rounds += 1;
target_ms_total += target_elapsed;
target_steps += 1;
}
}
// Anything still pending never got its K true tokens; score what is
// known rather than dropping it silently.
for (at, drafted) in &pending {
let have = produced.len() - at;
let truth = &produced[*at..];
let acc = drafted
.iter()
.zip(truth.iter())
.take_while(|(d, t)| d == t)
.count();
if acc < have {
accepted_hist[acc] += 1;
}
}
let _ = &mut accepted_hist_penalised;
// ── verify cost, sampled ────────────────────────────────────────
//
// Timed after the loop because each sample advances the target's
// cache by K+1 positions — harmless once measuring is done, and it
// spares the loop the re-prefill that undoing one sample required.
let mut verify_samples = Vec::with_capacity(args.verify_samples);
for i in 0..args.verify_samples {
let block: Vec<u32> = std::iter::repeat_n(produced[0], args.draft_len + 1).collect();
let blk = Tensor::new(block.as_slice(), &dev)?.unsqueeze(0)?;
let at = n + produced.len() + i * (args.draft_len + 1);
let t0 = Instant::now();
let _ = model.forward_multi(&blk, at)?;
sync(&dev)?;
verify_samples.push(t0.elapsed().as_secs_f64() * 1000.0);
}
verify_samples.sort_by(f64::total_cmp);
let verify_ms = verify_samples[verify_samples.len() / 2];
// ── report ──────────────────────────────────────────────────────
let rounds: usize = accepted_hist.iter().sum();
let mean_accepted: f64 = accepted_hist
.iter()
.enumerate()
.map(|(k, n)| (k * n) as f64)
.sum::<f64>()
/ rounds.max(1) as f64;
let target_ms = target_ms_total / target_steps.max(1) as f64;
let draft_ms = draft_ms_total / (draft_rounds.max(1) * args.draft_len) as f64;
let c = draft_ms / target_ms;
// One round commits 1 + accepted tokens and costs one verify plus K
// drafts, against (1 + accepted) plain decode steps.
let round_cost = verify_ms + args.draft_len as f64 * draft_ms;
let speedup = (1.0 + mean_accepted) * target_ms / round_cost;
let summary = serde_json::json!({
"model": args.model.to_string_lossy(),
"device": args.device,
"dtype": format!("{dtype:?}"),
"prompt_tokens": n,
"draft_len": args.draft_len,
"rounds_scored": rounds,
"target_decode_ms": target_ms,
"draft_step_ms": draft_ms,
"verify_ms_k_plus_1": verify_ms,
"verify_ms_min": verify_samples.first(),
"verify_ms_max": verify_samples.last(),
"verify_sample_count": verify_samples.len(),
"c_draft_over_target": c,
"mean_accepted_tokens": mean_accepted,
"accepted_histogram": accepted_hist,
"predicted_speedup": speedup,
"penalty_argmax_disagreements": penalty_disagreements,
"penalty_argmax_disagreement_rate":
penalty_disagreements as f64 / produced.len().max(1) as f64,
});
let pretty = serde_json::to_string_pretty(&summary)?;
println!("{pretty}");
if let Some(p) = &args.json {
std::fs::write(p, &pretty).context("write --json")?;
}
Ok(())
}
/// Tensor-parallel measurement (#96).
///
/// The only route to the 27B: 54 GB in bf16 does not fit one 5090, and
/// quantisation is reachable only through the TP loader. The shape
/// mirrors the single-GPU path — prefill, then draft-and-rewind at each
/// decode step — with two differences:
///
/// - the target's step fans out to every rank, because the row-parallel
/// `AllReduce`s only complete when all of them arrive;
/// - the draft head is unsharded on the leader, so drafting issues no
/// collectives at all and cannot leave a rank waiting.
#[cfg(feature = "cuda")]
async fn run_tp(args: Args) -> Result<()> {
use neuron::harness::device_worker::DeviceWorkerHandle;
use neuron::harness::tp::WorkerPool;
let cfg_raw =
std::fs::read_to_string(args.model.join("config.json")).context("read config.json")?;
let config: Config = Config::from_config_json(&cfg_raw).context("parse config.json")?;
let text_cfg = config.text_config.clone();
anyhow::ensure!(
MtpHead::present_in(&text_cfg),
"this checkpoint declares no MTP head (mtp_num_hidden_layers = 0)"
);
let mut shards: Vec<PathBuf> = std::fs::read_dir(&args.model)
.context("read model dir")?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "safetensors"))
.collect();
shards.sort();
anyhow::ensure!(!shards.is_empty(), "no .safetensors in {:?}", args.model);
let worker_bin = match &args.worker_binary {
Some(p) => p.clone(),
None => std::env::current_exe()
.context("resolve current_exe")?
.parent()
.context("executable has no parent directory")?
.join("neuron"),
};
anyhow::ensure!(
worker_bin.exists(),
"worker binary {worker_bin:?} not found — build `neuron` beside this probe, \
or pass --worker-binary"
);
let devices: Vec<u32> = (0..args.tensor_parallel).collect();
eprintln!(
"tp-{}: spawning workers from {worker_bin:?} on devices {devices:?}…",
args.tensor_parallel
);
let leader = DeviceWorkerHandle::spawn(devices[0])?;
let mut pool =
WorkerPool::spawn(&worker_bin, args.tensor_parallel, &devices, leader.clone()).await?;
pool.init_nccl(devices[0]).await?;
let leader_device = Device::new_cuda(devices[0] as usize)?;
let model_id = "mtp-probe";
let t0 = Instant::now();
let handle = pool
.load_dense_shard(
model_id,
&cfg_raw,
&shards,
&leader_device,
DType::BF16,
args.quant.clone(),
)
.await?;
eprintln!("shards loaded: {:.1} s", t0.elapsed().as_secs_f64());
let shard_strs: Vec<String> = shards
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
leader
.tp_load_mtp_head(handle, cfg_raw.clone(), shard_strs)
.await?;
eprintln!("draft head loaded on the leader");
let tok = tokenizers::Tokenizer::from_file(args.model.join("tokenizer.json"))
.map_err(|e| anyhow::anyhow!("load tokenizer: {e}"))?;
let prompt = match &args.prompt_file {
Some(p) => std::fs::read_to_string(p).context("read prompt file")?,
None => "Explain, step by step, how a bicycle derailleur shifts gears.".into(),
};
let ids: Vec<u32> = tok
.encode(prompt.as_str(), false)
.map_err(|e| anyhow::anyhow!("tokenize: {e}"))?
.get_ids()
.to_vec();
let n = ids.len();
anyhow::ensure!(n >= 2, "prompt is too short to prefill");
eprintln!("prompt: {n} tokens");
// ── prefill, chunked, target then head ──────────────────────────
let t0 = Instant::now();
let mut last_logits: Vec<f32> = Vec::new();
let mut at = 0usize;
while at < n {
let end = (at + args.prefill_chunk).min(n);
last_logits = pool
.generate_step(model_id, handle, ids[at..end].to_vec(), at)
.await?;
let head_end = end.min(n - 1);
if head_end > at {
leader
.tp_mtp_prefill_chunk(handle, ids[at + 1..head_end + 1].to_vec(), at)
.await?;
}
at = end;
}
eprintln!("prefill ({n} tokens): {:.1} s", t0.elapsed().as_secs_f64());
// ── draft / verify observation ──────────────────────────────────
let mut produced: Vec<u32> = Vec::new();
let mut pending: Vec<(usize, Vec<u32>)> = Vec::new();
let mut accepted_hist = vec![0usize; args.draft_len + 1];
let mut penalty_disagreements = 0usize;
let mut draft_ms_total = 0f64;
let mut target_ms_total = 0f64;
let mut measured = 0usize;
for round in 0..args.warmup + args.steps {
let measuring = round >= args.warmup;
let pos = n + produced.len();
let next = argmax(&last_logits);
let next_penalised = argmax_with_penalty(
&last_logits,
&produced[produced.len().saturating_sub(args.repeat_last_n)..],
args.repeat_penalty,
);
if next != next_penalised {
penalty_disagreements += 1;
}
produced.push(next);
pending.retain(|(start, drafted)| {
if produced.len() - start < drafted.len() {
return true;
}
let truth = &produced[*start..*start + drafted.len()];
let acc = drafted
.iter()
.zip(truth.iter())
.take_while(|(d, t)| d == t)
.count();
accepted_hist[acc] += 1;
false
});
let t0 = Instant::now();
let drafted = leader
.tp_mtp_draft(handle, next, pos, args.draft_len)
.await?;
let draft_elapsed = t0.elapsed().as_secs_f64() * 1000.0;
leader.tp_mtp_advance(handle, next, pos).await?;
let t0 = Instant::now();
last_logits = pool
.generate_step(model_id, handle, vec![next], pos)
.await?;
let target_elapsed = t0.elapsed().as_secs_f64() * 1000.0;
if measuring {
pending.push((produced.len(), drafted));
draft_ms_total += draft_elapsed;
target_ms_total += target_elapsed;
measured += 1;
}
}
// ── verify cost, sampled ────────────────────────────────────────
//
// After the loop, because each sample advances the target's cache by
// K+1. One sample was not enough: it moved 57.3 -> 50.1 ms between
// otherwise identical runs, wide enough to decide the gate by itself.
let mut verify_samples = Vec::with_capacity(args.verify_samples);
for i in 0..args.verify_samples {
let block: Vec<u32> = std::iter::repeat_n(produced[0], args.draft_len + 1).collect();
let at = n + produced.len() + i * (args.draft_len + 1);
let t0 = Instant::now();
let _ = pool
.generate_step_multi(model_id, handle, block, at)
.await?;
verify_samples.push(t0.elapsed().as_secs_f64() * 1000.0);
}
verify_samples.sort_by(f64::total_cmp);
let verify_ms = verify_samples[verify_samples.len() / 2];
let rounds: usize = accepted_hist.iter().sum();
let mean_accepted: f64 = accepted_hist
.iter()
.enumerate()
.map(|(k, c)| (k * c) as f64)
.sum::<f64>()
/ rounds.max(1) as f64;
let target_ms = target_ms_total / measured.max(1) as f64;
let draft_ms = draft_ms_total / (measured.max(1) * args.draft_len) as f64;
let round_cost = verify_ms + args.draft_len as f64 * draft_ms;
let summary = serde_json::json!({
"model": args.model.to_string_lossy(),
"tensor_parallel": args.tensor_parallel,
"quant": args.quant,
"prompt_tokens": n,
"draft_len": args.draft_len,
"rounds_scored": rounds,
"target_decode_ms": target_ms,
"draft_step_ms": draft_ms,
"verify_ms_k_plus_1": verify_ms,
"verify_ms_min": verify_samples.first(),
"verify_ms_max": verify_samples.last(),
"verify_sample_count": verify_samples.len(),
"c_draft_over_target": draft_ms / target_ms,
"mean_accepted_tokens": mean_accepted,
"accepted_histogram": accepted_hist,
"predicted_speedup": (1.0 + mean_accepted) * target_ms / round_cost,
"penalty_argmax_disagreements": penalty_disagreements,
"penalty_argmax_disagreement_rate":
penalty_disagreements as f64 / produced.len().max(1) as f64,
});
let pretty = serde_json::to_string_pretty(&summary)?;
println!("{pretty}");
if let Some(p) = &args.json {
std::fs::write(p, &pretty).context("write --json")?;
}
pool.unload_model(model_id).await?;
Ok(())
}
#[cfg(not(feature = "cuda"))]
async fn run_tp(_args: Args) -> Result<()> {
anyhow::bail!("--tensor-parallel > 1 requires a build with the `cuda` feature")
}
@@ -0,0 +1,478 @@
//! ModernBERT, the bidirectional encoder under every Laya checkpoint.
//!
//! Follows the structure of `candle_transformers::models::modernbert`
//! (itself a port of transformers' `ModernBertModel`), vendored rather
//! than used as-is because that module only runs correctly in f32:
//!
//! - its attention masks are built in F32 whatever the model dtype, so
//! an f16/bf16 load fails at the first `broadcast_add`;
//! - it computes the rotary angles `position × inv_freq` in the model
//! dtype, which in f16 already loses a quarter-radian of phase by
//! position 500;
//! - it loads each layer's `attn_norm` with `.ok()`, so a weight-prefix
//! mistake silently yields a model with no pre-attention norms.
//!
//! Here the angles are computed in f32 and only the resulting cos/sin
//! tables are cast; attention scores are softmaxed in f32 against f32
//! masks; and `attn_norm` is required on every layer except layer 0,
//! which ModernBERT builds as an identity.
//!
//! Precision follows PyTorch autocast, which is how the reference runs on
//! a GPU: the linear layers (and so the attention inputs) run in the
//! load dtype, while the residual stream, the embeddings and every
//! LayerNorm stay in f32. Casting the residual stream to bf16 as well
//! drifts the released English checkpoint's logits by ~0.25 over its 28
//! layers; keeping it in f32 is what autocast does and costs little.
//!
//! Masked positions use a large *finite* negative rather than `-inf`.
//! A padded query near the end of a short row can find every key in
//! its sliding window padded; with `-inf` that row's softmax is NaN,
//! and NaN in a padded row's value vector poisons the valid rows that
//! give it zero weight (`0 × NaN = NaN`). A finite value makes such a
//! row uniform instead, and valid rows never look at it.
use anyhow::{Context, Result, ensure};
use candle_core::{D, DType, Device, Module, Tensor};
use candle_nn::{Embedding, LayerNorm, Linear, VarBuilder};
use super::config::{EncoderConfig, LayerKind};
/// Additive mask value for a position that must not be attended.
pub(crate) const MASKED: f32 = -1e9;
/// Apply `lin` in its weight's dtype, casting `x` in if needed. The
/// result stays in that dtype; callers cast back to f32 where it joins
/// the residual stream.
pub(crate) fn project(lin: &Linear, x: &Tensor) -> candle_core::Result<Tensor> {
x.to_dtype(lin.weight().dtype())?.apply(lin)
}
/// A bias-free LayerNorm that takes candle's fused kernel.
///
/// `candle_nn::LayerNorm` dispatches to the fused `layer_norm` op only
/// when it has a bias; without one it falls back to six elementwise
/// kernels (mean, subtract, square, mean, divide, scale), which was a
/// quarter of the encoder's GPU time. ModernBERT's norms have no bias,
/// so they get an explicit zero one: same result, one kernel.
fn fused_layer_norm_no_bias(size: usize, eps: f64, vb: VarBuilder) -> Result<LayerNorm> {
let weight = vb.get(size, "weight")?;
let bias = weight.zeros_like()?;
Ok(LayerNorm::new(weight, bias, eps))
}
/// Precomputed rotary tables for one base, `(max_pos, head_dim / 2)`.
struct Rotary {
cos: Tensor,
sin: Tensor,
}
impl Rotary {
fn new(cfg: &EncoderConfig, theta: f64, dtype: DType, device: &Device) -> Result<Self> {
let dim = cfg.head_dim();
let inv_freq: Vec<f32> = (0..dim)
.step_by(2)
.map(|i| (1.0 / theta.powf(i as f64 / dim as f64)) as f32)
.collect();
let n = inv_freq.len();
let inv_freq = Tensor::from_vec(inv_freq, (1, n), device)?;
let max_pos = cfg.max_position_embeddings;
let pos = Tensor::arange(0u32, max_pos as u32, device)?
.to_dtype(DType::F32)?
.reshape((max_pos, 1))?;
let freqs = pos.matmul(&inv_freq)?;
Ok(Self {
cos: freqs.cos()?.to_dtype(dtype)?,
sin: freqs.sin()?.to_dtype(dtype)?,
})
}
fn apply(&self, q: &Tensor, k: &Tensor, seq_len: usize) -> Result<(Tensor, Tensor)> {
let cos = self.cos.narrow(0, 0, seq_len)?;
let sin = self.sin.narrow(0, 0, seq_len)?;
Ok((
candle_nn::rotary_emb::rope(&q.contiguous()?, &cos, &sin)?,
candle_nn::rotary_emb::rope(&k.contiguous()?, &cos, &sin)?,
))
}
}
struct Attention {
wqkv: Linear,
wo: Linear,
n_heads: usize,
head_dim: usize,
kind: LayerKind,
}
impl Attention {
fn forward(&self, xs: &Tensor, rotary: &Rotary, mask: Option<&Tensor>) -> Result<Tensor> {
let (b, l, d) = xs.dims3()?;
let qkv = project(&self.wqkv, xs)?
.reshape((b, l, 3, self.n_heads, self.head_dim))?
.permute((2, 0, 3, 1, 4))?;
// Rotary in the projection dtype, as under autocast; the scores
// are then taken in f32 (see `scaled_attention`).
let q = qkv.get(0)?;
let k = qkv.get(1)?;
let v = qkv.get(2)?.contiguous()?;
let (q, k) = rotary.apply(&q, &k, l)?;
let ctx = scaled_attention(&q, &k, &v, mask, self.head_dim)?;
let ctx = ctx.transpose(1, 2)?.reshape((b, l, d))?;
Ok(ctx.apply(&self.wo)?.to_dtype(DType::F32)?)
}
}
/// A batch packed without padding for the flash-attention path: every
/// row's tokens back to back, `(total, …)`. Linear layers then do no work
/// for padding, and flash varlen keeps rows from attending to each other.
#[cfg(feature = "flash-attn")]
pub struct Packed {
/// `(b + 1,)` u32 cumulative row lengths, as flash varlen takes them.
pub cu_seqlens: Tensor,
/// Longest row.
pub max_len: usize,
/// `(total,)` u32: each token's position within its own row.
pub positions: Tensor,
/// Where each row starts in the packed order.
pub offsets: Vec<usize>,
}
#[cfg(feature = "flash-attn")]
impl Packed {
pub fn new(lens: &[usize], device: &Device) -> Result<Self> {
let mut cu = Vec::with_capacity(lens.len() + 1);
let mut offsets = Vec::with_capacity(lens.len());
let mut positions = Vec::with_capacity(lens.iter().sum());
let mut total = 0u32;
cu.push(0u32);
for &n in lens {
offsets.push(total as usize);
positions.extend(0..n as u32);
total += n as u32;
cu.push(total);
}
let n = cu.len();
Ok(Self {
cu_seqlens: Tensor::from_vec(cu, n, device)?,
max_len: lens.iter().copied().max().unwrap_or(0),
positions: Tensor::from_vec(positions, total as usize, device)?,
offsets,
})
}
}
/// Bidirectional flash attention over a packed batch. `q`, `k`, `v` are
/// `(total, h, dh)`; `half_window` limits each query to keys within that
/// distance on either side (ModernBERT's sliding layers), `None` attends
/// the whole row. Scores are accumulated in f32 inside the kernel.
#[cfg(feature = "flash-attn")]
pub(crate) fn flash_attention(
q: &Tensor,
k: &Tensor,
v: &Tensor,
packed: &Packed,
half_window: Option<usize>,
) -> Result<Tensor> {
let head_dim = q.dim(2)?;
let scale = (head_dim as f64).powf(-0.5) as f32;
Ok(candle_flash_attn::flash_attn_varlen_windowed(
q,
k,
v,
&packed.cu_seqlens,
&packed.cu_seqlens,
packed.max_len,
packed.max_len,
scale,
half_window,
half_window,
)?)
}
/// Split a packed `(total, 3·d)` qkv projection into contiguous
/// `(total, h, dh)` q, k and v.
#[cfg(feature = "flash-attn")]
pub(crate) fn split_qkv(
qkv: &Tensor,
n_heads: usize,
head_dim: usize,
) -> Result<(Tensor, Tensor, Tensor)> {
let t = qkv.dim(0)?;
let qkv = qkv.reshape((t, 3, n_heads, head_dim))?;
let part = |i: usize| -> Result<Tensor> { Ok(qkv.narrow(1, i, 1)?.squeeze(1)?.contiguous()?) };
Ok((part(0)?, part(1)?, part(2)?))
}
#[cfg(feature = "flash-attn")]
impl Rotary {
/// Rotate packed `(total, h, dh)` q and k by each token's position.
fn apply_packed(&self, q: &Tensor, k: &Tensor, positions: &Tensor) -> Result<(Tensor, Tensor)> {
let cos = self.cos.index_select(positions, 0)?;
let sin = self.sin.index_select(positions, 0)?;
let rot = |x: &Tensor| -> Result<Tensor> {
Ok(candle_nn::rotary_emb::rope_thd(&x.unsqueeze(0)?, &cos, &sin)?.squeeze(0)?)
};
Ok((rot(q)?, rot(k)?))
}
}
#[cfg(feature = "flash-attn")]
impl Attention {
/// `xs` is the packed f32 residual stream `(total, d)`.
fn forward_packed(
&self,
xs: &Tensor,
rotary: &Rotary,
packed: &Packed,
half_window: usize,
) -> Result<Tensor> {
let (t, d) = xs.dims2()?;
let (q, k, v) = split_qkv(&project(&self.wqkv, xs)?, self.n_heads, self.head_dim)?;
let (q, k) = rotary.apply_packed(&q, &k, &packed.positions)?;
let window = match self.kind {
LayerKind::Sliding => Some(half_window),
LayerKind::Global => None,
};
let ctx = flash_attention(&q, &k, &v, packed, window)?.reshape((t, d))?;
Ok(ctx.apply(&self.wo)?.to_dtype(DType::F32)?)
}
}
/// `softmax(q kᵀ / √d + mask) v`, scores and softmax in f32.
///
/// `q`, `k`, `v` are `(b, h, l, d)`; `mask` is an f32 additive mask
/// broadcastable to `(b, h, l, l)`, or `None` when nothing is masked —
/// which saves a bandwidth-bound pass over the `(b, h, l, l)` scores.
pub(crate) fn scaled_attention(
q: &Tensor,
k: &Tensor,
v: &Tensor,
mask: Option<&Tensor>,
head_dim: usize,
) -> Result<Tensor> {
let dtype = v.dtype();
let scale = (head_dim as f64).powf(-0.5);
// Scores in f32 end to end: a bf16 `q kᵀ` is rounded to 8 mantissa
// bits before the softmax ever sees it, which on this model's
// logit-sized scores is most of the drift from the reference's GPU
// path (whose SDPA kernels keep scores in f32).
// The scale goes on q, not on the `(b, h, l, l)` scores: the same
// product, on a tensor `l / d` times smaller.
let scores = (q.to_dtype(DType::F32)? * scale)?.matmul(&k.to_dtype(DType::F32)?.t()?)?;
let scores = match mask {
Some(mask) => scores.broadcast_add(mask)?,
None => scores,
};
let probs = candle_nn::ops::softmax_last_dim(&scores)?;
Ok(probs.to_dtype(dtype)?.matmul(v)?)
}
struct Mlp {
wi: Linear,
wo: Linear,
}
impl Module for Mlp {
/// f32 in, f32 out; the projections run in the weights' dtype.
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
let xs = project(&self.wi, xs)?;
let parts = xs.chunk(2, D::Minus1)?;
(parts[0].gelu_erf()? * &parts[1])?
.apply(&self.wo)?
.to_dtype(DType::F32)
}
}
struct Layer {
attn_norm: Option<LayerNorm>,
attn: Attention,
mlp_norm: LayerNorm,
mlp: Mlp,
}
/// The two additive masks one forward needs, both f32. Either is `None`
/// when it would mask nothing: `global` for a batch with no padding (every
/// single-question request), `sliding` additionally when no row is longer
/// than the window reaches.
pub struct Masks {
/// `(b, 1, 1, l)`: padded keys masked. Global layers and the
/// decision head use this one.
pub global: Option<Tensor>,
/// `(b, 1, l, l)`: padded keys, plus keys outside the window.
pub sliding: Option<Tensor>,
}
impl Masks {
/// Build both masks from per-row valid lengths. Rows are
/// right-padded: positions `>= lens[i]` of row `i` are padding.
pub fn new(
lens: &[usize],
seq_len: usize,
half_window: usize,
device: &Device,
) -> Result<Self> {
let b = lens.len();
let padded = lens.iter().any(|&n| n < seq_len);
let windowed = seq_len > half_window + 1;
let global = if padded {
let mut pad = Vec::with_capacity(b * seq_len);
for &n in lens {
pad.extend((0..seq_len).map(|j| if j < n { 0f32 } else { MASKED }));
}
Some(Tensor::from_vec(pad, (b, 1, 1, seq_len), device)?)
} else {
None
};
if !windowed {
let sliding = global.clone();
return Ok(Self { global, sliding });
}
// Built on the device from two aranges, not on the host: at
// 1024 tokens a host-built window is a 4 MB upload per forward.
let pos = Tensor::arange(0u32, seq_len as u32, device)?.to_dtype(DType::F32)?;
let dist = pos
.reshape((seq_len, 1))?
.broadcast_sub(&pos.reshape((1, seq_len))?)?
.abs()?;
let outside = dist.gt(half_window as f64)?;
let zeros = Tensor::zeros((seq_len, seq_len), DType::F32, device)?;
let masked = Tensor::full(MASKED, (seq_len, seq_len), device)?;
let window = outside
.where_cond(&masked, &zeros)?
.reshape((1, 1, seq_len, seq_len))?;
let sliding = match &global {
Some(g) => g.broadcast_add(&window)?,
None => window,
};
Ok(Self {
global,
sliding: Some(sliding),
})
}
}
pub struct ModernBert {
embeddings: Embedding,
embed_norm: LayerNorm,
layers: Vec<Layer>,
final_norm: LayerNorm,
global_rotary: Rotary,
sliding_rotary: Rotary,
half_window: usize,
}
impl ModernBert {
/// Load from a VarBuilder rooted at the encoder's prefix (`encoder`
/// in a Laya checkpoint, `model` in a stock ModernBERT one). Linear
/// weights load in the VarBuilder's dtype; embeddings and norms in
/// f32.
pub fn load(vb: VarBuilder, cfg: &EncoderConfig) -> Result<Self> {
let d = cfg.hidden_size;
let f32_vb = vb.clone().set_dtype(DType::F32);
let embeddings = candle_nn::embedding(
cfg.vocab_size,
d,
f32_vb.pp("embeddings").pp("tok_embeddings"),
)
.context("encoder.embeddings.tok_embeddings")?;
let embed_norm = fused_layer_norm_no_bias(d, cfg.norm_eps, f32_vb.pp("embeddings.norm"))?;
let mut layers = Vec::with_capacity(cfg.num_hidden_layers);
for (i, kind) in cfg.layer_kinds.iter().copied().enumerate() {
let lvb = vb.pp(format!("layers.{i}"));
let norm_vb = f32_vb.pp(format!("layers.{i}"));
// ModernBERT builds layer 0's attn_norm as an identity and
// saves nothing for it; every other layer must have one.
let attn_norm = if lvb.contains_tensor("attn_norm.weight") {
Some(fused_layer_norm_no_bias(
d,
cfg.norm_eps,
norm_vb.pp("attn_norm"),
)?)
} else {
ensure!(
i == 0,
"encoder layer {i} has no attn_norm.weight — wrong weight prefix, \
or not a ModernBERT checkpoint"
);
None
};
let attn = Attention {
wqkv: candle_nn::linear_no_bias(d, 3 * d, lvb.pp("attn.Wqkv"))?,
wo: candle_nn::linear_no_bias(d, d, lvb.pp("attn.Wo"))?,
n_heads: cfg.num_attention_heads,
head_dim: cfg.head_dim(),
kind,
};
let mlp_norm = fused_layer_norm_no_bias(d, cfg.norm_eps, norm_vb.pp("mlp_norm"))?;
let mlp = Mlp {
wi: candle_nn::linear_no_bias(d, 2 * cfg.intermediate_size, lvb.pp("mlp.Wi"))?,
wo: candle_nn::linear_no_bias(cfg.intermediate_size, d, lvb.pp("mlp.Wo"))?,
};
layers.push(Layer {
attn_norm,
attn,
mlp_norm,
mlp,
});
}
let final_norm = fused_layer_norm_no_bias(d, cfg.norm_eps, f32_vb.pp("final_norm"))?;
let device = vb.device();
Ok(Self {
embeddings,
embed_norm,
layers,
final_norm,
global_rotary: Rotary::new(cfg, cfg.global_rope_theta, vb.dtype(), device)?,
sliding_rotary: Rotary::new(cfg, cfg.local_rope_theta, vb.dtype(), device)?,
half_window: cfg.local_attention / 2,
})
}
pub fn half_window(&self) -> usize {
self.half_window
}
/// `ids` is `(b, l)` u32; returns the final hidden states `(b, l, d)`
/// in f32.
pub fn forward(&self, ids: &Tensor, masks: &Masks) -> Result<Tensor> {
let mut xs = ids.apply(&self.embeddings)?.apply(&self.embed_norm)?;
for layer in &self.layers {
let normed = match &layer.attn_norm {
Some(norm) => xs.apply(norm)?,
None => xs.clone(),
};
let (rotary, mask) = match layer.attn.kind {
LayerKind::Global => (&self.global_rotary, masks.global.as_ref()),
LayerKind::Sliding => (&self.sliding_rotary, masks.sliding.as_ref()),
};
xs = (&xs + layer.attn.forward(&normed, rotary, mask)?)?;
let mlp_out = xs.apply(&layer.mlp_norm)?.apply(&layer.mlp)?;
xs = (xs + mlp_out)?;
}
Ok(xs.apply(&self.final_norm)?)
}
/// The flash path: `ids` is the packed `(total,)` u32 token stream;
/// returns the packed final hidden states `(total, d)` in f32.
#[cfg(feature = "flash-attn")]
pub fn forward_packed(&self, ids: &Tensor, packed: &Packed) -> Result<Tensor> {
let mut xs = ids.apply(&self.embeddings)?.apply(&self.embed_norm)?;
for layer in &self.layers {
let normed = match &layer.attn_norm {
Some(norm) => xs.apply(norm)?,
None => xs.clone(),
};
let rotary = match layer.attn.kind {
LayerKind::Global => &self.global_rotary,
LayerKind::Sliding => &self.sliding_rotary,
};
xs = (&xs
+ layer
.attn
.forward_packed(&normed, rotary, packed, self.half_window)?)?;
let mlp_out = xs.apply(&layer.mlp_norm)?.apply(&layer.mlp)?;
xs = (xs + mlp_out)?;
}
Ok(xs.apply(&self.final_norm)?)
}
}
@@ -0,0 +1,358 @@
//! The two config files a Laya checkpoint ships, and what they mean.
//!
//! A checkpoint directory holds `rl_agent_config.json` (the decision
//! model: head depth, token budgets, calibration temperatures) and
//! `encoder/config.json` (the ModernBERT backbone, written by
//! transformers 5). There is no top-level `config.json`.
//!
//! The encoder config is parsed here rather than through
//! `candle_transformers::models::modernbert::Config` because
//! transformers 5 moved the rotary bases into a nested
//! `rope_parameters.{full_attention,sliding_attention}.rope_theta`
//! block, which that struct cannot read; the older flat
//! `global_rope_theta` / `local_rope_theta` fields are still honoured
//! when a checkpoint carries them. mmBERT (the multilingual
//! checkpoint's backbone) is the case that matters: both of its bases
//! are 160,000, where the flat-field defaults would silently give the
//! sliding layers 10,000.
use anyhow::{Context, Result, bail, ensure};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::Path;
/// `rl_agent_config.json`.
#[derive(Debug, Clone, Deserialize)]
pub struct LayaConfig {
/// The backbone the head was trained on, e.g.
/// `answerdotai/ModernBERT-large`. Informational: the weights come
/// from the checkpoint, the shape from `encoder/config.json`.
#[serde(default)]
pub encoder: Option<String>,
#[serde(default = "default_head_layers")]
pub head_layers: usize,
#[serde(default = "default_max_len")]
pub max_len: usize,
#[serde(default = "default_head_max_len")]
pub head_max_len: usize,
/// One act head output per entry, plus one for "answer". The act
/// head's width is `act_costs.len() + 1`.
#[serde(default)]
pub act_costs: BTreeMap<String, f64>,
/// Per-question-type temperatures, indexed choice / score / noul.
#[serde(default = "default_temperature")]
pub temperature: Vec<f64>,
/// Temperatures fitted per `"<type>:<cardinality bucket>"`.
#[serde(default)]
pub temperature_by_options: BTreeMap<String, f64>,
/// Autocast dtype the reference runs on GPU (`"bf16"` or `"fp16"`).
#[serde(default)]
pub amp_dtype: Option<String>,
}
fn default_head_layers() -> usize {
2
}
fn default_max_len() -> usize {
512
}
fn default_head_max_len() -> usize {
192
}
fn default_temperature() -> Vec<f64> {
vec![1.0, 1.0, 1.0]
}
impl LayaConfig {
pub fn n_act(&self) -> usize {
self.act_costs.len() + 1
}
}
/// Whether an attention layer sees the whole sequence or a window.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayerKind {
Global,
Sliding,
}
/// The ModernBERT backbone shape, resolved from `encoder/config.json`.
#[derive(Debug, Clone)]
pub struct EncoderConfig {
pub vocab_size: usize,
pub hidden_size: usize,
pub num_hidden_layers: usize,
pub num_attention_heads: usize,
pub intermediate_size: usize,
pub max_position_embeddings: usize,
pub norm_eps: f64,
pub pad_token_id: u32,
/// Full sliding-window width. A token attends to keys at distance
/// `<= local_attention / 2` on either side.
pub local_attention: usize,
pub global_rope_theta: f64,
pub local_rope_theta: f64,
pub layer_kinds: Vec<LayerKind>,
}
#[derive(Deserialize)]
struct RawEncoderConfig {
vocab_size: usize,
hidden_size: usize,
num_hidden_layers: usize,
num_attention_heads: usize,
intermediate_size: usize,
max_position_embeddings: usize,
#[serde(default)]
norm_eps: Option<f64>,
#[serde(default)]
layer_norm_eps: Option<f64>,
pad_token_id: u32,
#[serde(default = "default_global_every")]
global_attn_every_n_layers: usize,
local_attention: usize,
#[serde(default)]
layer_types: Option<Vec<String>>,
#[serde(default)]
rope_parameters: Option<serde_json::Value>,
#[serde(default)]
global_rope_theta: Option<f64>,
#[serde(default)]
local_rope_theta: Option<f64>,
#[serde(default)]
norm_bias: bool,
#[serde(default)]
attention_bias: bool,
#[serde(default)]
mlp_bias: bool,
#[serde(default)]
hidden_activation: Option<String>,
}
fn default_global_every() -> usize {
3
}
/// transformers' ModernBERT defaults for checkpoints that predate
/// `rope_parameters` and also omit the flat fields.
const DEFAULT_GLOBAL_ROPE_THETA: f64 = 160_000.0;
const DEFAULT_LOCAL_ROPE_THETA: f64 = 10_000.0;
impl EncoderConfig {
pub fn from_json(text: &str) -> Result<Self> {
let raw: RawEncoderConfig =
serde_json::from_str(text).context("parse ModernBERT encoder config")?;
// Variants this port does not implement are refused, not
// approximated: each would load without error and answer wrong.
ensure!(
!raw.norm_bias && !raw.attention_bias && !raw.mlp_bias,
"ModernBERT with norm/attention/mlp biases is not supported"
);
if let Some(act) = raw.hidden_activation.as_deref() {
ensure!(
act == "gelu",
"ModernBERT hidden_activation {act:?} is not supported (only exact \"gelu\")"
);
}
ensure!(
raw.hidden_size.is_multiple_of(raw.num_attention_heads),
"hidden_size {} is not divisible by num_attention_heads {}",
raw.hidden_size,
raw.num_attention_heads
);
let (global_rope_theta, local_rope_theta) = rope_thetas(&raw)?;
let layer_kinds = match &raw.layer_types {
Some(types) => {
ensure!(
types.len() == raw.num_hidden_layers,
"layer_types has {} entries for {} layers",
types.len(),
raw.num_hidden_layers
);
types
.iter()
.map(|t| match t.as_str() {
"full_attention" => Ok(LayerKind::Global),
"sliding_attention" => Ok(LayerKind::Sliding),
other => bail!("unknown ModernBERT layer type {other:?}"),
})
.collect::<Result<Vec<_>>>()?
}
None => (0..raw.num_hidden_layers)
.map(|i| {
if i % raw.global_attn_every_n_layers == 0 {
LayerKind::Global
} else {
LayerKind::Sliding
}
})
.collect(),
};
Ok(Self {
vocab_size: raw.vocab_size,
hidden_size: raw.hidden_size,
num_hidden_layers: raw.num_hidden_layers,
num_attention_heads: raw.num_attention_heads,
intermediate_size: raw.intermediate_size,
max_position_embeddings: raw.max_position_embeddings,
norm_eps: raw.norm_eps.or(raw.layer_norm_eps).unwrap_or(1e-5),
pad_token_id: raw.pad_token_id,
local_attention: raw.local_attention,
global_rope_theta,
local_rope_theta,
layer_kinds,
})
}
pub fn head_dim(&self) -> usize {
self.hidden_size / self.num_attention_heads
}
}
/// Resolve the two rotary bases. `rope_parameters` (transformers 5)
/// wins over the flat fields; within it, a per-layer-type block wins
/// over a shared top-level `rope_theta`.
fn rope_thetas(raw: &RawEncoderConfig) -> Result<(f64, f64)> {
let mut global = raw.global_rope_theta.unwrap_or(DEFAULT_GLOBAL_ROPE_THETA);
let mut local = raw.local_rope_theta.unwrap_or(DEFAULT_LOCAL_ROPE_THETA);
if let Some(rope) = &raw.rope_parameters {
let shared = rope.get("rope_theta").and_then(|v| v.as_f64());
for (key, slot) in [
("full_attention", &mut global),
("sliding_attention", &mut local),
] {
let block = rope.get(key);
if let Some(rope_type) = block
.and_then(|b| b.get("rope_type"))
.and_then(|v| v.as_str())
{
ensure!(
rope_type == "default",
"rope_type {rope_type:?} for {key} is not supported"
);
}
let theta = block
.and_then(|b| b.get("rope_theta"))
.and_then(|v| v.as_f64())
.or(shared);
if let Some(theta) = theta {
*slot = theta;
}
}
}
Ok((global, local))
}
/// Both configs of one checkpoint directory.
#[derive(Debug, Clone)]
pub struct CheckpointConfig {
pub laya: LayaConfig,
pub encoder: EncoderConfig,
}
impl CheckpointConfig {
/// Read `rl_agent_config.json` and `encoder/config.json` from a
/// checkpoint directory (the repo root, or one of its subfolders).
pub fn from_dir(dir: &Path) -> Result<Self> {
let laya_path = dir.join("rl_agent_config.json");
let laya: LayaConfig = serde_json::from_str(
&std::fs::read_to_string(&laya_path)
.with_context(|| format!("read {}", laya_path.display()))?,
)
.with_context(|| format!("parse {}", laya_path.display()))?;
let enc_path = dir.join("encoder").join("config.json");
let encoder = EncoderConfig::from_json(
&std::fs::read_to_string(&enc_path)
.with_context(|| format!("read {}", enc_path.display()))?,
)
.with_context(|| format!("parse {}", enc_path.display()))?;
ensure!(
laya.temperature.len() == 3,
"rl_agent_config temperature must have 3 entries (choice, score, noul), got {}",
laya.temperature.len()
);
Ok(Self { laya, encoder })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> serde_json::Value {
serde_json::json!({
"vocab_size": 100, "hidden_size": 64, "num_hidden_layers": 4,
"num_attention_heads": 4, "intermediate_size": 96,
"max_position_embeddings": 512, "norm_eps": 1e-5, "pad_token_id": 0,
"global_attn_every_n_layers": 3, "local_attention": 16,
})
}
#[test]
fn nested_rope_parameters_override_flat_defaults() {
let mut v = base();
v["rope_parameters"] = serde_json::json!({
"full_attention": {"rope_theta": 160000.0, "rope_type": "default"},
"sliding_attention": {"rope_theta": 160000.0, "rope_type": "default"},
});
let c = EncoderConfig::from_json(&v.to_string()).unwrap();
assert_eq!(c.global_rope_theta, 160_000.0);
// The mmBERT case: the flat default would have been 10,000.
assert_eq!(c.local_rope_theta, 160_000.0);
}
#[test]
fn flat_fields_are_read_without_rope_parameters() {
let mut v = base();
v["global_rope_theta"] = serde_json::json!(80000.0);
v["local_rope_theta"] = serde_json::json!(5000.0);
let c = EncoderConfig::from_json(&v.to_string()).unwrap();
assert_eq!((c.global_rope_theta, c.local_rope_theta), (80000.0, 5000.0));
}
#[test]
fn layer_kinds_follow_every_n_when_layer_types_absent() {
let c = EncoderConfig::from_json(&base().to_string()).unwrap();
assert_eq!(
c.layer_kinds,
vec![
LayerKind::Global,
LayerKind::Sliding,
LayerKind::Sliding,
LayerKind::Global
]
);
}
#[test]
fn explicit_layer_types_win() {
let mut v = base();
v["layer_types"] = serde_json::json!([
"sliding_attention",
"full_attention",
"full_attention",
"sliding_attention"
]);
let c = EncoderConfig::from_json(&v.to_string()).unwrap();
assert_eq!(c.layer_kinds[0], LayerKind::Sliding);
assert_eq!(c.layer_kinds[1], LayerKind::Global);
}
#[test]
fn unsupported_variants_are_refused() {
let mut v = base();
v["attention_bias"] = serde_json::json!(true);
assert!(EncoderConfig::from_json(&v.to_string()).is_err());
let mut v = base();
v["hidden_activation"] = serde_json::json!("gelu_pytorch_tanh");
assert!(EncoderConfig::from_json(&v.to_string()).is_err());
let mut v = base();
v["rope_parameters"] =
serde_json::json!({"full_attention": {"rope_type": "yarn", "rope_theta": 1.0}});
assert!(EncoderConfig::from_json(&v.to_string()).is_err());
}
}
+180
View File
@@ -0,0 +1,180 @@
//! The decision head that sits on top of the encoder.
//!
//! Mirrors the reference `DecisionModel` (the `laya` SDK's
//! `common.py`):
//!
//! 1. a per-question-type embedding added to every position;
//! 2. `head_layers` × PyTorch `nn.TransformerEncoderLayer` with
//! `norm_first=True`: pre-norm self-attention through a fused
//! `in_proj` with bias, then a pre-norm feed-forward whose
//! activation is **ReLU** — PyTorch's default, which the reference
//! never overrides;
//! 3. `scorer`, applied at each option's `[MASK]` marker: LayerNorm →
//! Linear → exact GELU → Linear to one logit;
//! 4. `act_head`, over the first position's hidden state plus four
//! summary features of the (uncalibrated) answer distribution.
use anyhow::Result;
use candle_core::{DType, Tensor};
use candle_nn::{Embedding, LayerNorm, Linear, VarBuilder};
use super::backbone::{project, scaled_attention};
/// PyTorch's `LayerNorm` default.
const TORCH_LN_EPS: f64 = 1e-5;
struct HeadLayer {
norm1: LayerNorm,
in_proj: Linear,
out_proj: Linear,
norm2: LayerNorm,
linear1: Linear,
linear2: Linear,
n_heads: usize,
head_dim: usize,
}
impl HeadLayer {
fn load(vb: VarBuilder, d: usize) -> Result<Self> {
// `nn.TransformerEncoder` is built with `nhead = max(1, d // 64)`
// and `dim_feedforward = 4 * d`.
let n_heads = (d / 64).max(1);
let f32_vb = vb.clone().set_dtype(DType::F32);
Ok(Self {
norm1: candle_nn::layer_norm(d, TORCH_LN_EPS, f32_vb.pp("norm1"))?,
in_proj: linear_torch_mha_in_proj(d, vb.pp("self_attn"))?,
out_proj: candle_nn::linear(d, d, vb.pp("self_attn.out_proj"))?,
norm2: candle_nn::layer_norm(d, TORCH_LN_EPS, f32_vb.pp("norm2"))?,
linear1: candle_nn::linear(d, 4 * d, vb.pp("linear1"))?,
linear2: candle_nn::linear(4 * d, d, vb.pp("linear2"))?,
n_heads,
head_dim: d / n_heads,
})
}
/// `xs` is the f32 residual stream; projections run in the weights'
/// dtype and join it back in f32, as under autocast.
fn forward(&self, xs: &Tensor, key_mask: Option<&Tensor>) -> Result<Tensor> {
let (b, l, d) = xs.dims3()?;
let qkv = project(&self.in_proj, &xs.apply(&self.norm1)?)?
.reshape((b, l, 3, self.n_heads, self.head_dim))?
.permute((2, 0, 3, 1, 4))?;
let q = qkv.get(0)?.contiguous()?;
let k = qkv.get(1)?.contiguous()?;
let v = qkv.get(2)?.contiguous()?;
let ctx = scaled_attention(&q, &k, &v, key_mask, self.head_dim)?
.transpose(1, 2)?
.reshape((b, l, d))?;
let xs = (xs + ctx.apply(&self.out_proj)?.to_dtype(DType::F32)?)?;
let ff = project(&self.linear1, &xs.apply(&self.norm2)?)?
.relu()?
.apply(&self.linear2)?
.to_dtype(DType::F32)?;
Ok((xs + ff)?)
}
/// The flash path over a packed `(total, d)` f32 stream.
#[cfg(feature = "flash-attn")]
fn forward_packed(&self, xs: &Tensor, packed: &super::backbone::Packed) -> Result<Tensor> {
use super::backbone::{flash_attention, split_qkv};
let (t, d) = xs.dims2()?;
let qkv = project(&self.in_proj, &xs.apply(&self.norm1)?)?;
let (q, k, v) = split_qkv(&qkv, self.n_heads, self.head_dim)?;
let ctx = flash_attention(&q, &k, &v, packed, None)?.reshape((t, d))?;
let xs = (xs + ctx.apply(&self.out_proj)?.to_dtype(DType::F32)?)?;
let ff = project(&self.linear1, &xs.apply(&self.norm2)?)?
.relu()?
.apply(&self.linear2)?
.to_dtype(DType::F32)?;
Ok((xs + ff)?)
}
}
/// `nn.MultiheadAttention` stores q/k/v as one `in_proj_weight`
/// `(3d, d)` and `in_proj_bias` `(3d)`, rows ordered q then k then v —
/// exactly the layout one fused `Linear` wants.
fn linear_torch_mha_in_proj(d: usize, vb: VarBuilder) -> Result<Linear> {
let w = vb.get((3 * d, d), "in_proj_weight")?;
let b = vb.get(3 * d, "in_proj_bias")?;
Ok(Linear::new(w, Some(b)))
}
pub struct DecisionHead {
type_emb: Embedding,
layers: Vec<HeadLayer>,
scorer_norm: LayerNorm,
scorer_fc: Linear,
scorer_out: Linear,
act_fc: Linear,
act_out: Linear,
}
impl DecisionHead {
/// `vb` is the checkpoint root; the head's tensors are top-level.
/// Linear weights load in `vb`'s dtype; the type embedding and norms
/// in f32.
pub fn load(vb: VarBuilder, d: usize, head_layers: usize, n_act: usize) -> Result<Self> {
let layers = (0..head_layers)
.map(|i| HeadLayer::load(vb.pp(format!("head.layers.{i}")), d))
.collect::<Result<Vec<_>>>()?;
let f32_vb = vb.clone().set_dtype(DType::F32);
Ok(Self {
type_emb: candle_nn::embedding(3, d, f32_vb.pp("type_emb"))?,
layers,
scorer_norm: candle_nn::layer_norm(d, TORCH_LN_EPS, f32_vb.pp("scorer.0"))?,
scorer_fc: candle_nn::linear(d, d, vb.pp("scorer.1"))?,
scorer_out: candle_nn::linear(d, 1, vb.pp("scorer.3"))?,
act_fc: candle_nn::linear(d + 4, 256, vb.pp("act_head.0"))?,
act_out: candle_nn::linear(256, n_act, vb.pp("act_head.2"))?,
})
}
/// Type embedding plus the transformer layers. `h` is the encoder
/// output `(b, l, d)`, `qtype` is `(b,)` u32.
pub fn contextualise(
&self,
h: &Tensor,
qtype: &Tensor,
key_mask: Option<&Tensor>,
) -> Result<Tensor> {
let mut h = h.broadcast_add(&qtype.apply(&self.type_emb)?.unsqueeze(1)?)?;
for layer in &self.layers {
h = layer.forward(&h, key_mask)?;
}
Ok(h)
}
/// The flash path: `h` is the packed encoder output `(total, d)` and
/// `qtype` the per-token question type `(total,)` u32.
#[cfg(feature = "flash-attn")]
pub fn contextualise_packed(
&self,
h: &Tensor,
qtype: &Tensor,
packed: &super::backbone::Packed,
) -> Result<Tensor> {
let mut h = (h + qtype.apply(&self.type_emb)?)?;
for layer in &self.layers {
h = layer.forward_packed(&h, packed)?;
}
Ok(h)
}
/// Score gathered marker states `(b, k, d)` → `(b, k)` f32 logits.
pub fn score(&self, markers: &Tensor) -> Result<Tensor> {
let x = project(&self.scorer_fc, &markers.apply(&self.scorer_norm)?)?
.gelu_erf()?
.apply(&self.scorer_out)?;
Ok(x.squeeze(2)?.to_dtype(DType::F32)?)
}
/// Act logits `(b, n_act)` f32 from the pooled first position
/// `(b, d)` and the four answer-distribution features `(b, 4)`.
pub fn act(&self, pooled: &Tensor, feats: &Tensor) -> Result<Tensor> {
let x = Tensor::cat(&[pooled, &feats.to_dtype(pooled.dtype())?], 1)?;
let x = project(&self.act_fc, &x)?
.gelu_erf()?
.apply(&self.act_out)?;
Ok(x.to_dtype(DType::F32)?)
}
}
@@ -0,0 +1,64 @@
//! Laya — a non-autoregressive decision model (`convaiinnovations/laya`).
//!
//! Not a language model: a ModernBERT encoder plus a small decision
//! head that scores the options of a typed question in one forward
//! pass. The caller assembles each question into one token sequence
//! (`[CLS] <type> question: <instructions> [SEP] [MASK] opt0 [MASK]
//! opt1 … [SEP] state [SEP]`) and says where each option's `[MASK]`
//! marker sits; this module returns one raw score per marker plus the
//! act head's distribution. Calibration (temperature scaling) and
//! answer shaping happen after, CPU-side, from the checkpoint's
//! `rl_agent_config.json`.
//!
//! The reference implementation is the `laya` SDK's `DecisionModel`
//! (`laya/common.py`), not the `rl_common.py` shipped in the model
//! repository, which has diverged from it.
//!
//! - [`config`] — the two config files a checkpoint ships.
//! - [`backbone`] — ModernBERT, vendored so it runs outside f32.
//! - [`head`] — type embedding, the head's transformer layers, the
//! marker scorer and the act head.
//! - [`model`] — the composition, batch in, [`DecisionRow`]s out.
pub mod backbone;
pub mod config;
pub mod head;
pub mod model;
pub use config::CheckpointConfig;
pub use model::LayaModel;
/// Question type, as the checkpoint's type embedding indexes it.
pub const QTYPE_CHOICE: u8 = 0;
pub const QTYPE_SCORE: u8 = 1;
pub const QTYPE_NOUL: u8 = 2;
/// One assembled question: its token ids, where each option's
/// `[MASK]` marker sits, and its type.
#[derive(Debug, Clone, PartialEq)]
pub struct DecisionSeq {
pub input_ids: Vec<u32>,
pub markers: Vec<usize>,
/// [`QTYPE_CHOICE`], [`QTYPE_SCORE`] or [`QTYPE_NOUL`].
pub qtype: u8,
}
/// Sequences scored in one forward. One request's questions normally
/// travel together, so the state they share is encoded in one pass.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DecisionBatch {
pub seqs: Vec<DecisionSeq>,
}
/// One sequence's result, CPU-side.
#[derive(Debug, Clone, PartialEq)]
pub struct DecisionRow {
/// One uncalibrated score per marker, in marker order.
pub logits: Vec<f32>,
/// Softmax over the act head's outputs. Index 0 is what the
/// reference reports as `action.act_probability`.
pub act: Vec<f32>,
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,260 @@
//! A whole Laya checkpoint: encoder + decision head, batch in, raw
//! scores out.
use anyhow::{Context, Result, ensure};
use candle_core::{DType, Device, Tensor};
use candle_nn::VarBuilder;
use std::path::Path;
use super::backbone::{Masks, ModernBert};
use super::config::CheckpointConfig;
use super::head::DecisionHead;
use super::{DecisionBatch, DecisionRow};
pub struct LayaModel {
encoder: ModernBert,
head: DecisionHead,
config: CheckpointConfig,
device: Device,
/// Precision of the linear layers (the load dtype).
dtype: DType,
}
/// A validated batch, flattened: what both forward paths start from.
struct Flat {
lens: Vec<usize>,
qtypes: Vec<u32>,
/// Each row's marker positions, padded to `k_max` with 0.
marker_idx: Vec<u32>,
k_max: usize,
seq_len: usize,
}
impl LayaModel {
/// Load one checkpoint directory (the repo root, or one of its
/// subfolders): `rl_agent_config.json`, `encoder/config.json` and
/// `model.safetensors`.
pub fn load(dir: &Path, device: &Device, dtype: DType) -> Result<Self> {
let config = CheckpointConfig::from_dir(dir)?;
let weights = dir.join("model.safetensors");
// SAFETY: the file is memory-mapped read-only for the lifetime
// of the VarBuilder; nothing writes to a checkpoint in place.
let vb = unsafe { VarBuilder::from_mmaped_safetensors(&[&weights], dtype, device) }
.with_context(|| format!("mmap {}", weights.display()))?;
Self::from_var_builder(vb, config, device)
}
pub fn from_var_builder(
vb: VarBuilder,
config: CheckpointConfig,
device: &Device,
) -> Result<Self> {
let dtype = vb.dtype();
let encoder = ModernBert::load(vb.pp("encoder"), &config.encoder)?;
let head = DecisionHead::load(
vb.clone(),
config.encoder.hidden_size,
config.laya.head_layers,
config.laya.n_act(),
)?;
Ok(Self {
encoder,
head,
config,
device: device.clone(),
dtype,
})
}
pub fn config(&self) -> &CheckpointConfig {
&self.config
}
/// Precision the linear layers were loaded in.
pub fn dtype(&self) -> DType {
self.dtype
}
/// One forward over every sequence in `batch`.
///
/// On a CUDA device in reduced precision, in a build with the
/// `flash-attn` feature, the batch is packed without padding and
/// attention runs in flash varlen kernels. Otherwise rows are
/// right-padded to the longest, as the reference collates them, and
/// attention is eager; padding changes nothing about an unpadded
/// row's result.
pub fn forward(&self, batch: &DecisionBatch) -> Result<Vec<DecisionRow>> {
let flat = self.validate(batch)?;
#[cfg(feature = "flash-attn")]
if self.device.is_cuda() && self.dtype != DType::F32 && flash_enabled() {
return self.forward_packed(batch, &flat);
}
self.forward_padded(batch, &flat)
}
fn validate(&self, batch: &DecisionBatch) -> Result<Flat> {
let seqs = &batch.seqs;
ensure!(!seqs.is_empty(), "empty decision batch");
let max_pos = self.config.encoder.max_position_embeddings;
let seq_len = seqs.iter().map(|s| s.input_ids.len()).max().unwrap_or(0);
ensure!(seq_len > 0, "decision batch has an empty sequence");
ensure!(
seq_len <= max_pos,
"sequence of {seq_len} tokens exceeds max_position_embeddings {max_pos}"
);
let vocab = self.config.encoder.vocab_size as u32;
let k_max = seqs.iter().map(|s| s.markers.len()).max().unwrap_or(0);
ensure!(
k_max > 0,
"decision batch has a sequence with no option markers"
);
let mut lens = Vec::with_capacity(seqs.len());
let mut qtypes = Vec::with_capacity(seqs.len());
let mut marker_idx = Vec::with_capacity(seqs.len() * k_max);
for s in seqs {
let n = s.input_ids.len();
ensure!(n > 0, "decision batch has an empty sequence");
ensure!(!s.markers.is_empty(), "sequence has no option markers");
ensure!(s.qtype < 3, "question type {} out of range", s.qtype);
if let Some(&bad) = s.input_ids.iter().find(|&&t| t >= vocab) {
anyhow::bail!("token id {bad} outside vocabulary of {vocab}");
}
if let Some(&bad) = s.markers.iter().find(|&&m| m >= n) {
anyhow::bail!("marker position {bad} outside a {n}-token sequence");
}
lens.push(n);
qtypes.push(s.qtype as u32);
marker_idx.extend(s.markers.iter().map(|&m| m as u32));
// Absent slots gather position 0; their scores are discarded.
marker_idx.extend(std::iter::repeat_n(0u32, k_max - s.markers.len()));
}
Ok(Flat {
lens,
qtypes,
marker_idx,
k_max,
seq_len,
})
}
/// The eager path: rows right-padded to `(b, seq_len)`.
fn forward_padded(&self, batch: &DecisionBatch, flat: &Flat) -> Result<Vec<DecisionRow>> {
let b = batch.seqs.len();
let seq_len = flat.seq_len;
let pad = self.config.encoder.pad_token_id;
let mut ids = Vec::with_capacity(b * seq_len);
for s in &batch.seqs {
ids.extend_from_slice(&s.input_ids);
ids.extend(std::iter::repeat_n(pad, seq_len - s.input_ids.len()));
}
let ids = Tensor::from_vec(ids, (b, seq_len), &self.device)?;
let masks = Masks::new(
&flat.lens,
seq_len,
self.encoder.half_window(),
&self.device,
)?;
let qtype = Tensor::from_vec(flat.qtypes.clone(), b, &self.device)?;
let h = self.encoder.forward(&ids, &masks)?;
let h = self.head.contextualise(&h, &qtype, masks.global.as_ref())?;
let d = self.config.encoder.hidden_size;
let idx = Tensor::from_vec(flat.marker_idx.clone(), (b, flat.k_max, 1), &self.device)?
.broadcast_as((b, flat.k_max, d))?
.contiguous()?;
let marker_h = h.gather(&idx, 1)?;
let pooled = h.narrow(1, 0, 1)?.squeeze(1)?;
self.finish(batch, &marker_h, &pooled)
}
/// The flash path: tokens packed without padding, `(total, d)`.
#[cfg(feature = "flash-attn")]
fn forward_packed(&self, batch: &DecisionBatch, flat: &Flat) -> Result<Vec<DecisionRow>> {
use super::backbone::Packed;
let b = batch.seqs.len();
let packed = Packed::new(&flat.lens, &self.device)?;
let total: usize = flat.lens.iter().sum();
let mut ids = Vec::with_capacity(total);
let mut qtype_tok = Vec::with_capacity(total);
for (s, &q) in batch.seqs.iter().zip(&flat.qtypes) {
ids.extend_from_slice(&s.input_ids);
qtype_tok.extend(std::iter::repeat_n(q, s.input_ids.len()));
}
let ids = Tensor::from_vec(ids, total, &self.device)?;
let qtype_tok = Tensor::from_vec(qtype_tok, total, &self.device)?;
let h = self.encoder.forward_packed(&ids, &packed)?;
let h = self.head.contextualise_packed(&h, &qtype_tok, &packed)?;
let d = self.config.encoder.hidden_size;
// Marker positions, and each row's first token, in packed order.
let global: Vec<u32> = flat
.marker_idx
.chunks(flat.k_max)
.zip(&packed.offsets)
.flat_map(|(ms, &off)| ms.iter().map(move |&m| off as u32 + m))
.collect();
let global = Tensor::from_vec(global, b * flat.k_max, &self.device)?;
let marker_h = h.index_select(&global, 0)?.reshape((b, flat.k_max, d))?;
let firsts: Vec<u32> = packed.offsets.iter().map(|&o| o as u32).collect();
let pooled = h.index_select(&Tensor::from_vec(firsts, b, &self.device)?, 0)?;
self.finish(batch, &marker_h, &pooled)
}
/// Score the gathered marker states `(b, k_max, d)` and run the act
/// head over the pooled first positions `(b, d)`.
fn finish(
&self,
batch: &DecisionBatch,
marker_h: &Tensor,
pooled: &Tensor,
) -> Result<Vec<DecisionRow>> {
let b = batch.seqs.len();
let logits: Vec<Vec<f32>> = self.head.score(marker_h)?.to_vec2()?;
let mut feats = Vec::with_capacity(b * 4);
let mut row_logits = Vec::with_capacity(b);
for (s, full) in batch.seqs.iter().zip(logits) {
let row: Vec<f32> = full[..s.markers.len()].to_vec();
feats.extend_from_slice(&act_features(&row));
row_logits.push(row);
}
let feats = Tensor::from_vec(feats, (b, 4), &self.device)?;
let act_logits = self.head.act(pooled, &feats)?;
let act = candle_nn::ops::softmax_last_dim(&act_logits)?.to_vec2::<f32>()?;
Ok(row_logits
.into_iter()
.zip(act)
.map(|(logits, act)| DecisionRow { logits, act })
.collect())
}
}
/// Runtime kill switch for the flash path: `NEURON_FLASH_ATTN=0` (or
/// `false`) forces eager attention without a rebuild — the same switch
/// the text architectures read. Read once.
#[cfg(feature = "flash-attn")]
fn flash_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED
.get_or_init(|| !std::env::var("NEURON_FLASH_ATTN").is_ok_and(|v| v == "0" || v == "false"))
}
/// The act head's four inputs, from one row's uncalibrated logits:
/// top-1 probability, top-1 minus top-2, entropy normalised by
/// `ln(max(k, 2))`, and `k / 255`.
///
/// The reference computes these over the batch's padded width, with
/// absent option slots filled with a logit of -1e4. Those slots carry
/// exactly zero probability in f32, so computing per row over only the
/// real options gives the same result.
pub fn act_features(logits: &[f32]) -> [f32; 4] {
let max = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let exps: Vec<f32> = logits.iter().map(|&z| (z - max).exp()).collect();
let sum: f32 = exps.iter().sum();
let mut p: Vec<f32> = exps.iter().map(|e| e / sum).collect();
let k = (logits.len().max(2)) as f32;
let ent = -p.iter().map(|&x| x * x.max(1e-9).ln()).sum::<f32>() / k.ln();
p.sort_by(|a, b| b.total_cmp(a));
let top1 = p[0];
let top2 = p.get(1).copied().unwrap_or(0.0);
[top1, top1 - top2, ent, k / 255.0]
}
@@ -0,0 +1,450 @@
//! Parity against the upstream reference.
//!
//! `testdata/laya/tiny/` is a toy-sized checkpoint built and scored by
//! the SDK's own `DecisionModel` (`script/generate_laya_fixture.py`);
//! it runs in CI. `testdata/laya/reference.json` holds the released
//! checkpoints' outputs over a real corpus (`script/laya-reference.py`);
//! scoring it needs the weights, so that test is ignored unless
//! `LAYA_REFERENCE_DIR` names a snapshot of `convaiinnovations/laya`.
use super::*;
use candle_core::{DType, Device};
use serde::Deserialize;
use std::path::{Path, PathBuf};
fn testdata() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("src/harness/testdata/laya")
}
#[derive(Deserialize)]
struct TinyExpected {
seqs: Vec<TinySeq>,
}
#[derive(Deserialize)]
struct TinySeq {
input_ids: Vec<u32>,
markers: Vec<usize>,
qtype: u8,
logits: Vec<f32>,
act: Vec<f32>,
}
fn load_tiny() -> (LayaModel, TinyExpected) {
let dir = testdata().join("tiny");
let model = LayaModel::load(&dir, &Device::Cpu, DType::F32).expect("load tiny checkpoint");
let expected: TinyExpected = serde_json::from_str(
&std::fs::read_to_string(dir.join("expected.json")).expect("read expected.json"),
)
.expect("parse expected.json");
(model, expected)
}
fn batch_of(seqs: &[TinySeq]) -> DecisionBatch {
DecisionBatch {
seqs: seqs
.iter()
.map(|s| DecisionSeq {
input_ids: s.input_ids.clone(),
markers: s.markers.clone(),
qtype: s.qtype,
})
.collect(),
}
}
/// Device and precision for the weight-backed tests: `LAYA_DEVICE`
/// (`cpu`, or `cuda:N` in a build with the `cuda` feature) and
/// `LAYA_DTYPE` (`f32`, `bf16` or `f16`). Defaults: CPU, f32.
fn test_device() -> (Device, DType) {
let device = match std::env::var("LAYA_DEVICE").as_deref() {
Ok("cpu") | Err(_) => Device::Cpu,
Ok(s) => {
let ordinal = s
.strip_prefix("cuda:")
.and_then(|n| n.parse().ok())
.unwrap_or_else(|| panic!("LAYA_DEVICE={s}: expected cpu or cuda:N"));
Device::new_cuda(ordinal).unwrap_or_else(|e| panic!("LAYA_DEVICE={s}: {e}"))
}
};
let dtype = match std::env::var("LAYA_DTYPE").as_deref() {
Ok("f32") | Err(_) => DType::F32,
Ok("bf16") => DType::BF16,
Ok("f16") => DType::F16,
Ok(s) => panic!("LAYA_DTYPE={s}: expected f32, bf16 or f16"),
};
(device, dtype)
}
/// Logit tolerance against the f32 reference for a given precision.
/// Reduced precision is judged mainly on argmax agreement; the bound
/// only catches a broken kernel, not rounding.
fn logit_tolerance(dtype: DType) -> f32 {
if dtype == DType::F32 { 1e-3 } else { 1.0 }
}
fn max_abs_diff(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len(), "length mismatch");
a.iter()
.zip(b)
.map(|(x, y)| (x - y).abs())
.fold(0.0, f32::max)
}
/// The whole forward — encoder with both attention kinds and rotary
/// bases, padding, type embedding, head layers, marker gather, scorer
/// and act head — against the reference, in f32.
#[test]
fn tiny_checkpoint_matches_reference() {
let (model, expected) = load_tiny();
let rows = model.forward(&batch_of(&expected.seqs)).expect("forward");
assert_eq!(rows.len(), expected.seqs.len());
for (i, (got, want)) in rows.iter().zip(&expected.seqs).enumerate() {
let dl = max_abs_diff(&got.logits, &want.logits);
let da = max_abs_diff(&got.act, &want.act);
assert!(
dl < 1e-5 && da < 1e-5,
"row {i}: logit diff {dl:e}, act diff {da:e}\n got {:?} / {:?}\nwant {:?} / {:?}",
got.logits,
got.act,
want.logits,
want.act
);
}
}
/// A row's result must not depend on what it was batched with: padded
/// keys are masked out of every attention, including the head's.
#[test]
fn padding_does_not_change_a_row() {
let (model, expected) = load_tiny();
let batched = model.forward(&batch_of(&expected.seqs)).expect("batched");
for (i, seq) in expected.seqs.iter().enumerate() {
let alone = model
.forward(&batch_of(std::slice::from_ref(seq)))
.expect("single");
let dl = max_abs_diff(&alone[0].logits, &batched[i].logits);
let da = max_abs_diff(&alone[0].act, &batched[i].act);
assert!(dl < 1e-5 && da < 1e-5, "row {i}: {dl:e} / {da:e}");
}
}
#[test]
fn malformed_batches_are_refused() {
let (model, _) = load_tiny();
let seq = |ids: Vec<u32>, markers: Vec<usize>, qtype| DecisionBatch {
seqs: vec![DecisionSeq {
input_ids: ids,
markers,
qtype,
}],
};
assert!(model.forward(&DecisionBatch::default()).is_err());
assert!(model.forward(&seq(vec![1, 2, 3], vec![], 0)).is_err());
assert!(model.forward(&seq(vec![1, 2, 3], vec![3], 0)).is_err());
assert!(model.forward(&seq(vec![1, 2, 3], vec![1], 3)).is_err());
assert!(model.forward(&seq(vec![1, 500, 3], vec![1], 0)).is_err());
}
#[test]
fn act_features_match_the_reference_definition() {
// Two equal logits: top1 = top2 = 0.5, entropy exactly 1.
let f = model::act_features(&[0.3, 0.3]);
assert!((f[0] - 0.5).abs() < 1e-6 && f[1].abs() < 1e-6 && (f[2] - 1.0).abs() < 1e-6);
assert!((f[3] - 2.0 / 255.0).abs() < 1e-7);
// One option: probability 1, no second, k clamps to 2.
let f = model::act_features(&[4.0]);
assert_eq!(f[0], 1.0);
assert_eq!(f[1], 1.0);
assert!(f[2].abs() < 1e-6);
assert!((f[3] - 2.0 / 255.0).abs() < 1e-7);
}
// ---------------------------------------------------------------- released checkpoints
#[derive(Deserialize)]
struct Reference {
cases: Vec<RefCase>,
}
#[derive(Deserialize)]
struct RefCase {
name: String,
items: Vec<RefItem>,
response: serde_json::Value,
}
#[derive(Deserialize)]
struct RefItem {
input_ids: Vec<u32>,
markers: Vec<usize>,
qtype: u8,
logits: Vec<f32>,
act: Vec<f32>,
}
/// Every case in `reference.json`, scored by the checkpoint the
/// reference routed it to, on the device and precision
/// [`test_device`] selects (f32 on the CPU by default).
///
/// `LAYA_REFERENCE_DIR` is a snapshot of `convaiinnovations/laya` at the
/// revision recorded in the fixture header: the root holds the English
/// checkpoint, `multilingual/` and `typed-decisions/` the other two.
#[test]
#[ignore = "needs the released weights: set LAYA_REFERENCE_DIR"]
fn released_checkpoints_match_reference() {
let Ok(root) = std::env::var("LAYA_REFERENCE_DIR") else {
panic!("LAYA_REFERENCE_DIR is not set");
};
let root = PathBuf::from(root);
let (device, dtype) = test_device();
let tolerance = logit_tolerance(dtype);
let reference: Reference = serde_json::from_str(
&std::fs::read_to_string(testdata().join("reference.json")).expect("read reference.json"),
)
.expect("parse reference.json");
// The reference's own GPU run (bf16 autocast), for judging reduced
// precision against the drift upstream itself serves with.
let torch_bf16: Option<Reference> =
std::fs::read_to_string(testdata().join("reference_cuda_bf16.json"))
.ok()
.map(|t| serde_json::from_str(&t).expect("parse reference_cuda_bf16.json"));
// Per checkpoint: summed centred drift of ours and of the reference's
// bf16 run, their maxima, and the row count.
let mut drift: std::collections::BTreeMap<String, (f32, f32, f32, f32, usize)> =
Default::default();
let mut models = std::collections::BTreeMap::new();
let mut stats: std::collections::BTreeMap<String, (usize, f32, f32, usize)> =
Default::default();
// Forward wall time and tokens encoded, per checkpoint: a first
// CPU number, not a benchmark (debug builds are far slower).
let mut timing: std::collections::BTreeMap<String, (f64, usize)> = Default::default();
for case in &reference.cases {
let ckpt = case.response["routing"]["model"]
.as_str()
.expect("routing.model")
.to_string();
let model = models.entry(ckpt.clone()).or_insert_with(|| {
let dir = match ckpt.as_str() {
"english" => root.clone(),
other => root.join(other),
};
LayaModel::load(&dir, &device, dtype).unwrap_or_else(|e| panic!("load {ckpt}: {e:#}"))
});
let batch = DecisionBatch {
seqs: case
.items
.iter()
.map(|it| DecisionSeq {
input_ids: it.input_ids.clone(),
markers: it.markers.clone(),
qtype: it.qtype,
})
.collect(),
};
let started = std::time::Instant::now();
let rows = model
.forward(&batch)
.unwrap_or_else(|e| panic!("{}: {e:#}", case.name));
let t = timing.entry(ckpt.clone()).or_default();
t.0 += started.elapsed().as_secs_f64() * 1e3;
t.1 += batch.seqs.iter().map(|s| s.input_ids.len()).sum::<usize>();
let torch_case = torch_bf16
.as_ref()
.and_then(|r| r.cases.iter().find(|c| c.name == case.name));
for (i, (got, want)) in rows.iter().zip(&case.items).enumerate() {
let d = drift.entry(ckpt.clone()).or_default();
let ours = centred_diff(&got.logits, &want.logits);
d.0 += ours;
d.2 = d.2.max(ours);
if let Some(t) = torch_case.and_then(|c| c.items.get(i)) {
let theirs = centred_diff(&t.logits, &want.logits);
d.1 += theirs;
d.3 = d.3.max(theirs);
}
d.4 += 1;
}
let entry = stats.entry(ckpt.clone()).or_default();
for (got, want) in rows.iter().zip(&case.items) {
let dl = max_abs_diff(&got.logits, &want.logits);
let da = max_abs_diff(&got.act, &want.act);
let argmax = |v: &[f32]| {
v.iter()
.enumerate()
.max_by(|a, b| a.1.total_cmp(b.1))
.map(|(i, _)| i)
};
entry.0 += 1;
entry.1 = entry.1.max(dl);
entry.2 = entry.2.max(da);
if argmax(&got.logits) == argmax(&want.logits) {
entry.3 += 1;
}
if dtype != DType::F32 && std::env::var_os("LAYA_VERBOSE").is_some() {
eprintln!(" {:<40} {ckpt:<16} |Δlogit| {dl:.3}", case.name);
}
assert!(
dl < tolerance,
"{} ({ckpt}): logit diff {dl:e}\n got {:?}\nwant {:?}",
case.name,
got.logits,
want.logits
);
assert!(da < tolerance, "{} ({ckpt}): act diff {da:e}", case.name);
}
}
for (ckpt, (n, dl, da, agree)) in &stats {
let (ms, tokens) = timing[ckpt];
eprintln!(
"[{device:?} {dtype:?}] {ckpt}: {n} questions, max |Δlogit| {dl:.2e}, max |Δact| {da:.2e}, argmax {agree}/{n}; \
{tokens} tokens in {ms:.0} ms ({:.2} ms/token)",
ms / tokens as f64
);
assert_eq!(agree, n, "{ckpt}: argmax disagreement");
}
// Reduced precision: centred drift (softmax ignores a constant shift)
// against f32, next to the reference's own bf16 drift. Per-case drift
// is chaotic, so the gate is the mean: no worse than twice upstream's,
// plus 0.01 — about one bf16 ulp at these logits' magnitude — so a
// checkpoint with a single fixture case whose reference drift happens
// to round to nothing is not held to zero.
for (ckpt, (ours, theirs, ours_max, theirs_max, n)) in &drift {
let (ours, theirs) = (ours / *n as f32, theirs / *n as f32);
eprintln!(
"[{device:?} {dtype:?}] {ckpt}: centred drift mean {ours:.4} (reference bf16 {theirs:.4}), \
max {ours_max:.3} (reference bf16 {theirs_max:.3})"
);
if dtype != DType::F32 && torch_bf16.is_some() {
assert!(
ours <= 2.0 * theirs + 0.01,
"{ckpt}: mean drift {ours:.4} exceeds twice the reference's own bf16 drift {theirs:.4}"
);
}
}
}
/// Largest difference between two logit rows after removing each row's
/// mean — the part of a difference a softmax can see.
fn centred_diff(a: &[f32], b: &[f32]) -> f32 {
let mean = |v: &[f32]| v.iter().sum::<f32>() / v.len() as f32;
let (ma, mb) = (mean(a), mean(b));
a.iter()
.zip(b)
.map(|(x, y)| ((x - ma) - (y - mb)).abs())
.fold(0.0, f32::max)
}
/// Forward latency for the four workloads #337 benchmarks against the
/// PyTorch reference, plus batch throughput, on the English checkpoint.
///
/// Sequences are real ones from `reference.json`: `choice_basic` is the
/// short state (41 tokens), `multi_question` five questions over it, and
/// `state_right_truncated` a full 512-token sequence (repeated for the
/// five-question and throughput batches). The timed span is the whole
/// `forward` call — host-to-device copy, forward, and the CPU readback
/// that ends it — so it is directly comparable to the reference's
/// end-to-end `predict()` minus tokenisation.
///
/// Run with `cargo test --release -p neuron [--features cuda] --
/// --ignored --nocapture laya_forward_timing`, with `LAYA_REFERENCE_DIR`
/// and optionally `LAYA_DEVICE` / `LAYA_DTYPE` set.
#[test]
#[ignore = "benchmark: needs the released weights (LAYA_REFERENCE_DIR)"]
fn laya_forward_timing() {
let root = PathBuf::from(std::env::var("LAYA_REFERENCE_DIR").expect("LAYA_REFERENCE_DIR"));
let (device, dtype) = test_device();
let reference: Reference = serde_json::from_str(
&std::fs::read_to_string(testdata().join("reference.json")).expect("read reference.json"),
)
.expect("parse reference.json");
let seqs_of = |name: &str| -> Vec<DecisionSeq> {
reference
.cases
.iter()
.find(|c| c.name == name)
.unwrap_or_else(|| panic!("no case {name}"))
.items
.iter()
.map(|it| DecisionSeq {
input_ids: it.input_ids.clone(),
markers: it.markers.clone(),
qtype: it.qtype,
})
.collect()
};
let long = seqs_of("state_right_truncated").remove(0);
let workloads: Vec<(String, DecisionBatch)> = vec![
(
"short x1q".into(),
DecisionBatch {
seqs: seqs_of("choice_basic"),
},
),
(
"short x5q".into(),
DecisionBatch {
seqs: seqs_of("multi_question"),
},
),
(
"512tok x1q".into(),
DecisionBatch {
seqs: vec![long.clone()],
},
),
(
"512tok x5q".into(),
DecisionBatch {
seqs: vec![long.clone(); 5],
},
),
(
"512tok x16".into(),
DecisionBatch {
seqs: vec![long.clone(); 16],
},
),
(
"512tok x64".into(),
DecisionBatch {
seqs: vec![long.clone(); 64],
},
),
];
let model = LayaModel::load(&root, &device, dtype).expect("load English checkpoint");
// `LAYA_TIMING_ONLY` keeps the workloads whose name contains it, and
// `LAYA_TIMING_ITERS` sets the timed iterations — for profiling one
// workload under a tracer.
let only = std::env::var("LAYA_TIMING_ONLY").ok();
let iters: usize = std::env::var("LAYA_TIMING_ITERS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
for (name, batch) in &workloads {
if only.as_deref().is_some_and(|o| !name.contains(o)) {
continue;
}
for _ in 0..3 {
model.forward(batch).expect("warm-up forward");
}
let mut ms: Vec<f64> = (0..iters)
.map(|_| {
let started = std::time::Instant::now();
model.forward(batch).expect("forward");
started.elapsed().as_secs_f64() * 1e3
})
.collect();
ms.sort_by(f64::total_cmp);
let median = ms[ms.len() / 2];
let tokens: usize = batch.seqs.iter().map(|s| s.input_ids.len()).sum();
eprintln!(
"[{device:?} {dtype:?}] {name:>11}: median {median:7.1} ms, min {:7.1} ms, \
{tokens} tokens, {:.0} seq/s",
ms[0],
batch.seqs.len() as f64 / (median / 1e3)
);
}
}
+2
View File
@@ -20,6 +20,8 @@
//! TP-aware analogues live in `harness/tp/tp_<family>.rs` and follow
//! the pattern set by `tp_qwen3.rs`.
pub mod laya;
pub mod phase_probe;
pub mod qwen3_5;
pub mod qwen4_exp;
pub mod snapshot;
@@ -0,0 +1,167 @@
//! Opt-in phase timing inside a model's forward pass.
//!
//! The request-level split (`PhaseTiming` on the wire) says whether a
//! decode step's time went into the forward, the sampler or the emit.
//! When the answer is "the forward", this says *where* in the forward —
//! embedding, a gather, the layer stack, the head.
//!
//! # Why this needs a device synchronise, and why it is off by default
//!
//! candle's CUDA ops are asynchronous on the stream. An `Instant` taken
//! around GPU work therefore measures how fast kernels were *queued*,
//! not how long they ran, and whichever phase happens to contain the
//! next real synchronisation point silently absorbs everyone else's
//! cost. The numbers look plausible and attribute the time to the wrong
//! place — a worse failure than no numbers at all.
//!
//! So each mark synchronises the device first. That is only affordable
//! when the phases being measured are much larger than a sync: at a few
//! tens of microseconds per sync, a handful of marks is noise against a
//! step measured in hundreds of milliseconds, and material against one
//! measured in single milliseconds. Since that ratio is a property of
//! how slow the model currently is rather than something safe to assume,
//! the probe stays off unless `NEURON_FORWARD_PHASES` is set.
use candle_core::Device;
use std::sync::OnceLock;
use std::time::Instant;
fn enabled() -> bool {
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| std::env::var("NEURON_FORWARD_PHASES").is_ok())
}
/// Accumulates named phases within one forward pass.
///
/// Construct at the top of the forward, call [`mark`] at each boundary,
/// and [`emit`] at the end. Disabled, every method is a predictable
/// branch and nothing is allocated.
///
/// [`mark`]: PhaseProbe::mark
/// [`emit`]: PhaseProbe::emit
pub struct PhaseProbe {
on: bool,
device: Device,
last: Instant,
marks: Vec<(&'static str, f64)>,
}
impl PhaseProbe {
pub fn new(device: &Device) -> Self {
Self::with_enabled(device, enabled())
}
/// Constructor with the gate supplied explicitly.
///
/// [`new`] reads the environment through a `OnceLock`, which a test
/// cannot toggle — so without this the enabled path would be
/// unreachable from a test, and a probe that only ever runs
/// disabled is not evidence that it records anything.
///
/// [`new`]: PhaseProbe::new
pub fn with_enabled(device: &Device, on: bool) -> Self {
// Start from a synchronised device, or the first phase inherits
// whatever was still in flight when the forward was entered.
if on {
let _ = device.synchronize();
}
Self {
on,
device: device.clone(),
last: Instant::now(),
marks: Vec::new(),
}
}
/// Close the phase that ended here and open the next one.
pub fn mark(&mut self, name: &'static str) {
if !self.on {
return;
}
// A failed synchronise means the elapsed time below is not the
// phase's own. Record it as such rather than reporting a number
// that looks like a measurement.
let synced = self.device.synchronize().is_ok();
let ms = self.last.elapsed().as_secs_f64() * 1e3;
self.marks
.push((if synced { name } else { "UNSYNCED" }, ms));
self.last = Instant::now();
}
/// The phases recorded so far, in order.
#[cfg(test)]
pub fn marks(&self) -> &[(&'static str, f64)] {
&self.marks
}
/// Emit one line carrying every phase of this forward.
pub fn emit(self, rows: usize) {
if !self.on || self.marks.is_empty() {
return;
}
let total: f64 = self.marks.iter().map(|(_, ms)| ms).sum();
let detail = self
.marks
.iter()
.map(|(n, ms)| format!("{n}={ms:.3}"))
.collect::<Vec<_>>()
.join(" ");
tracing::info!(rows, total_ms = total, %detail, "forward phases");
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A disabled probe must record nothing at all — not zero-valued
/// phases. Zeros in a log read as "this phase was free", which is
/// the same failure the wire-side `Option` exists to prevent.
#[test]
fn a_disabled_probe_records_nothing() {
let mut p = PhaseProbe::with_enabled(&Device::Cpu, false);
p.mark("embed");
p.mark("layers");
assert!(
p.marks().is_empty(),
"disabled probe recorded {:?}",
p.marks()
);
}
/// An enabled probe records one phase per mark, in call order, and
/// each phase measures only the span since the previous mark rather
/// than since the start.
#[test]
fn phases_are_recorded_in_order_and_do_not_accumulate() {
let mut p = PhaseProbe::with_enabled(&Device::Cpu, true);
p.mark("embed");
std::thread::sleep(std::time::Duration::from_millis(20));
p.mark("layers");
p.mark("head");
let names: Vec<_> = p.marks().iter().map(|(n, _)| *n).collect();
assert_eq!(names, ["embed", "layers", "head"]);
let ms = |i: usize| p.marks()[i].1;
// "layers" contains the sleep; the marks either side must not.
// A probe that timed from the start instead of from the previous
// mark would make "head" >= "layers", which is the mistake this
// pins.
assert!(
ms(1) >= 15.0,
"layers should carry the sleep, got {:?}",
p.marks()
);
assert!(
ms(2) < 10.0,
"head must not inherit it, got {:?}",
p.marks()
);
assert!(
ms(0) < 10.0,
"embed must not inherit it, got {:?}",
p.marks()
);
}
}
@@ -77,7 +77,25 @@ impl Qwen3_5DecoderLayer {
cfg.layer_types.len()
)
})?;
Self::load_typed(cfg, rotary, layer_type, cfg.layer_uses_moe(layer_idx), vb)
}
/// Load a layer whose flavour is stated rather than looked up.
///
/// The MTP head's layer (#96) sits at `mtp.layers.0`, outside the
/// `layer_types` list that indexes the target's own layers — asking
/// that list about it would answer for a target layer that happens
/// to share the index. The checkpoint says what it is: plain
/// `self_attn.{q,k,v,o}_proj` with `q_norm`/`k_norm`, and a dense
/// `mlp`, i.e. full attention without MoE, whatever the target
/// layer at index 0 happens to be.
pub fn load_typed(
cfg: &TextConfig,
rotary: Arc<RotaryEmbedding>,
layer_type: &str,
use_moe: bool,
vb: &ShardedVarBuilder,
) -> Result<Self> {
let attention = match layer_type {
"full_attention" => {
AttentionKind::Full(Qwen3_5Attention::load(cfg, rotary, &vb.pp("self_attn"))?)
@@ -86,12 +104,12 @@ impl Qwen3_5DecoderLayer {
AttentionKind::Linear(GatedDeltaNet::load(cfg, &vb.pp("linear_attn"))?)
}
other => anyhow::bail!(
"unknown layer_type '{other}' for layer {layer_idx} (expected \
'full_attention' or 'linear_attention')"
"unknown layer_type '{other}' (expected 'full_attention' or \
'linear_attention')"
),
};
let mlp = if cfg.layer_uses_moe(layer_idx) {
let mlp = if use_moe {
MlpKind::Moe(Qwen3_5MoeBlock::load(cfg, &vb.pp("mlp"))?)
} else {
MlpKind::Dense(Qwen3_5MLP::load(cfg, &vb.pp("mlp"))?)
@@ -1391,6 +1391,8 @@ mod tests {
let (b, l) = (1, 3);
let cfg = TextConfig {
vocab_size: 100,
mtp_num_hidden_layers: 0,
mtp_use_dedicated_embeddings: false,
hidden_size: 16,
intermediate_size: 32,
num_hidden_layers: 1,
@@ -77,6 +77,7 @@ pub mod full_attn;
pub mod linear_attn;
pub mod mlp;
pub mod moe;
pub mod mtp;
pub mod rmsnorm;
pub mod rope;
pub mod vision;
@@ -225,6 +226,16 @@ pub struct TextConfig {
/// pointwise after softmax+matmul.
#[serde(default)]
pub attn_output_gate: bool,
/// Multi-token-prediction head depth (#96). `1` on the checkpoints
/// that ship one (`Qwen3.8-27B`, `Qwen3.5-0.8B`); absent or `0`
/// means the weights carry no `mtp.*` tensors.
#[serde(default)]
pub mtp_num_hidden_layers: usize,
/// Whether the MTP head has its own embedding table. `false` on
/// every checkpoint seen so far, which is why the head costs only
/// its own layer and not a second vocabulary-sized matrix.
#[serde(default)]
pub mtp_use_dedicated_embeddings: bool,
/// One entry per decoder layer; values are `"full_attention"` or
/// `"linear_attention"`. Length must equal `num_hidden_layers`.
@@ -883,6 +894,53 @@ impl Qwen3_5ForCausalLM {
hidden.i((.., l - 1.., ..))?.apply(&self.lm_head)
}
/// Logits at **every** position: `(B, L, vocab)`.
///
/// [`Self::forward`] slices the last position off before the LM
/// head, which is what a plain decode step wants. Speculative
/// verification (#96) wants the opposite: the target forwards the
/// drafted block in one pass and needs its own token at each
/// position to compare against the draft. The forward already
/// computes all of them — chunked prefill relies on it — so this
/// only declines to throw them away.
pub fn forward_multi(&mut self, input: &Tensor, offset: usize) -> candle_core::Result<Tensor> {
let hidden = self.base.forward(input, offset)?;
hidden.apply(&self.lm_head)
}
/// Like [`Self::forward`], but also returns the hidden state the
/// logits were produced from — `(B, L, hidden)`, post-final-norm,
/// every position.
///
/// The MTP head (#96) consumes exactly this tensor: vLLM's
/// `Qwen3NextModel::forward` returns `self.norm(...)` and its
/// runner hands that to the draft head. Kept beside `forward` so
/// the two cannot drift about which hidden state that is.
pub fn forward_with_hidden(
&mut self,
input: &Tensor,
offset: usize,
) -> candle_core::Result<(Tensor, Tensor)> {
let (_, l) = input.dims2()?;
let hidden = self.base.forward(input, offset)?;
let logits = hidden.i((.., l - 1.., ..))?.apply(&self.lm_head)?;
Ok((logits, hidden))
}
/// Apply the LM head to a hidden state. The MTP head has none of
/// its own (#96) — draft and target logits come from this same
/// projection, which is what makes their distributions comparable.
pub fn lm_head(&self, hidden: &Tensor) -> candle_core::Result<Tensor> {
hidden.apply(&self.lm_head)
}
/// The token embedding the MTP head fuses with the hidden state.
/// The head has none of its own (`mtp_use_dedicated_embeddings` is
/// false on every checkpoint seen), so it borrows the target's.
pub fn embed_tokens(&self, input: &Tensor) -> candle_core::Result<Tensor> {
self.base.embed_tokens.forward(input)
}
/// Lockstep batched decode step (#98): `(B, 1)` input, per-row
/// positions, padding mask from
/// [`Qwen3_5Model::batch_decode_mask`]. Returns `(B, 1,
@@ -0,0 +1,485 @@
//! The multi-token-prediction head — `qwen3_5`'s shipped draft model.
//!
//! `Qwen/Qwen3.8-27B` and `Qwen/Qwen3.5-0.8B` both carry a complete
//! extra decoder layer under `mtp.*` that nothing in this crate has
//! ever loaded: 15 tensors of 1199 in the 27B (810 MB BF16), 15 of 488
//! in the 0.8B (39 MB). It is the drafter speculative decoding needs
//! (#96), already in the weights we download.
//!
//! ```text
//! e = pre_fc_norm_embedding(embed(t)) [B, L, H]
//! h = pre_fc_norm_hidden(h) [B, L, H] (the target's)
//! x = fc(cat[e, h]) [B, L, 2H] -> [B, L, H]
//! x = layer(x) one full-attention block
//! x = norm(x) -> the target's lm_head
//! ```
//!
//! Three things here cannot be recovered from the tensor shapes, and
//! all three were read from vLLM's `qwen3_next_mtp.py` rather than
//! inferred — the same discipline, and for the same reason, as the
//! sibling head in `arch/qwen4_exp/mtp.rs`:
//!
//! 1. **The concatenation is `[embedding, hidden]`**, not the reverse:
//! `hidden_states = torch.cat([inputs_embeds, hidden_states], -1)`.
//! `fc` is square in the sense that both halves are `hidden_size`
//! wide, so swapping them loads, runs, and produces finite logits
//! that are simply wrong. Pinned by value below.
//! 2. **Each half is normalised before the concatenation**, by its own
//! norm — `pre_fc_norm_embedding` on the embedding,
//! `pre_fc_norm_hidden` on the hidden state — never one norm over
//! the joined 2H.
//! 3. **The hidden state is the target's *post-final-norm* output**,
//! the same tensor its `lm_head` consumes. vLLM's
//! `Qwen3NextModel::forward` returns `self.norm(...)` and the runner
//! hands that to the head. Feeding the pre-norm residual instead is
//! the other plausible reading, and it is wrong for this
//! architecture (`qwen4_exp`'s head takes its pre-mixer stream —
//! they do not generalise to each other).
//!
//! **The head has no `lm_head` or `embed_tokens` of its own.** The
//! checkpoint carries neither (`mtp_use_dedicated_embeddings: false`),
//! so both come from the target — which is what makes the resident cost
//! the 810 MB above rather than that plus a second embedding table.
//!
//! **The head keeps its own KV cache, and it is not the target's.** A
//! head fed only the newest token attends over an empty past, so it has
//! to walk the prompt alongside the target before it can draft. That
//! puts MTP prefill inside the speculative loop's cost, not just decode
//! — see #96.
use anyhow::{Context, Result};
use candle_core::{Module, Tensor};
use candle_nn::Linear;
use candle_nn::var_builder::ShardedVarBuilder;
use std::sync::Arc;
use super::TextConfig;
use super::decoder::Qwen3_5DecoderLayer;
use super::rmsnorm::Qwen3_5RmsNorm;
use super::rope::RotaryEmbedding;
pub struct MtpHead {
pre_fc_norm_embedding: Qwen3_5RmsNorm,
pre_fc_norm_hidden: Qwen3_5RmsNorm,
/// `Linear(2H -> H)` over `cat[embedding, hidden]`.
fc: Linear,
layer: Qwen3_5DecoderLayer,
/// Final norm before the *target's* lm_head.
norm: Qwen3_5RmsNorm,
}
impl MtpHead {
/// `vb` is the checkpoint root; the head hangs off `mtp.`.
pub fn load(
cfg: &TextConfig,
rotary: Arc<RotaryEmbedding>,
vb: &ShardedVarBuilder,
) -> Result<Self> {
let mtp = vb.pp("mtp");
let h = cfg.hidden_size;
let fc_weight = mtp
.pp("fc")
.get((h, 2 * h), "weight")
.context("load 'mtp.fc.weight'")?;
Ok(Self {
pre_fc_norm_embedding: Qwen3_5RmsNorm::load(
&mtp.pp("pre_fc_norm_embedding"),
h,
cfg.rms_norm_eps,
)
.context("load mtp.pre_fc_norm_embedding")?,
pre_fc_norm_hidden: Qwen3_5RmsNorm::load(
&mtp.pp("pre_fc_norm_hidden"),
h,
cfg.rms_norm_eps,
)
.context("load mtp.pre_fc_norm_hidden")?,
fc: Linear::new(fc_weight, None),
// Stated, not looked up: the head's layer is outside
// `layer_types`. Full attention, dense MLP — see
// `Qwen3_5DecoderLayer::load_typed`.
layer: Qwen3_5DecoderLayer::load_typed(
cfg,
rotary,
"full_attention",
false,
&mtp.pp("layers").pp(0),
)
.context("load mtp.layers.0")?,
norm: Qwen3_5RmsNorm::load(&mtp.pp("norm"), h, cfg.rms_norm_eps)
.context("load mtp.norm")?,
})
}
/// Whether this checkpoint carries a head at all.
///
/// `mtp_num_hidden_layers` is the config's own answer and is 1 on
/// the checkpoints that ship one. Absent or 0 means the weights
/// have no `mtp.*` tensors and [`Self::load`] would fail.
pub fn present_in(cfg: &TextConfig) -> bool {
cfg.mtp_num_hidden_layers > 0
}
/// One draft step.
///
/// `token_embed` is `embed_tokens(t)` for the token the draft is
/// predicting *from*, `[B, L, H]`. `hidden` is the target's
/// post-final-norm hidden state at the same positions, `[B, L, H]`
/// — on a chained second step, this head's own previous output.
/// The returned tensor is `[B, L, H]`, for the *target's* lm_head.
pub fn forward(
&mut self,
token_embed: &Tensor,
hidden: &Tensor,
attn_mask: Option<&Tensor>,
cos: &Tensor,
sin: &Tensor,
) -> Result<Tensor> {
anyhow::ensure!(
token_embed.dims() == hidden.dims(),
"mtp: embedding {:?} and hidden {:?} must have the same shape — \
the head fuses them position-for-position",
token_embed.dims(),
hidden.dims()
);
let e = self.pre_fc_norm_embedding.forward(token_embed)?;
let h = self.pre_fc_norm_hidden.forward(hidden)?;
// [embedding, hidden] — vLLM's order. See the module note.
let x = Tensor::cat(&[&e, &h], candle_core::D::Minus1)?;
let x = self.fc.forward(&x)?;
let x = self
.layer
.forward(&x, attn_mask, cos, sin)
.context("mtp draft layer")?;
Ok(self.norm.forward(&x)?)
}
/// Capture the draft layer's KV cache.
///
/// Drafting advances the head over tokens that may be rejected, so
/// anything that drafts speculatively has to be able to put the
/// cache back. One full-attention layer is ~2 KiB/token/card, so
/// this is cheap next to the target's — which is why the observer
/// (#96 S4) can snapshot around every draft without distorting what
/// it measures.
pub fn snapshot_kv(&self) -> candle_core::Result<super::super::snapshot::LayerKvSnapshot> {
self.layer.snapshot_kv()
}
/// Put the draft layer's KV cache back, undoing a speculative walk.
pub fn restore_kv(
&mut self,
snap: &super::super::snapshot::LayerKvSnapshot,
) -> candle_core::Result<()> {
self.layer.restore_kv(snap)
}
/// Drop the draft layer's KV cache. Independent of the target's.
pub fn clear_kv_cache(&mut self) {
self.layer.clear_kv_cache();
}
}
#[cfg(test)]
mod tests {
use super::*;
use candle_core::{DType, Device};
use std::collections::HashMap;
/// Build a `ShardedVarBuilder` over a synthetic `mtp.*` head, the
/// way the sibling tests in this arch do: write a safetensors file
/// and open it.
fn tiny_head(
dir: &std::path::Path,
h: usize,
inter: usize,
heads: usize,
kv: usize,
head_dim: usize,
) -> ShardedVarBuilder<'static> {
let dev = Device::Cpu;
let mut t: HashMap<String, Tensor> = HashMap::new();
let zeros = |shape: &[usize]| Tensor::zeros(shape, DType::F32, &dev).unwrap();
let randn = |shape: &[usize]| Tensor::randn(0f32, 0.1f32, shape, &dev).unwrap();
t.insert("mtp.fc.weight".into(), randn(&[h, 2 * h]));
t.insert("mtp.norm.weight".into(), zeros(&[h]));
t.insert("mtp.pre_fc_norm_embedding.weight".into(), zeros(&[h]));
t.insert("mtp.pre_fc_norm_hidden.weight".into(), zeros(&[h]));
let l = "mtp.layers.0";
t.insert(format!("{l}.input_layernorm.weight"), zeros(&[h]));
t.insert(format!("{l}.post_attention_layernorm.weight"), zeros(&[h]));
// Output-gated q_proj: 2 * heads * head_dim rows (attn_output_gate).
t.insert(
format!("{l}.self_attn.q_proj.weight"),
randn(&[2 * heads * head_dim, h]),
);
t.insert(
format!("{l}.self_attn.k_proj.weight"),
randn(&[kv * head_dim, h]),
);
t.insert(
format!("{l}.self_attn.v_proj.weight"),
randn(&[kv * head_dim, h]),
);
t.insert(
format!("{l}.self_attn.o_proj.weight"),
randn(&[h, heads * head_dim]),
);
t.insert(format!("{l}.self_attn.q_norm.weight"), zeros(&[head_dim]));
t.insert(format!("{l}.self_attn.k_norm.weight"), zeros(&[head_dim]));
t.insert(format!("{l}.mlp.gate_proj.weight"), randn(&[inter, h]));
t.insert(format!("{l}.mlp.up_proj.weight"), randn(&[inter, h]));
t.insert(format!("{l}.mlp.down_proj.weight"), randn(&[h, inter]));
let path = dir.join("mtp.safetensors");
candle_core::safetensors::save(&t, &path).expect("write tiny mtp checkpoint");
// SAFETY: mmaps a file this test just wrote and owns.
unsafe {
candle_nn::var_builder::ShardedSafeTensors::var_builder(
std::slice::from_ref(&path),
DType::F32,
&dev,
)
}
.expect("build ShardedVarBuilder")
}
fn tiny_cfg(h: usize, inter: usize, heads: usize, kv: usize, head_dim: usize) -> TextConfig {
let raw = format!(
r#"{{
"model_type": "qwen3_next",
"vocab_size": 32, "hidden_size": {h}, "intermediate_size": {inter},
"num_hidden_layers": 1, "num_attention_heads": {heads},
"num_key_value_heads": {kv}, "head_dim": {head_dim},
"max_position_embeddings": 64, "rms_norm_eps": 1e-6,
"attn_output_gate": true,
"layer_types": ["full_attention"],
"mtp_num_hidden_layers": 1,
"mtp_use_dedicated_embeddings": false
}}"#
);
super::super::Config::from_config_json(&raw)
.expect("parse tiny qwen3_5 config")
.text_config
}
/// The head loads from the `mtp.*` names the real checkpoints use
/// and produces finite output of the target's hidden width — which
/// is what the target's `lm_head` will consume.
#[test]
fn a_tiny_mtp_head_loads_and_drafts() {
let (h, inter, heads, kv, head_dim) = (8usize, 16usize, 2usize, 1usize, 4usize);
let dir = tempfile::tempdir().expect("tempdir");
let vb = tiny_head(dir.path(), h, inter, heads, kv, head_dim);
let cfg = tiny_cfg(h, inter, heads, kv, head_dim);
assert!(MtpHead::present_in(&cfg));
let dev = Device::Cpu;
let rotary = Arc::new(RotaryEmbedding::new(DType::F32, &cfg, &dev).expect("rotary"));
let mut head = MtpHead::load(&cfg, rotary.clone(), &vb).expect("load tiny mtp head");
let (b, l) = (1usize, 3usize);
let embed = Tensor::randn(0f32, 1.0, (b, l, h), &dev).unwrap();
let hidden = Tensor::randn(0f32, 1.0, (b, l, h), &dev).unwrap();
let positions: Vec<usize> = (0..l).collect();
let (cos, sin) = rotary.cos_sin_at(&positions).expect("cos/sin");
let out = head
.forward(&embed, &hidden, None, &cos, &sin)
.expect("draft step");
assert_eq!(out.dims(), &[b, l, h]);
let v: Vec<f32> = out.flatten_all().unwrap().to_vec1().unwrap();
assert!(
v.iter().all(|x| x.is_finite()),
"draft hidden must be finite"
);
}
/// `fc` consumes `cat[embedding, hidden]`, in that order.
///
/// Both halves are `hidden_size` wide, so the reversed reading
/// loads, runs, and returns finite numbers that are simply a
/// different model. vLLM settles it —
/// `torch.cat([inputs_embeds, hidden_states], dim=-1)` — and this
/// pins it by value so a future edit cannot quietly swap them.
///
/// The probe: `fc` reads only the embedding half (identity on the
/// first H columns, zero on the second). With a zero embedding and
/// a non-zero hidden state, the correct order yields zero and the
/// reversed order does not.
#[test]
fn the_fc_input_is_embedding_then_hidden() {
let dev = Device::Cpu;
let h = 4usize;
// fc = [I | 0] : takes the first H columns only.
let mut w = vec![0f32; h * 2 * h];
for i in 0..h {
w[i * 2 * h + i] = 1.0;
}
let fc = Linear::new(Tensor::from_vec(w, (h, 2 * h), &dev).unwrap(), None);
// Zero weight => (1 + w) = 1, a pure RMS normalisation, so the
// norms cannot mask the ordering.
let norm =
|| Qwen3_5RmsNorm::from_weight(Tensor::zeros(h, DType::F32, &dev).unwrap(), 1e-6, None);
let e = Tensor::zeros((1, 1, h), DType::F32, &dev).unwrap();
let hid = Tensor::from_vec(vec![1f32, 2., 3., 4.], (1, 1, h), &dev).unwrap();
let en = norm().forward(&e).unwrap();
let hn = norm().forward(&hid).unwrap();
let correct: Vec<f32> = fc
.forward(&Tensor::cat(&[&en, &hn], candle_core::D::Minus1).unwrap())
.unwrap()
.flatten_all()
.unwrap()
.to_vec1()
.unwrap();
let reversed: Vec<f32> = fc
.forward(&Tensor::cat(&[&hn, &en], candle_core::D::Minus1).unwrap())
.unwrap()
.flatten_all()
.unwrap()
.to_vec1()
.unwrap();
assert!(
correct.iter().all(|x| x.abs() < 1e-6),
"with [embedding, hidden] the zero embedding must reach fc: {correct:?}"
);
assert!(
reversed.iter().any(|x| x.abs() > 1e-3),
"the reversed reading is what this test exists to catch: {reversed:?}"
);
}
/// `forward_multi` must agree with `forward` at the position they
/// share.
///
/// The speculative verify pass (#96) reads the target's own token
/// at every drafted position, so the whole scheme rests on those
/// logits being the ones the model would have produced anyway.
///
/// Agreement is to a tolerance, not bit-for-bit, and the reason is
/// worth recording: `forward` slices the last position *before* the
/// LM head and multiplies `(1, 1, H) x (H, vocab)`, while
/// `forward_multi` multiplies `(1, L, H) x (H, vocab)` and slices
/// after. Different GEMM shapes accumulate in a different order, so
/// the results differ in the last ULP or two — on this fixture,
/// 0.0016435327 against 0.0016435329. An exact assertion passed on
/// one CPU's BLAS blocking and failed on another's, which is the
/// only thing it was really testing.
///
/// What matters for acceptance is that the argmax agrees, so that
/// is asserted too: a verify pass compares tokens, not floats.
#[test]
fn forward_multi_agrees_with_forward_at_the_last_position() {
use super::super::{Config, Qwen3_5ForCausalLM};
use candle_core::IndexOp;
let (h, inter, heads, kv, head_dim, vocab) =
(8usize, 16usize, 2usize, 1usize, 4usize, 32usize);
let dev = Device::Cpu;
let dir = tempfile::tempdir().expect("tempdir");
let mut t: HashMap<String, Tensor> = HashMap::new();
let zeros = |shape: &[usize]| Tensor::zeros(shape, DType::F32, &dev).unwrap();
let randn = |shape: &[usize]| Tensor::randn(0f32, 0.1f32, shape, &dev).unwrap();
t.insert("model.embed_tokens.weight".into(), randn(&[vocab, h]));
t.insert("lm_head.weight".into(), randn(&[vocab, h]));
t.insert("model.norm.weight".into(), zeros(&[h]));
let l = "model.layers.0";
t.insert(format!("{l}.input_layernorm.weight"), zeros(&[h]));
t.insert(format!("{l}.post_attention_layernorm.weight"), zeros(&[h]));
t.insert(
format!("{l}.self_attn.q_proj.weight"),
randn(&[2 * heads * head_dim, h]),
);
t.insert(
format!("{l}.self_attn.k_proj.weight"),
randn(&[kv * head_dim, h]),
);
t.insert(
format!("{l}.self_attn.v_proj.weight"),
randn(&[kv * head_dim, h]),
);
t.insert(
format!("{l}.self_attn.o_proj.weight"),
randn(&[h, heads * head_dim]),
);
t.insert(format!("{l}.self_attn.q_norm.weight"), zeros(&[head_dim]));
t.insert(format!("{l}.self_attn.k_norm.weight"), zeros(&[head_dim]));
t.insert(format!("{l}.mlp.gate_proj.weight"), randn(&[inter, h]));
t.insert(format!("{l}.mlp.up_proj.weight"), randn(&[inter, h]));
t.insert(format!("{l}.mlp.down_proj.weight"), randn(&[h, inter]));
let path = dir.path().join("model.safetensors");
candle_core::safetensors::save(&t, &path).expect("write tiny checkpoint");
// SAFETY: mmaps a file this test just wrote and owns.
let vb = unsafe {
candle_nn::var_builder::ShardedSafeTensors::var_builder(
std::slice::from_ref(&path),
DType::F32,
&dev,
)
}
.expect("build ShardedVarBuilder");
let raw = format!(
r#"{{
"model_type": "qwen3_next",
"vocab_size": {vocab}, "hidden_size": {h}, "intermediate_size": {inter},
"num_hidden_layers": 1, "num_attention_heads": {heads},
"num_key_value_heads": {kv}, "head_dim": {head_dim},
"max_position_embeddings": 64, "rms_norm_eps": 1e-6,
"attn_output_gate": true,
"layer_types": ["full_attention"]
}}"#
);
let cfg = Config::from_config_json(&raw).expect("parse tiny config");
let mut model = Qwen3_5ForCausalLM::new(cfg, vb).expect("load tiny checkpoint");
let input = Tensor::new(&[1u32, 5, 9, 2], &dev)
.unwrap()
.unsqueeze(0)
.unwrap();
let multi = model.forward_multi(&input, 0).expect("forward_multi");
assert_eq!(multi.dims(), &[1, 4, vocab], "one logits row per position");
let last_of_multi: Vec<f32> = multi.i((0, 3, ..)).unwrap().to_vec1().unwrap();
// A head that returned the same row L times would satisfy the
// comparison below without carrying per-position information,
// which is the whole point of the pass.
let first_of_multi: Vec<f32> = multi.i((0, 0, ..)).unwrap().to_vec1().unwrap();
assert_ne!(
first_of_multi, last_of_multi,
"positions must carry distinct logits, not a repeated row"
);
model.clear_kv_cache();
let single = model.forward(&input, 0).expect("forward");
assert_eq!(single.dims(), &[1, 1, vocab]);
let single: Vec<f32> = single.flatten_all().unwrap().to_vec1().unwrap();
let max_diff = last_of_multi
.iter()
.zip(single.iter())
.map(|(a, b)| (a - b).abs())
.fold(0f32, f32::max);
assert!(
max_diff < 1e-5,
"forward_multi's last row must match what forward returns \
(max abs diff {max_diff:e}): {last_of_multi:?} vs {single:?}"
);
let argmax = |v: &[f32]| {
v.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).expect("finite logits"))
.map(|(i, _)| i)
.expect("non-empty")
};
assert_eq!(
argmax(&last_of_multi),
argmax(&single),
"the verify pass compares tokens; the argmax must agree"
);
}
}
@@ -45,6 +45,7 @@ use super::config::{Config, TextConfig};
use super::decoder::DecoderLayer;
use super::hyper::HyperConnection;
use super::ple::{MmapNGramTable, NGramHasher, NGramTable, ShardedNGramTable};
use crate::harness::arch::phase_probe::PhaseProbe;
use crate::harness::arch::snapshot::{KvCacheSnapshot, PleSnapshot};
/// The hashed n-gram lookup, hoisted to the model because it is a
@@ -255,6 +256,7 @@ impl Qwen4ExpForCausalLM {
// Four identical streams — the residual this architecture
// carries between layers, not one.
let mut probe = PhaseProbe::new(&self.device);
let x = self.embed_tokens.forward(input_ids)?;
let mut h = x.repeat((1, 1, self.hc_count))?;
@@ -264,6 +266,7 @@ impl Qwen4ExpForCausalLM {
} else {
None
};
probe.mark("embed");
// Computed once: it is a function of the ids, and one layer
// consumes it.
@@ -274,12 +277,17 @@ impl Qwen4ExpForCausalLM {
}
None => None,
};
// The n-gram table is mapped from the checkpoint rather than
// resident (#310), so this phase is a host page-fault path, not
// device work: it is where a cold or evicted mapping shows up.
probe.mark("ngram");
for (i, layer) in self.layers.iter_mut().enumerate() {
h = layer
.forward(&h, ngram.as_ref(), mask.as_ref(), &cos, &sin, offset)
.with_context(|| format!("layer {i}"))?;
}
probe.mark("layers");
// The mixer's hc_norm is the only normalisation before the head.
let x = self.mixer.collapse(&h)?;
@@ -292,7 +300,10 @@ impl Qwen4ExpForCausalLM {
// them all and [`Self::logits_all_positions`] exists to be
// compared against it.
let l = x.dim(1)?;
Ok((self.lm_head.forward(&x.i((.., l - 1.., ..))?)?, h))
let logits = self.lm_head.forward(&x.i((.., l - 1.., ..))?)?;
probe.mark("head");
probe.emit(seq_len);
Ok((logits, h))
}
/// Capture every piece of per-request state at one token boundary:
@@ -420,6 +420,26 @@ impl MmapNGramTable {
// defend against.
let map = unsafe { memmap2::Mmap::map(&file) }
.with_context(|| format!("mmap {}", path.display()))?;
// Every access through this mapping is a 320-byte row picked
// by a hash: sixteen unrelated rows per token, out of
// 320,001,536. The kernel's default readahead assumes
// sequential access and faults a whole window per touch,
// which is pure amplification here -- and this table is
// 95 GiB against a host that is also holding the routed
// experts (#318) in RAM, so every wasted page evicts one
// that a later gather wanted.
//
// Advisory: a failure changes performance, never
// correctness, so it is logged rather than propagated. A
// kernel or filesystem that ignores MADV_RANDOM simply
// leaves the previous behaviour in place.
if let Err(e) = map.advise(memmap2::Advice::Random) {
tracing::debug!(
file = %path.display(),
error = %e,
"ple: MADV_RANDOM refused; readahead stays on for this mapping"
);
}
let (header_len, meta) =
safetensors::SafeTensors::read_metadata(&map).map_err(|e| {
anyhow::anyhow!("read safetensors metadata from {}: {e}", path.display())
+218 -18
View File
@@ -56,6 +56,52 @@ use crate::harness::arch::qwen3_5::rope::{RotaryEmbedding, apply_partial_rotary}
use super::config::TextConfig as Qwen4ExpTextConfig;
/// The indexer's geometry, from `config.json`.
/// The positions one query may attend, ascending.
///
/// The reference derives this from the attention mask
/// (`torch.nonzero(visible_token_indices[b, 0, q])`) and forms blocks
/// from whichever positions survive, in order. Under a plain causal
/// mask that is exactly `0..n`, which is worth representing without
/// materialising: the set reaches the context window in size and is
/// rebuilt for every query, so a `Vec` per query would cost more than
/// the selection it feeds.
///
/// [`Sparse`] is the general case — left padding, ragged batches, any
/// mask with holes. Both are handled by the same arithmetic below,
/// which is the point: block `b` covers *set positions*
/// `b * block_size ..`, never *absolute positions*, and those coincide
/// only when the set is contiguous from zero.
///
/// [`Sparse`]: VisibleSet::Sparse
#[derive(Debug, Clone, Copy)]
pub enum VisibleSet<'a> {
/// Every position `0..n` — a plain causal mask.
Contiguous(usize),
/// Exactly these positions, ascending and without duplicates.
Sparse(&'a [usize]),
}
impl VisibleSet<'_> {
pub fn len(&self) -> usize {
match self {
Self::Contiguous(n) => *n,
Self::Sparse(v) => v.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// The `i`th visible position.
fn at(&self, i: usize) -> usize {
match self {
Self::Contiguous(_) => i,
Self::Sparse(v) => v[i],
}
}
}
pub struct BlockSelector {
n_heads: usize,
head_dim: usize,
@@ -96,9 +142,14 @@ impl BlockSelector {
/// The position each block starts at — where its pooled key is
/// RoPE'd, **not** the position it is scored against.
pub fn block_starts(&self, n_visible: usize) -> Vec<usize> {
(0..self.n_blocks(n_visible))
.map(|b| b * self.block_size)
///
/// Block `b` begins at the `b * block_size`-th **visible** position,
/// which is `b * block_size` only when the set is contiguous from
/// zero. Rotating a pooled key at the wrong absolute position is
/// silent: the scores stay finite and the selection stays plausible.
pub fn block_starts(&self, visible: &VisibleSet) -> Vec<usize> {
(0..self.n_blocks(visible.len()))
.map(|b| visible.at(b * self.block_size))
.collect()
}
@@ -149,7 +200,8 @@ impl BlockSelector {
///
/// Ties are broken towards the earlier block, which matters only for
/// reproducibility — the reference's `topk` does the same.
pub fn select(&self, scores: &[f32], n_visible: usize) -> Result<Vec<usize>> {
pub fn select(&self, scores: &[f32], visible: &VisibleSet) -> Result<Vec<usize>> {
let n_visible = visible.len();
let blocks = self.n_blocks(n_visible);
ensure!(
scores.len() == blocks,
@@ -170,15 +222,28 @@ impl BlockSelector {
order.sort_unstable();
}
// Indices into the visible set, not absolute positions. They
// coincide only when the set is contiguous from zero, and
// conflating them is what this issue (#324) is about.
let tail_start = blocks * self.block_size;
let mut positions = Vec::with_capacity(keep * self.block_size + (n_visible - tail_start));
for b in order {
let start = b * self.block_size;
positions.extend(start..start + self.block_size);
positions.extend((start..start + self.block_size).map(|i| visible.at(i)));
}
// The tail never competes for the budget. It holds the query's
// own position.
positions.extend(tail_start..n_visible);
positions.extend((tail_start..n_visible).map(|i| visible.at(i)));
// Ascending is the contract `dense_mask` relies on, and it
// holds without sorting: `order` is ascending, `visible.at` is
// monotonic in its index, and the tail's indices all follow the
// blocks'. Asserted rather than enforced, so a future change
// that breaks the invariant is caught instead of hidden by a
// sort that makes it look fine.
debug_assert!(
positions.windows(2).all(|w| w[0] < w[1]),
"selected positions must be ascending and distinct: {positions:?}"
);
Ok(positions)
}
@@ -356,7 +421,10 @@ impl Indexer {
total: usize,
) -> candle_core::Result<Tensor> {
let b = cache.dims()[0];
let starts = self.selector.block_starts(total);
// The cache holds every past position, so the poolable set is
// the contiguous 0..total. When a padded or ragged batch can
// reach here this becomes `Sparse` of that row's live positions.
let starts = self.selector.block_starts(&VisibleSet::Contiguous(total));
let mut pooled = Vec::with_capacity(b);
for row in 0..b {
pooled.push(self.selector.pool(&cache.i(row)?)?);
@@ -475,10 +543,20 @@ impl Indexer {
None => k,
};
let total = cache.dims()[1];
// This equality is also what makes the visible set contiguous:
// every query's set is built as `Contiguous(past_len + i + 1)`,
// which describes the batch only while one scalar `past_len`
// does. A ragged or left-padded batch presents a padded `total`
// that no single `past_len` matches, and trips this — which is
// the intent, because the alternative is a selection computed
// against the wrong past that still looks plausible (#324).
ensure!(
total == past_len + seq_len,
"indexer cache holds {total} positions but the queries start at {past_len} \
and run for {seq_len} — the cache and the main KV have diverged"
and run for {seq_len} — the cache and the main KV have diverged, or this \
is a ragged/padded batch whose visible set is no longer contiguous. The \
latter needs the set derived from the attention mask (VisibleSet::Sparse) \
rather than from a count (#324)."
);
self.key_cache = Some(cache.clone());
Ok((q, cache))
@@ -540,12 +618,16 @@ impl Indexer {
for row in scores.iter().take(batch) {
let mut per_query = Vec::with_capacity(seq_len);
for (i, all_blocks) in row.iter().enumerate().take(seq_len) {
let visible = past_len + i + 1;
// Contiguous because `model.rs` builds the causal mask
// itself and no padding mask reaches this arch (#324).
// The moment one can, this is where the row's live
// positions are derived from it.
let visible = VisibleSet::Contiguous(past_len + i + 1);
// Only blocks wholly behind this query exist for it.
let visible_blocks = self.selector.n_blocks(visible);
let visible_blocks = self.selector.n_blocks(visible.len());
per_query.push(
self.selector
.select(&all_blocks[..visible_blocks], visible)?,
.select(&all_blocks[..visible_blocks], &visible)?,
);
}
out.push(per_query);
@@ -600,6 +682,63 @@ mod tests {
assert_eq!(qsa.block_topk() * 4, 2048);
}
/// Blocks are formed from consecutive **visible** positions, not
/// consecutive absolute ones (#324).
///
/// The reference takes `nonzero` of the mask and chunks whatever
/// survives, in order. With holes in the visible set the two
/// readings diverge completely: under the old arithmetic the query
/// would be handed positions 4..8, which here are not merely the
/// wrong block — 4, 6, 8, 9 and 10 are positions the mask forbids
/// it from seeing at all.
#[test]
fn blocks_are_cut_from_the_visible_set_not_the_position_axis() {
// block_size 4, budget 4 => one block survives the top-k.
let qsa = BlockSelector::new(2, 1, 4, 4).unwrap();
let live = [3usize, 5, 7, 11, 13, 17, 19, 23, 29];
let visible = VisibleSet::Sparse(&live);
assert_eq!(qsa.n_blocks(visible.len()), 2);
// Block 0 begins at visible[0] = 3, block 1 at visible[4] = 13.
assert_eq!(qsa.block_starts(&visible), vec![3, 13]);
// Score block 1 above block 0, so only block 1 is kept.
let got = qsa.select(&[0.1f32, 0.9], &visible).unwrap();
assert_eq!(
got,
vec![13, 17, 19, 23, 29],
"block 1 is visible[4..8], and the tail is visible[8..]"
);
// The failure this pins: index arithmetic would give 4..8 plus 8.
assert!(
!got.contains(&4) && !got.contains(&8),
"positions the mask forbids must never be selected: {got:?}"
);
}
/// The two representations must agree wherever both are valid.
///
/// `Contiguous(n)` is an optimisation for the causal case, not a
/// different rule — if it can disagree with the explicit list of the
/// same positions, one of the two paths is wrong and only the
/// explicit one is checked against the reference.
#[test]
fn contiguous_and_sparse_agree_on_the_same_positions() {
let qsa = BlockSelector::new(2, 1, 4, 8).unwrap();
for n in [1usize, 4, 7, 8, 9, 15, 23] {
let explicit: Vec<usize> = (0..n).collect();
let scores = vec![0.5f32; qsa.n_blocks(n)];
let a = qsa.select(&scores, &VisibleSet::Contiguous(n)).unwrap();
let b = qsa.select(&scores, &VisibleSet::Sparse(&explicit)).unwrap();
assert_eq!(a, b, "representations disagree at n={n}");
assert_eq!(
qsa.block_starts(&VisibleSet::Contiguous(n)),
qsa.block_starts(&VisibleSet::Sparse(&explicit)),
"block starts disagree at n={n}"
);
}
}
/// The cheapest gate in the architecture: below the budget, the
/// selection returns every visible position, so a short prompt must
/// be bit-comparable to dense causal attention.
@@ -609,7 +748,9 @@ mod tests {
for n_visible in [1usize, 3, 4, 7, 2047, 2048] {
assert!(qsa.is_dense(n_visible), "{n_visible} should be dense");
let scores = vec![0.0f32; qsa.n_blocks(n_visible)];
let got = qsa.select(&scores, n_visible).unwrap();
let got = qsa
.select(&scores, &VisibleSet::Contiguous(n_visible))
.unwrap();
assert_eq!(
got,
(0..n_visible).collect::<Vec<_>>(),
@@ -631,7 +772,9 @@ mod tests {
assert_eq!(qsa.n_blocks(n_visible), 4);
// Block 2 is worthless; with topk 3 it is the one dropped.
let scores = [0.9f32, 0.8, 0.1, 0.7];
let got = qsa.select(&scores, n_visible).unwrap();
let got = qsa
.select(&scores, &VisibleSet::Contiguous(n_visible))
.unwrap();
let want: Vec<usize> = (0..8).chain(12..19).collect();
assert_eq!(got, want);
assert!(got.contains(&(n_visible - 1)), "the query's own position");
@@ -721,7 +864,7 @@ mod tests {
);
// And the selection that rests on it picks the right block.
assert_eq!(
qsa.select(&scores, 8).unwrap(),
qsa.select(&scores, &VisibleSet::Contiguous(8)).unwrap(),
vec![4, 5, 6, 7],
"the higher-scoring block should be the one kept"
);
@@ -755,24 +898,26 @@ mod tests {
let qsa = small();
let keys = Tensor::zeros((11, 8), DType::BF16, &dev).unwrap();
assert_eq!(qsa.pool(&keys).unwrap().dims(), &[2, 8]);
assert_eq!(qsa.block_starts(11), vec![0, 4]);
assert_eq!(qsa.block_starts(&VisibleSet::Contiguous(11)), vec![0, 4]);
let short = Tensor::zeros((3, 8), DType::F32, &dev).unwrap();
assert_eq!(qsa.pool(&short).unwrap().dims(), &[0, 8]);
assert!(qsa.block_starts(3).is_empty());
assert!(qsa.block_starts(&VisibleSet::Contiguous(3)).is_empty());
}
#[test]
fn selection_is_ascending_and_rejects_a_wrong_score_count() {
let qsa = small();
let got = qsa.select(&[0.1f32, 0.9, 0.5, 0.7, 0.2], 23).unwrap();
let got = qsa
.select(&[0.1f32, 0.9, 0.5, 0.7, 0.2], &VisibleSet::Contiguous(23))
.unwrap();
assert!(got.windows(2).all(|w| w[0] < w[1]), "got {got:?}");
// blocks 1, 3, 2 win; block 0 and 4 lose; tail 20..23 stays.
assert_eq!(
got,
vec![4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20, 21, 22]
);
assert!(qsa.select(&[0.1f32], 23).is_err());
assert!(qsa.select(&[0.1f32], &VisibleSet::Contiguous(23)).is_err());
}
// ---- the indexer as a layer ----
@@ -832,6 +977,61 @@ mod tests {
.unwrap()
}
/// A cache that does not match `past_len + seq_len` is a ragged or
/// left-padded batch, and must fail loudly (#324).
///
/// Every visible set is built as `Contiguous(past_len + i + 1)`, so
/// the moment one scalar `past_len` stops describing every row, the
/// selection is computed against the wrong past — and it stays
/// plausible, which is what makes it dangerous. The guard is only
/// worth having if it can fire, so this drives it directly rather
/// than trusting that it would.
#[test]
fn a_cache_inconsistent_with_past_len_is_refused() {
let (hidden_size, n_heads, head_dim) = (8, 2, 8);
let mut ix = indexer(hidden_size, n_heads, head_dim, 4, 4);
let rope = rope_for(head_dim);
// A history of 20 positions, while the step claims past_len 5:
// the shape a padded batch presents, where `total` is the
// padded maximum and no single `past_len` matches every row.
let stale = Tensor::zeros((1, 20, head_dim), DType::F32, &Device::Cpu).unwrap();
ix.restore_keys(Some(&stale));
let err = ix
.visible_positions(&hidden(1, 1, hidden_size, 0.7), &rope, 5)
.expect_err("a cache that disagrees with past_len must not be served");
let msg = format!("{err:#}");
assert!(
msg.contains("no longer contiguous") && msg.contains("324"),
"the error must name the contiguity assumption it protects, got: {msg}"
);
}
/// The same call with a consistent cache must be served, or the
/// guard above is just refusing everything.
#[test]
fn a_cache_consistent_with_past_len_is_served() {
let (hidden_size, n_heads, head_dim) = (8, 2, 8);
let mut ix = indexer(hidden_size, n_heads, head_dim, 4, 4);
let rope = rope_for(head_dim);
let history = Tensor::zeros((1, 5, head_dim), DType::F32, &Device::Cpu).unwrap();
ix.restore_keys(Some(&history));
// past_len 5 + this step's 1 == the 6 the cache will hold.
let got = ix
.visible_positions(&hidden(1, 1, hidden_size, 0.7), &rope, 5)
.expect("a contiguous history must still be served");
assert_eq!(got.len(), 1, "one batch row");
assert_eq!(got[0].len(), 1, "one query");
assert!(
got[0][0].iter().all(|p| *p < 6),
"positions must lie inside the 6-position history: {:?}",
got[0][0]
);
}
/// `index_qk_proj` is fused 4:1, not halved. With 4 query heads and
/// 1 key head of 8 dims the split is 32 and 8 of 40 — a halving
/// would take 20 and 20 and put half a query head in the key.
@@ -391,6 +391,8 @@ mod tests {
fn tiny_config() -> TextConfig {
TextConfig {
vocab_size: 32,
mtp_num_hidden_layers: 0,
mtp_use_dedicated_embeddings: false,
hidden_size: 16,
intermediate_size: 32,
num_hidden_layers: 2,
+482 -14
View File
@@ -25,6 +25,11 @@ use cortex_core::openai::{
ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse, ChatMessage, MessageContent,
};
// `PhaseTiming` is constructed only on the two cuda-gated streaming
// paths (device-worker and TP), so importing it unconditionally warns
// on a CPU-only build and clippy runs with `-D warnings`.
#[cfg(feature = "cuda")]
use crate::wire::PhaseTiming;
use crate::wire::{
FinishReason, FinishTiming, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
detect_reasoning_token_pair, detect_tool_call_token_pair, openai_chat as wire_chat,
@@ -138,6 +143,9 @@ pub enum LoadedHandle {
Tp(Arc<TpLoadedModel>),
/// Z-Image text-to-image pipeline (#198).
Image(Arc<LoadedImageModel>),
/// Laya decision model (#336): typed questions in, calibrated
/// option scores out, no text generation.
Decision(Arc<super::decision_model::LoadedDecisionModel>),
}
impl LoadedHandle {
@@ -147,6 +155,7 @@ impl LoadedHandle {
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => &m.model_id,
LoadedHandle::Image(m) => &m.model_id,
LoadedHandle::Decision(m) => &m.model_id,
}
}
@@ -157,6 +166,7 @@ impl LoadedHandle {
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => &m.spec,
LoadedHandle::Image(m) => &m.spec,
LoadedHandle::Decision(m) => &m.spec,
}
}
@@ -166,6 +176,7 @@ impl LoadedHandle {
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => m.devices.clone(),
LoadedHandle::Image(m) => m.devices.clone(),
LoadedHandle::Decision(m) => m.devices.clone(),
}
}
@@ -179,6 +190,7 @@ impl LoadedHandle {
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => m.poisoned.load(Ordering::Acquire),
LoadedHandle::Image(m) => m.poisoned.load(Ordering::Acquire),
LoadedHandle::Decision(m) => m.poisoned.load(Ordering::Acquire),
}
}
@@ -190,6 +202,7 @@ impl LoadedHandle {
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => (m.admission.in_flight(), m.admission.queue_depth()),
LoadedHandle::Image(m) => (m.admission.in_flight(), m.admission.queue_depth()),
LoadedHandle::Decision(m) => (m.admission.in_flight(), m.admission.queue_depth()),
}
}
@@ -202,6 +215,9 @@ impl LoadedHandle {
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => (m.admission.max_in_flight(), m.admission.max_queue_depth()),
LoadedHandle::Image(m) => (m.admission.max_in_flight(), m.admission.max_queue_depth()),
LoadedHandle::Decision(m) => {
(m.admission.max_in_flight(), m.admission.max_queue_depth())
}
}
}
@@ -225,6 +241,10 @@ impl LoadedHandle {
m.admission.anon_in_flight(),
m.admission.anon_max_in_flight(),
),
LoadedHandle::Decision(m) => (
m.admission.anon_in_flight(),
m.admission.anon_max_in_flight(),
),
}
}
@@ -235,6 +255,7 @@ impl LoadedHandle {
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => m.admission.close(),
LoadedHandle::Image(m) => m.admission.close(),
LoadedHandle::Decision(m) => m.admission.close(),
}
}
@@ -246,6 +267,7 @@ impl LoadedHandle {
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => m.admission.rejections(),
LoadedHandle::Image(m) => m.admission.rejections(),
LoadedHandle::Decision(m) => m.admission.rejections(),
}
}
@@ -257,6 +279,7 @@ impl LoadedHandle {
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => &m.admission,
LoadedHandle::Image(m) => &m.admission,
LoadedHandle::Decision(m) => &m.admission,
};
(admission.kv_budget_mb(), admission.kv_available_mb())
}
@@ -270,7 +293,7 @@ impl LoadedHandle {
LoadedHandle::Tp(m) => &m.prefill_rate,
// Image models have no token throughput; steps/sec lives in
// the per-request timing instead.
LoadedHandle::Image(_) => return (0.0, 0.0),
LoadedHandle::Image(_) | LoadedHandle::Decision(_) => return (0.0, 0.0),
};
(ema.get().unwrap_or(0.0), ema.decode().unwrap_or(0.0))
}
@@ -282,12 +305,15 @@ impl LoadedHandle {
if matches!(self, LoadedHandle::Image(_)) {
return vec!["image".to_string()];
}
if matches!(self, LoadedHandle::Decision(_)) {
return vec!["decision".to_string()];
}
let mut caps = vec!["text".to_string()];
let has_vision = match self {
LoadedHandle::Single(m) => m.has_vision,
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => m.has_vision,
LoadedHandle::Image(_) => unreachable!("handled above"),
LoadedHandle::Image(_) | LoadedHandle::Decision(_) => unreachable!("handled above"),
};
if has_vision {
caps.push("vision".to_string());
@@ -333,7 +359,7 @@ impl LoadedHandle {
LoadedHandle::Tp(m) => (m.last_free_mb.load(Ordering::Acquire), m.context_profile),
// Image generation has its own VRAM path and no prefill
// floor, so this check would say nothing useful about it.
LoadedHandle::Image(_) => return None,
LoadedHandle::Image(_) | LoadedHandle::Decision(_) => return None,
};
// `0` is the CPU-load and missing-worker sentinel — the same one
// `check_vram` skips on. Nothing to assert either way.
@@ -414,7 +440,7 @@ impl LoadedHandle {
LoadedHandle::Single(m) => m.tool_call_tokens.is_some(),
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => m.tool_call_tokens.is_some(),
LoadedHandle::Image(_) => false,
LoadedHandle::Image(_) | LoadedHandle::Decision(_) => false,
}
}
@@ -425,7 +451,7 @@ impl LoadedHandle {
LoadedHandle::Single(m) => m.reasoning_tokens.is_some(),
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => m.reasoning_tokens.is_some(),
LoadedHandle::Image(_) => false,
LoadedHandle::Image(_) | LoadedHandle::Decision(_) => false,
}
}
@@ -436,7 +462,7 @@ impl LoadedHandle {
LoadedHandle::Single(m) => m.preserve_thinking,
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => m.preserve_thinking,
LoadedHandle::Image(_) => None,
LoadedHandle::Image(_) | LoadedHandle::Decision(_) => None,
}
}
@@ -446,7 +472,7 @@ impl LoadedHandle {
LoadedHandle::Single(m) => m.reasoning_efforts.clone(),
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => m.reasoning_efforts.clone(),
LoadedHandle::Image(_) => Default::default(),
LoadedHandle::Image(_) | LoadedHandle::Decision(_) => Default::default(),
}
}
@@ -472,7 +498,7 @@ impl LoadedHandle {
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => m.query_vram_tightest_free_mb().await,
// No context-limit derivation for image models; nothing to cache.
LoadedHandle::Image(_) => return,
LoadedHandle::Image(_) | LoadedHandle::Decision(_) => return,
};
// Don't clobber a good cached value with a transient `0`
// (worker gone/poisoned sentinel).
@@ -481,7 +507,7 @@ impl LoadedHandle {
LoadedHandle::Single(m) => m.last_free_mb.store(free, Ordering::Release),
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => m.last_free_mb.store(free, Ordering::Release),
LoadedHandle::Image(_) => {}
LoadedHandle::Image(_) | LoadedHandle::Decision(_) => {}
}
}
}
@@ -510,7 +536,7 @@ impl LoadedHandle {
m.prefill_rate.get(),
),
// Token limits are meaningless for image generation.
LoadedHandle::Image(_) => return None,
LoadedHandle::Image(_) | LoadedHandle::Decision(_) => return None,
};
let rate = rate.unwrap_or(cfg.bootstrap_prefill_tok_per_sec);
// The KV budget admission will actually grant. Advertising past
@@ -520,7 +546,9 @@ impl LoadedHandle {
LoadedHandle::Single(m) => m.admission.kv_budget_mb(),
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => m.admission.kv_budget_mb(),
LoadedHandle::Image(_) => unreachable!("derived_limit returned above"),
LoadedHandle::Image(_) | LoadedHandle::Decision(_) => {
unreachable!("derived_limit returned above")
}
};
let limit = super::context_limit::derive_limit(
&profile,
@@ -539,7 +567,9 @@ impl LoadedHandle {
LoadedHandle::Single(m) => &m.derived_input_cap,
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => &m.derived_input_cap,
LoadedHandle::Image(_) => unreachable!("derived_limit returned above"),
LoadedHandle::Image(_) | LoadedHandle::Decision(_) => {
unreachable!("derived_limit returned above")
}
};
cap.store(input, Ordering::Release);
}
@@ -1162,6 +1192,18 @@ impl ModelArch {
}
}
/// Logits at every position of a multi-token forward: `(B, L,
/// vocab)`. The speculative verify pass (#96) reads the target's
/// own token at each drafted position; `squeeze_to_vocab` is
/// deliberately not applied, as it would collapse the L axis this
/// exists to keep.
pub fn forward_multi(&mut self, input: &Tensor, offset: usize) -> Result<Tensor> {
match self {
ModelArch::Qwen3_5Dense(m) => Ok(m.forward_multi(input, offset)?),
_ => anyhow::bail!("forward_multi: architecture has no multi-position logits support"),
}
}
/// Padding mask for a batched decode step — see
/// `Qwen3_5Model::batch_decode_mask`.
pub fn batch_decode_mask(
@@ -3265,7 +3307,7 @@ impl CandleHarness {
LoadedHandle::Tp(m) => {
return self.inference_tp_stream(m, request, principal, wire).await;
}
LoadedHandle::Image(_) => {
LoadedHandle::Image(_) | LoadedHandle::Decision(_) => {
return Err(InferenceError::WrongModality {
model_id: request.model.clone(),
});
@@ -3505,11 +3547,13 @@ impl CandleHarness {
let reasoning_tokens_inner = loaded.reasoning_tokens.clone();
let tool_call_tokens_inner = loaded.tool_call_tokens.clone();
let tool_schemas_inner = tool_schemas.clone();
let model_id_inner = model_id.clone();
tokio::spawn(
async move {
let _admit = admit;
let _inference_guard = loaded_for_task.inference_lock.lock().await;
match stream_inference_via_worker(
model_id_inner,
worker,
handle,
tokenizer,
@@ -3982,6 +4026,11 @@ impl Harness for CandleHarness {
if matches!(plan.format, super::preflight::SourceFormat::Diffusers) {
return self.load_image_model(spec, &source_id).await;
}
// Laya decision models (#336) take the decision path: one
// device, unquantized (preflight rejected tp>1 and quant).
if matches!(plan.format, super::preflight::SourceFormat::DecisionModel) {
return self.load_decision_model(spec, &source_id).await;
}
let tp_size = spec.tensor_parallel.unwrap_or(1);
if tp_size > 1 {
@@ -4348,6 +4397,11 @@ impl Harness for CandleHarness {
);
}
}
LoadedHandle::Decision(dm) => {
// Each checkpoint's weights drop on the thread that owns
// them (the device worker, for a CUDA load).
dm.release().await;
}
#[cfg(feature = "cuda")]
LoadedHandle::Tp(tp) => {
// Try to recover the inner TpLoadedModel so we can move
@@ -4522,6 +4576,132 @@ impl CandleHarness {
Ok(())
}
/// Load a Laya decision model (#336): resolve the checkpoints' files,
/// build them on the device worker (or the CPU), and register the
/// model with `capabilities: ["decision"]`.
///
/// The repo root is required. A released family also carries
/// `multilingual/` and `typed-decisions/` beside it (#339); each one
/// the repo has is loaded too, so the `/v1/systemone` handler can
/// route non-English states to the multilingual checkpoint and honour
/// a request that pins one. A repo without them (a single fine-tune)
/// serves its root alone.
async fn load_decision_model(
&self,
spec: &ModelSpec,
source_id: &cortex_core::source::ModelSourceId,
) -> Result<()> {
let devices = spec.devices.clone().unwrap_or_else(|| vec![0]);
let device = Self::pick_device(&devices)?;
#[cfg(feature = "cuda")]
let worker = if matches!(device, Device::Cuda(_)) {
Some(self.ensure_device_worker(devices[0]).await?)
} else {
None
};
// CPU build: always the CPU backend. Correct, but at hundreds of
// milliseconds per question it is a fallback, not a serving tier.
#[cfg(not(feature = "cuda"))]
let worker = {
let _ = &device;
None
};
let files = self.resolve_decision_files(source_id, None).await?;
let root = super::decision_model::DecisionCheckpoint::load(
super::decision_model::ROOT_CHECKPOINT.to_string(),
files,
&spec.model_id,
worker.clone(),
)
.await?;
let dtype = root.dtype;
let mut checkpoints = vec![root];
for sub in super::decision::route::FAMILY
.iter()
.filter(|c| **c != super::decision_model::ROOT_CHECKPOINT)
{
let files = match self.resolve_decision_files(source_id, Some(sub)).await {
Ok(files) => files,
Err(e) => {
tracing::info!(
model = %spec.model_id,
checkpoint = sub,
error = %format!("{e:#}"),
"decision checkpoint not available; serving without it"
);
continue;
}
};
checkpoints.push(
super::decision_model::DecisionCheckpoint::load(
sub.to_string(),
files,
&spec.model_id,
worker.clone(),
)
.await?,
);
}
let names: Vec<&str> = checkpoints.iter().map(|c| c.name.as_str()).collect();
tracing::info!(model = %spec.model_id, checkpoints = ?names, "decision checkpoints loaded");
let loaded = super::decision_model::LoadedDecisionModel {
model_id: spec.model_id.clone(),
spec: spec.clone(),
devices,
checkpoints,
poisoned: Arc::new(AtomicBool::new(false)),
admission: crate::harness::admission::AdmissionController::new(&self.admission_cfg),
};
let mut models = self.models.write().await;
models.insert(
spec.model_id.clone(),
LoadedHandle::Decision(Arc::new(loaded)),
);
tracing::info!(model = %spec.model_id, dtype = ?dtype, "decision model loaded");
Ok(())
}
/// Fetch one Laya checkpoint's files through hf-hub: the repo root
/// for `subfolder = None`, else that subfolder. Only the files the
/// checkpoint needs are fetched, not the whole repo.
async fn resolve_decision_files(
&self,
source_id: &cortex_core::source::ModelSourceId,
subfolder: Option<&str>,
) -> Result<super::decision_model::CheckpointFiles> {
let api = self.hf_api_for(&source_id.scheme)?;
let repo = api.model(source_id.repo_path());
let display_id = source_id.to_string();
let prefix = subfolder.map(|s| format!("{s}/")).unwrap_or_default();
let mut fetched = Vec::new();
for file in [
"rl_agent_config.json",
"encoder/config.json",
"model.safetensors",
"tokenizer/tokenizer.json",
"tokenizer/tokenizer_config.json",
] {
let path = format!("{prefix}{file}");
fetched.push(
repo.get(&path)
.await
.with_context(|| format!("fetch {path} from {display_id}"))?,
);
}
// hf-hub materialises a snapshot as a directory tree mirroring
// the repo, so the checkpoint directory is where its config sits.
let dir = fetched[0]
.parent()
.context("checkpoint config has no parent directory")?
.to_path_buf();
Ok(super::decision_model::CheckpointFiles {
dir,
tokenizer: fetched[3].clone(),
})
}
/// Resolve a diffusers-layout Z-Image repo into local component
/// paths, downloading through hf-hub as needed. Verifies the
/// pipeline class before fetching multi-GB weight shards.
@@ -4635,6 +4815,189 @@ impl CandleHarness {
}
}
/// The loaded decision model `model_id` (#336), for the
/// `/v1/systemone` handler: it takes the admission permit, scores
/// through [`super::decision_model::DecisionCheckpoint::decide`],
/// and shapes the answers. A model of another kind is a
/// `WrongModality`, as for images.
pub async fn decision_model(
&self,
model_id: &str,
) -> Result<Arc<super::decision_model::LoadedDecisionModel>, InferenceError> {
let handle = {
let models = self.models.read().await;
models.get(model_id).cloned()
};
match handle {
Some(LoadedHandle::Decision(m)) => Ok(m),
Some(_) => Err(InferenceError::WrongModality {
model_id: model_id.to_string(),
}),
None => Err(InferenceError::ModelNotLoaded(model_id.to_string())),
}
}
/// Answer one `/v1/systemone` request (#338): pick the served decision
/// model and the checkpoint of it that answers, assemble the question
/// sequences, score them in one forward pass, and shape the answers.
///
/// The served model is `served` when the gateway routed the request
/// (the `x-helexa-decision-model` header), else the model the body's
/// `model` names if one is loaded, else the one decision model this
/// node serves. The body's `model` is then read a second time, as the
/// reference server reads it: as an optional pin to a checkpoint of
/// the family (`multilingual`, `typed-decisions`, …).
///
/// Questions are validated and tokenised before admission, so a
/// malformed request never takes a slot.
pub async fn systemone(
&self,
served: Option<&str>,
request: cortex_core::decisions::SystemOneRequest,
principal: Option<&str>,
) -> Result<cortex_core::decisions::SystemOneResponse, SystemOneError> {
use super::decision::{self, route};
let model = match served {
Some(id) => self.decision_model(id).await?,
None => {
self.default_decision_model(request.model.as_deref())
.await?
}
};
if model.poisoned.load(Ordering::Acquire) {
return Err(InferenceError::Other(anyhow::anyhow!(
"decision model '{}' is poisoned; unload and reload it",
model.model_id
))
.into());
}
let pin = route::resolve_pin(request.model.as_deref());
let mut routing = route::route(&model.model_id, &request.state, &request.questions, pin);
let checkpoint = match model.checkpoint(Some(&routing.model)) {
Some(c) => c,
None if pin.is_some() => {
return Err(decision::DecisionRequestError::new(
404,
format!(
"checkpoint '{}' is not served by '{}' on this node",
routing.model, model.model_id
),
)
.into());
}
// Routed (not pinned) to a checkpoint this repo does not
// carry: answer with the default one, and say so.
None => {
let fallback = model.checkpoint(None).ok_or_else(|| {
InferenceError::Other(anyhow::anyhow!(
"decision model '{}' has no checkpoints",
model.model_id
))
})?;
routing.reason = format!(
"{}; the {} checkpoint is not loaded, so {} answered",
routing.reason, routing.model, fallback.name
);
routing.model = fallback.name.clone();
routing.repo = route::repo_of(&model.model_id, &fallback.name);
fallback
}
};
let lang = route::detected_language(&routing).map(str::to_string);
let prepared = decision::prepare(
&request.state,
&request.questions,
&checkpoint.calibration,
&checkpoint.tokenizer,
decision::Budget {
max_len: request.max_len,
head_max_len: request.head_max_len,
},
)?;
let _permit = model.admission.enter(principal).await?;
let outputs: Vec<decision::RowOutput> = if prepared.rows.is_empty() {
Vec::new()
} else {
let batch = super::arch::laya::DecisionBatch {
seqs: prepared
.rows
.iter()
.map(|r| super::arch::laya::DecisionSeq {
input_ids: r.input_ids.clone(),
markers: r.markers.clone(),
qtype: r.qtype as u8,
})
.collect(),
};
checkpoint
.decide(batch)
.await
.map_err(|e| InferenceError::Other(anyhow::anyhow!("decision forward: {e:#}")))?
.into_iter()
.map(|row| decision::RowOutput {
act_probability: row.act.first().copied().unwrap_or(0.0),
logits: row.logits,
})
.collect()
};
let (answers, usage) = decision::shape(
&prepared,
&outputs,
&checkpoint.calibration,
lang.as_deref(),
);
Ok(decision::shape::response(answers, usage, Some(routing)))
}
/// Register an already-built decision model, bypassing the hub fetch
/// `load_model` does — for tests that serve local checkpoints.
#[cfg(test)]
pub(crate) async fn register_decision_model(
&self,
model: super::decision_model::LoadedDecisionModel,
) {
self.models.write().await.insert(
model.model_id.clone(),
LoadedHandle::Decision(Arc::new(model)),
);
}
/// The decision model to serve a request no gateway routed: the one
/// `requested` names if it is loaded, else the only (first) loaded
/// decision model.
async fn default_decision_model(
&self,
requested: Option<&str>,
) -> Result<Arc<super::decision_model::LoadedDecisionModel>, InferenceError> {
let models = self.models.read().await;
if let Some(id) = requested
&& let Some(handle) = models.get(id)
{
return match handle {
LoadedHandle::Decision(m) => Ok(Arc::clone(m)),
_ => Err(InferenceError::WrongModality {
model_id: id.to_string(),
}),
};
}
let mut decision: Vec<&Arc<super::decision_model::LoadedDecisionModel>> = models
.values()
.filter_map(|h| match h {
LoadedHandle::Decision(m) => Some(m),
_ => None,
})
.collect();
decision.sort_by(|a, b| a.model_id.cmp(&b.model_id));
decision
.first()
.map(|m| Arc::clone(m))
.ok_or_else(|| InferenceError::ModelNotLoaded("no decision model is loaded".into()))
}
/// Run one text-to-image generation (#198). This is the internal
/// path the `/v1/images/generations` handler (#200) fronts;
/// admission, validation, tokenization, and PNG encoding happen
@@ -5322,6 +5685,9 @@ impl CandleHarness {
// read them; populated at the prefill→decode boundary inside.
let mut prefill_ms_measured: u32 = 0;
let mut decode_start: Option<std::time::Instant> = None;
let mut phase_forward = std::time::Duration::ZERO;
let mut phase_sample = std::time::Duration::ZERO;
let mut phase_emit = std::time::Duration::ZERO;
// Hoisted out of `'work` so the completion path below
// can report it (#269) — the reuse count is decided
@@ -5482,6 +5848,12 @@ impl CandleHarness {
};
// Decode-phase timer for the Finish prefill/decode split (#85).
decode_start = Some(std::time::Instant::now());
// Phase split (see `PhaseTiming`). Instrumented here
// as well as on the single-GPU path so a healthy
// model is a control for a slow one: both archs share
// this loop, this sampler and a 248,320 vocabulary,
// so a phase that is cheap here and expensive there
// is a property of the arch, not of the loop.
if Some(next_token) == eos_id {
finish_reason = FinishReason::Stop;
@@ -5568,6 +5940,7 @@ impl CandleHarness {
}
for index in 0..max_new.saturating_sub(1) {
let t_forward = std::time::Instant::now();
let logits_vec = match pool
.generate_step(
&model_id,
@@ -5583,6 +5956,8 @@ impl CandleHarness {
break 'work;
}
};
phase_forward += t_forward.elapsed();
let t_sample = std::time::Instant::now();
let logits = match Tensor::new(logits_vec.as_slice(), &Device::Cpu) {
Ok(t) => t,
Err(e) => {
@@ -5611,6 +5986,11 @@ impl CandleHarness {
break 'work;
}
};
phase_sample += t_sample.elapsed();
// NB: this per-step VRAM query is a second
// channel round-trip per token. It is charged
// to neither phase, so it lands in the
// residual between the three and `decode_ms`.
// Always await the query (even when the
// trace! is filtered out by RUST_LOG): the
// channel hop is ~tens of µs, comparable to
@@ -5630,6 +6010,7 @@ impl CandleHarness {
finish_reason = FinishReason::Stop;
break;
}
let t_emit = std::time::Instant::now();
all_tokens.push(next_token);
match handle_tool_call_marker(
next_token,
@@ -5711,6 +6092,7 @@ impl CandleHarness {
"TP stream: decode_stream step failed"
),
}
phase_emit += t_emit.elapsed();
}
}
}
@@ -5775,8 +6157,24 @@ impl CandleHarness {
decode_ms = decode_start
.map(|d| d.elapsed().as_millis() as u32)
.unwrap_or(0),
forward_ms = phase_forward.as_millis(),
sample_ms = phase_sample.as_millis(),
emit_ms = phase_emit.as_millis(),
"chat_completion (stream): done"
);
crate::metrics::record_finish(
&model_id,
prefill_ms_measured,
decode_start
.map(|d| d.elapsed().as_millis() as u32)
.unwrap_or(0),
all_tokens.len() as u32,
Some((
phase_forward.as_millis() as u32,
phase_sample.as_millis() as u32,
phase_emit.as_millis() as u32,
)),
);
let _ = tx
.send(InferenceEvent::Finish {
reason: finish_reason,
@@ -5790,6 +6188,11 @@ impl CandleHarness {
.map(|d| d.elapsed().as_millis() as u32)
.unwrap_or(0),
prefill_tokens: prompt_len as u32,
phases: Some(PhaseTiming {
forward_ms: phase_forward.as_millis() as u32,
sample_ms: phase_sample.as_millis() as u32,
emit_ms: phase_emit.as_millis() as u32,
}),
}),
})
.await;
@@ -6219,6 +6622,33 @@ pub enum InferenceError {
Other(#[from] anyhow::Error),
}
/// Why a `/v1/systemone` request was not answered (#338): a request the
/// reference server would also reject (its status and `detail` text), or
/// a serving failure shared with the other endpoints.
#[derive(Debug)]
pub enum SystemOneError {
Request(super::decision::DecisionRequestError),
Inference(InferenceError),
}
impl From<super::decision::DecisionRequestError> for SystemOneError {
fn from(e: super::decision::DecisionRequestError) -> Self {
SystemOneError::Request(e)
}
}
impl From<InferenceError> for SystemOneError {
fn from(e: InferenceError) -> Self {
SystemOneError::Inference(e)
}
}
impl From<super::admission::AdmissionRejection> for SystemOneError {
fn from(e: super::admission::AdmissionRejection) -> Self {
SystemOneError::Inference(e.into())
}
}
impl From<super::admission::AdmissionRejection> for InferenceError {
fn from(rejection: super::admission::AdmissionRejection) -> Self {
use super::admission::AdmissionRejection;
@@ -7236,6 +7666,9 @@ async fn run_inference_via_worker(
#[cfg(feature = "cuda")]
#[allow(clippy::too_many_arguments)]
async fn stream_inference_via_worker(
// Owned: this runs in a spawned task, and the only other source of
// the model name here is the tracing span, which is not a value.
model_id: String,
worker: Arc<super::device_worker::DeviceWorkerHandle>,
handle: super::device_worker::ArchHandle,
tokenizer: Tokenizer,
@@ -7369,6 +7802,13 @@ async fn stream_inference_via_worker(
};
// Decode-phase timer for the Finish prefill/decode split (#85).
let decode_start = std::time::Instant::now();
// Phase split of the decode loop. Each boundary below is already a
// synchronisation point -- the worker hands back CPU-side logits --
// so this adds no device sync and measures elapsed work rather than
// kernel-launch time.
let mut phase_forward = std::time::Duration::ZERO;
let mut phase_sample = std::time::Duration::ZERO;
let mut phase_emit = std::time::Duration::ZERO;
// Per-token routing. `tokenizers::DecodeStream` carries five
// generic parameters (`M, N, PT, PP, D`) which makes naming
@@ -7475,10 +7915,14 @@ async fn stream_inference_via_worker(
}
for index in 0..max_new.saturating_sub(1) {
let t_forward = std::time::Instant::now();
let logits_vec = worker
.forward_logits(handle, vec![next_token], prompt_len + index)
.await
.map_err(|e| anyhow::anyhow!("decode step {index}: {e:#}"))?;
phase_forward += t_forward.elapsed();
let t_sample = std::time::Instant::now();
let logits = Tensor::new(logits_vec.as_slice(), &Device::Cpu)?;
next_token = match sample_with_penalty(
&logits,
@@ -7498,11 +7942,15 @@ async fn stream_inference_via_worker(
return Err(e);
}
};
phase_sample += t_sample.elapsed();
if Some(next_token) == eos_id {
finish_reason = FinishReason::Stop;
break;
}
if !route_token!(next_token) {
let t_emit = std::time::Instant::now();
let routed = route_token!(next_token);
phase_emit += t_emit.elapsed();
if !routed {
return Ok(finish_reason.as_openai_str().to_string());
}
}
@@ -7524,8 +7972,22 @@ async fn stream_inference_via_worker(
finish_reason = ?finish_reason,
prefill_ms = prefill_elapsed.as_millis(),
decode_ms = decode_start.elapsed().as_millis(),
forward_ms = phase_forward.as_millis(),
sample_ms = phase_sample.as_millis(),
emit_ms = phase_emit.as_millis(),
"chat_completion (stream): done"
);
crate::metrics::record_finish(
&model_id,
prefill_elapsed.as_millis() as u32,
decode_start.elapsed().as_millis() as u32,
all_tokens.len() as u32,
Some((
phase_forward.as_millis() as u32,
phase_sample.as_millis() as u32,
phase_emit.as_millis() as u32,
)),
);
let _ = tx
.send(InferenceEvent::Finish {
reason: finish_reason,
@@ -7537,6 +7999,11 @@ async fn stream_inference_via_worker(
prefill_ms: prefill_elapsed.as_millis() as u32,
decode_ms: decode_start.elapsed().as_millis() as u32,
prefill_tokens: prefill_prompt_len as u32,
phases: Some(PhaseTiming {
forward_ms: phase_forward.as_millis() as u32,
sample_ms: phase_sample.as_millis() as u32,
emit_ms: phase_emit.as_millis() as u32,
}),
}),
})
.await;
@@ -7757,6 +8224,7 @@ fn run_inference_streaming(
prefill_ms: prefill_elapsed.as_millis() as u32,
decode_ms: decode_start.elapsed().as_millis() as u32,
prefill_tokens: prompt_tokens.len() as u32,
phases: None,
}),
});
Ok(())
+19
View File
@@ -57,6 +57,9 @@ pub fn classify_files(
) -> Vec<String> {
match preflight::classify(filenames) {
SourceFormat::Diffusers => vec!["image".to_string()],
// A decision model answers typed questions (`/v1/systemone`);
// it generates no text, so it must not claim "text".
SourceFormat::DecisionModel => vec!["decision".to_string()],
// A repo with no recognised weights (tokenizer-only, or an empty
// entry) is not servable, so claiming "text" would be a lie of
// the same kind this module exists to remove.
@@ -101,6 +104,22 @@ mod tests {
);
}
#[test]
fn laya_repo_is_a_decision_model() {
let files = [
"rl_agent_config.json",
"encoder/config.json",
"model.safetensors",
"tokenizer/tokenizer.json",
];
assert_eq!(
classify_files(&files, || panic!(
"config must not be read for a decision model"
)),
vec!["decision".to_string()]
);
}
#[test]
fn dense_repo_without_vision_config_is_text_only() {
let files = ["config.json", "tokenizer.json", "model.safetensors"];
@@ -0,0 +1,233 @@
//! Per-checkpoint decision settings, read from `rl_agent_config.json`.
//!
//! Parsed with [`Json`], not `serde_json`: the fitted temperatures are
//! written with 17 significant digits, and `serde_json`'s default float
//! parser is not correctly rounded (it reads `1.7601518630981445` as
//! `…443`).
use cortex_core::decisions::Json;
use std::collections::HashMap;
/// Default token budgets when a checkpoint's config omits them.
pub const DEFAULT_MAX_LEN: usize = 512;
pub const DEFAULT_HEAD_MAX_LEN: usize = 192;
/// Bounds on a usable calibration temperature. A fitted value below 1
/// sharpens instead of softening; the shipped `choice:11+` bucket is 0.1006,
/// which would publish a coin flip as a certainty, so the reference refuses
/// to apply anything outside this range.
pub const TEMP_MIN: f64 = 0.5;
pub const TEMP_MAX: f64 = 5.0;
/// A temperature confined to [`TEMP_MIN`, `TEMP_MAX`]; a non-finite or
/// non-numeric value falls back to 1.0.
pub fn clamp_temperature(t: Option<f64>) -> f64 {
match t {
Some(t) if t.is_finite() => t.clamp(TEMP_MIN, TEMP_MAX),
_ => 1.0,
}
}
/// Python's `float(v)` for a JSON value: numbers, bools, and numeric strings.
fn py_float(v: &Json) -> Option<f64> {
match v {
Json::Int(d) => d.parse().ok(),
Json::Float(x) => Some(*x),
Json::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
Json::String(s) => s.trim().parse().ok(),
_ => None,
}
}
/// One set of calibration temperatures: per question type, overridden per
/// `(type, option-count bucket)` (see [`temp_bucket`]). Already clamped.
#[derive(Debug, Clone, PartialEq)]
pub struct Temperatures {
/// Indexed by [`super::QType`] (`choice`, `score`, `noul`).
pub by_type: [f64; 3],
/// `"choice:3-5"` → temperature, and so on.
pub by_options: HashMap<String, f64>,
}
impl Temperatures {
/// The temperature for a question of type index `qtype` with `k` options.
pub fn for_question(&self, qtype: usize, k: usize) -> f64 {
self.by_options
.get(&temp_bucket(qtype, k))
.copied()
.unwrap_or(self.by_type[qtype])
}
fn from_json(
temperature: Option<&Json>,
by_options: Option<&Json>,
fallback: Option<[f64; 3]>,
) -> Result<Self, String> {
let raw = match temperature {
None => fallback.unwrap_or([1.0; 3]).map(Some),
Some(Json::Array(a)) if a.len() == 3 => {
[py_float(&a[0]), py_float(&a[1]), py_float(&a[2])]
}
Some(other) => {
return Err(format!(
"temperature must be a list of 3 floats, got {}",
other.py_repr()
));
}
};
let by_options = match by_options {
Some(Json::Object(m)) => m
.iter()
.map(|(k, v)| (k.clone(), clamp_temperature(py_float(v))))
.collect(),
_ => HashMap::new(),
};
Ok(Self {
by_type: raw.map(clamp_temperature),
by_options,
})
}
}
/// The calibration bucket of a question: its type and option count.
pub fn temp_bucket(qtype: usize, k: usize) -> String {
let size = match k {
0..=2 => "2",
3..=5 => "3-5",
6..=10 => "6-10",
_ => "11+",
};
format!("{}:{size}", super::QType::NAMES[qtype])
}
/// What the sequence builder and the calibration need from a checkpoint.
#[derive(Debug, Clone, PartialEq)]
pub struct DecisionConfig {
/// Total sequence budget.
pub max_len: usize,
/// Budget for the question text and every option together.
pub head_max_len: usize,
pub temperatures: Temperatures,
/// Per-language overrides, keyed by the lower-cased primary subtag
/// (`"de"` for `de-AT`). Empty for the shipped checkpoints; an operator
/// may fit them per language.
pub lang_temperatures: HashMap<String, Temperatures>,
}
impl DecisionConfig {
/// Parse a checkpoint's `rl_agent_config.json`.
pub fn from_json(text: &str) -> Result<Self, String> {
let v = Json::parse(text.as_bytes()).map_err(|e| format!("rl_agent_config.json: {e}"))?;
let usize_or = |key: &str, default: usize| match v.get(key) {
Some(Json::Int(d)) => d.parse().unwrap_or(default),
_ => default,
};
Ok(Self {
max_len: usize_or("max_len", DEFAULT_MAX_LEN),
head_max_len: usize_or("head_max_len", DEFAULT_HEAD_MAX_LEN),
temperatures: Temperatures::from_json(
v.get("temperature"),
v.get("temperature_by_options"),
None,
)?,
lang_temperatures: HashMap::new(),
})
}
/// Add per-language temperatures, the shape the reference takes them in:
/// `{"de": {"temperature": [..3], "temperature_by_options": {..}}}`. A
/// language that omits `temperature` inherits the checkpoint's raw
/// per-type values.
pub fn with_lang_temperatures(mut self, spec: &Json) -> Result<Self, String> {
let Json::Object(m) = spec else {
return Err("lang_temperatures must be an object".into());
};
for (lang, cfg) in m {
let t = Temperatures::from_json(
cfg.get("temperature"),
cfg.get("temperature_by_options"),
Some(self.temperatures.by_type),
)
.map_err(|e| format!("language override {lang:?}: {e}"))?;
let primary = lang.split('-').next().unwrap_or(lang).to_lowercase();
self.lang_temperatures.insert(primary, t);
}
Ok(self)
}
/// The temperatures to apply for a request detected (or declared) as
/// `lang`, which may carry a region (`de-AT`).
pub fn temperatures_for(&self, lang: Option<&str>) -> &Temperatures {
lang.and_then(|l| {
let primary = l.split('-').next().unwrap_or(l).to_lowercase();
self.lang_temperatures.get(&primary)
})
.unwrap_or(&self.temperatures)
}
}
#[cfg(test)]
mod tests {
use super::*;
const ENGLISH: &str = r#"{"encoder": "answerdotai/ModernBERT-large", "head_layers": 2,
"max_len": 512, "head_max_len": 192,
"temperature": [1.6369030475616455, 1.2514300346374512, 1.983399510383606],
"temperature_by_options": {"choice:3-5": 1.7601518630981445, "choice:11+": 0.10058280825614929}}"#;
#[test]
fn shipped_choice_11_bucket_is_clamped() {
let c = DecisionConfig::from_json(ENGLISH).unwrap();
assert_eq!(c.temperatures.for_question(0, 12), TEMP_MIN);
assert_eq!(c.temperatures.for_question(0, 4), 1.7601518630981445);
// No bucket: the per-type value.
assert_eq!(c.temperatures.for_question(1, 7), 1.2514300346374512);
}
#[test]
fn buckets_split_where_the_reference_splits() {
let b: Vec<_> = [1, 2, 3, 5, 6, 10, 11]
.iter()
.map(|&k| temp_bucket(2, k))
.collect();
assert_eq!(
b,
[
"noul:2",
"noul:2",
"noul:3-5",
"noul:3-5",
"noul:6-10",
"noul:6-10",
"noul:11+"
]
);
}
#[test]
fn language_overrides_match_on_primary_subtag() {
let c = DecisionConfig::from_json(ENGLISH)
.unwrap()
.with_lang_temperatures(
&Json::parse(br#"{"DE": {"temperature": [1.0, 1.4, 9.0]}}"#).unwrap(),
)
.unwrap();
assert_eq!(
c.temperatures_for(Some("de-AT")).by_type,
[1.0, 1.4, TEMP_MAX]
);
assert_eq!(c.temperatures_for(Some("fr")), &c.temperatures);
assert_eq!(c.temperatures_for(None), &c.temperatures);
}
#[test]
fn bad_temperatures_are_refused_or_neutralised() {
assert!(DecisionConfig::from_json(r#"{"temperature": [1.0]}"#).is_err());
let c = DecisionConfig::from_json(r#"{"temperature": [" 2.5 ", "x", null]}"#).unwrap();
assert_eq!(c.temperatures.by_type, [2.5, 1.0, 1.0]);
assert_eq!(
(c.max_len, c.head_max_len),
(DEFAULT_MAX_LEN, DEFAULT_HEAD_MAX_LEN)
);
}
}
@@ -0,0 +1,249 @@
//! `CandleHarness::systemone` end to end: request in, routed, validated,
//! scored and shaped response out (#338).
//!
//! The tiny checkpoint (`testdata/laya/tiny/`) exercises the plumbing in
//! CI. The released-weights test replays every reference case through
//! the same entry point the HTTP handler calls and compares the whole
//! response — answers, usage and routing — with what the reference
//! server returned; it needs `LAYA_REFERENCE_DIR`.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use cortex_core::decisions::{Json, SystemOneRequest, limits};
use crate::harness::candle::{CandleHarness, SystemOneError};
use crate::harness::decision_model::{CheckpointFiles, DecisionCheckpoint, LoadedDecisionModel};
fn testdata() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("src/harness/testdata/laya")
}
fn harness() -> Arc<CandleHarness> {
CandleHarness::new(
"http://localhost:13131".into(),
&crate::config::CandleHarnessConfig::default(),
)
}
/// A decision model made of the given `(routing name, directory)`
/// checkpoints, loaded on the CPU in f32.
async fn decision_model(model_id: &str, checkpoints: &[(&str, PathBuf)]) -> LoadedDecisionModel {
let mut loaded = Vec::new();
for (name, dir) in checkpoints {
let files = CheckpointFiles {
tokenizer: if dir.join("tokenizer/tokenizer.json").exists() {
dir.join("tokenizer/tokenizer.json")
} else {
dir.join("tokenizer.json")
},
dir: dir.clone(),
};
loaded.push(
DecisionCheckpoint::load(name.to_string(), files, model_id, None)
.await
.unwrap_or_else(|e| panic!("load {name}: {e:#}")),
);
}
LoadedDecisionModel {
model_id: model_id.to_string(),
spec: serde_json::from_value(serde_json::json!({
"model_id": model_id,
"harness": "candle",
}))
.expect("model spec"),
devices: vec![0],
checkpoints: loaded,
poisoned: Arc::new(AtomicBool::new(false)),
admission: crate::harness::admission::AdmissionController::new(
&crate::config::AdmissionConfig::default(),
),
}
}
fn request(body: &str) -> SystemOneRequest {
SystemOneRequest::parse(body.as_bytes(), limits::DEFAULT_MAX_TOKEN_BUDGET)
.expect("valid request")
}
async fn tiny_harness() -> Arc<CandleHarness> {
let h = harness();
h.register_decision_model(
decision_model("test/tiny", &[("english", testdata().join("tiny"))]).await,
)
.await;
h
}
#[tokio::test]
async fn answers_every_question_in_order_with_exact_usage() {
let h = tiny_harness().await;
let req = request(
r#"{"state": "a b a", "questions": {
"z_last_alphabetically": {"type": "noul", "instructions": "a?"},
"a_first": {"type": "choice", "instructions": "b?", "criteria": ["a", "b", "a b"]}
}}"#,
);
let resp = h.systemone(None, req, None).await.expect("answered");
let json = serde_json::to_value(&resp).unwrap();
// Answers come back in request order, not sorted.
let keys: Vec<&str> = resp.answers.0.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, ["z_last_alphabetically", "a_first"]);
assert_eq!(json["answers"]["z_last_alphabetically"]["type"], "noul");
let probs = json["answers"]["a_first"]["probabilities"]
.as_object()
.unwrap();
let total: f64 = probs.values().map(|p| p.as_f64().unwrap()).sum();
assert!((total - 1.0).abs() < 1e-3, "{probs:?}");
// Usage is the exact number of tokens encoded, output tokens zero.
assert!(json["usage"]["input_tokens"].as_u64().unwrap() > 0);
assert_eq!(json["usage"]["output_tokens"], 0);
assert_eq!(json["routing"]["model"], "english");
assert_eq!(json["routing"]["repo"], "test/tiny");
}
#[tokio::test]
async fn pinning_a_checkpoint_the_model_lacks_is_404() {
let h = tiny_harness().await;
let req = request(
r#"{"state": "a", "model": "multilingual",
"questions": {"q": {"type": "noul", "instructions": "a?"}}}"#,
);
match h.systemone(None, req, None).await {
Err(SystemOneError::Request(e)) => assert_eq!(e.status, 404, "{}", e.detail),
other => panic!("expected a 404, got {other:?}"),
}
}
#[tokio::test]
async fn routing_to_a_missing_checkpoint_falls_back_and_says_so() {
// Devanagari routes to `multilingual`, which this model lacks.
let h = tiny_harness().await;
let req = request(
r#"{"state": "मुझे पैसे वापस चाहिए",
"questions": {"q": {"type": "noul", "instructions": "a?"}}}"#,
);
let resp = h.systemone(None, req, None).await.expect("answered");
let routing = resp.routing.expect("routing");
assert_eq!(routing.model, "english");
assert!(
routing
.reason
.contains("multilingual checkpoint is not loaded"),
"{}",
routing.reason
);
}
#[tokio::test]
async fn invalid_questions_are_rejected_before_admission() {
let h = tiny_harness().await;
let req =
request(r#"{"state": "a", "questions": {"q": {"type": "maybe", "instructions": "a?"}}}"#);
match h.systemone(None, req, None).await {
Err(SystemOneError::Request(e)) => assert_eq!(e.status, 422, "{}", e.detail),
other => panic!("expected a 422, got {other:?}"),
}
}
#[tokio::test]
async fn a_gateway_routed_model_that_is_not_a_decision_model_is_refused() {
let h = tiny_harness().await;
let req = request(r#"{"state": "a", "questions": {}}"#);
match h.systemone(Some("not/loaded"), req, None).await {
Err(SystemOneError::Inference(crate::harness::candle::InferenceError::ModelNotLoaded(
id,
))) => {
assert_eq!(id, "not/loaded")
}
other => panic!("expected ModelNotLoaded, got {other:?}"),
}
}
/// Every reference case through `systemone`, compared with the reference
/// server's whole response. Routing and usage must match exactly;
/// probabilities to within the engine's measured f32 distance from the
/// reference (a last-digit wobble after 4-decimal rounding); the chosen
/// answer exactly.
#[tokio::test]
#[ignore = "needs the released weights: set LAYA_REFERENCE_DIR"]
async fn released_family_answers_like_the_reference_server() {
let root = PathBuf::from(std::env::var("LAYA_REFERENCE_DIR").expect("LAYA_REFERENCE_DIR"));
let h = harness();
h.register_decision_model(
decision_model(
"convaiinnovations/laya",
&[
("english", root.clone()),
("multilingual", root.join("multilingual")),
("typed-decisions", root.join("typed-decisions")),
],
)
.await,
)
.await;
let text = std::fs::read_to_string(testdata().join("reference.json")).unwrap();
let reference = Json::parse(text.as_bytes()).unwrap();
let Some(Json::Array(cases)) = reference.get("cases") else {
panic!("no cases")
};
let mut max_dp = 0.0f64;
for case in cases {
let name = case.get("name").and_then(Json::as_str).unwrap();
let body = serde_json::to_vec(case.get("request").unwrap()).unwrap();
let req = SystemOneRequest::parse(&body, limits::DEFAULT_MAX_TOKEN_BUDGET).unwrap();
let resp = h
.systemone(Some("convaiinnovations/laya"), req, None)
.await
.unwrap_or_else(|e| panic!("{name}: {e:?}"));
let want_json = case.get("response").unwrap();
// Routing and usage compared as serialized text through `Json`:
// exact, key order included. serde_json would re-round the
// reference's floats on the way in.
for key in ["routing", "usage", "model"] {
let got = match key {
"routing" => serde_json::to_string(&resp.routing).unwrap(),
"usage" => serde_json::to_string(&resp.usage).unwrap(),
_ => serde_json::to_string(&resp.model).unwrap(),
};
let want = serde_json::to_string(want_json.get(key).unwrap()).unwrap();
assert_eq!(got, want, "{name}: {key}");
}
let got = serde_json::to_value(&resp).unwrap();
let want: serde_json::Value =
serde_json::from_str(&serde_json::to_string(want_json).unwrap()).unwrap();
let (ga, wa) = (
got["answers"].as_object().unwrap(),
want["answers"].as_object().unwrap(),
);
assert_eq!(
ga.keys().collect::<Vec<_>>(),
wa.keys().collect::<Vec<_>>(),
"{name}: answer ids"
);
for (qid, w) in wa {
let g = &ga[qid];
for key in ["choice", "type", "legend"] {
assert_eq!(g.get(key), w.get(key), "{name}/{qid}: {key}");
}
if let Some(wp) = w["probabilities"].as_object() {
for (opt, p) in wp {
let d = (g["probabilities"][opt].as_f64().unwrap() - p.as_f64().unwrap()).abs();
max_dp = max_dp.max(d);
}
}
for key in ["noul", "score", "confidence", "answer_confidence"] {
if let (Some(gv), Some(wv)) = (
g.get(key).and_then(|v| v.as_f64()),
w.get(key).and_then(|v| v.as_f64()),
) {
max_dp = max_dp.max((gv - wv).abs());
}
}
}
}
eprintln!("released family: max |Δ| over probabilities and confidences {max_dp:.1e}");
assert!(max_dp <= 5e-4, "max |Δ| {max_dp:e}");
}
+59
View File
@@ -0,0 +1,59 @@
//! Laya decision models: everything around the forward pass (#335).
//!
//! A Laya checkpoint answers typed questions over a *state* (text, a JSON
//! document, a conversation) in one encoder forward pass: each question
//! becomes one sequence,
//!
//! ```text
//! [CLS] "<type> question: <instructions>" [SEP] [MASK] opt0 [MASK] opt1 … [SEP] state [SEP]
//! ```
//!
//! and the decision head scores the hidden state at each option's `[MASK]`
//! marker. This module is the part that touches no tensor:
//!
//! - [`question`] — validate and normalise the question definitions, and
//! render each option's text;
//! - [`sequence`] — assemble the token sequences under the model's token
//! budgets ([`prepare`]);
//! - [`shape`] — turn per-option logits into calibrated, typed answers
//! ([`shape()`]);
//! - [`config`] / [`tokenizer`] — the per-checkpoint pieces those need.
//!
//! The reference is the `laya` Python SDK (`laya/common.py`,
//! `laya/agent.py`, `laya/serve.py`), whose behaviour this reproduces token
//! for token: the parity tests replay fixtures recorded from it
//! (`script/laya-reference.py`). The calibration arithmetic follows numpy's
//! float32 evaluation order, so rounded probabilities match to the last
//! published digit.
//!
//! The wire types (`/v1/systemone`) live in [`cortex_core::decisions`].
pub mod config;
pub mod question;
pub mod route;
pub mod sequence;
pub mod shape;
pub mod tokenizer;
#[cfg(test)]
mod handler_tests;
pub use config::DecisionConfig;
pub use cortex_core::decisions::DecisionRequestError;
pub use question::{QType, Question};
pub use sequence::{Budget, OptionStats, Prepared, Row, prepare};
pub use shape::{RowOutput, shape};
pub use tokenizer::{DecisionTokenizer, SpecialTokens, TextEncoder};
/// The constant `model` field of every response: the decision head's name.
/// Which checkpoint answered is reported in `routing`.
pub const DECISION_MODEL_NAME: &str = "laya-rl-agent";
/// A request error in the reference's `ValueError` class: the server
/// answers it with 422 and the message as `detail`.
pub(crate) fn unprocessable(detail: impl Into<String>) -> DecisionRequestError {
DecisionRequestError::new(422, detail)
}
#[cfg(test)]
mod parity_tests;
@@ -0,0 +1,209 @@
//! Replays the reference fixtures (`testdata/laya/reference.json`, recorded
//! by `script/laya-reference.py`) through [`prepare`] and [`shape`].
//!
//! Token ids and marker positions must match exactly. The answers and usage
//! must serialize to exactly the same JSON — same keys, same order, same
//! rounded values — when fed the reference's own raw logits.
//!
//! The English tokenizer is committed. The multilingual one is 34 MB, so the
//! multilingual cases run only when `LAYA_ML_TOKENIZER_DIR` names a copy of
//! the checkpoint's `multilingual/tokenizer/` directory.
use super::*;
use cortex_core::decisions::{Json, SystemOneRequest, limits};
use std::path::{Path, PathBuf};
fn testdata() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("src/harness/testdata/laya")
}
fn reference() -> Json {
Json::parse(&std::fs::read(testdata().join("reference.json")).expect("fixtures")).expect("json")
}
fn to_value(j: &Json) -> serde_json::Value {
serde_json::to_value(j).expect("serializable")
}
fn tokenizer_for(checkpoint: &str) -> Option<DecisionTokenizer> {
let dir = match checkpoint {
// typed-decisions ships the English tokenizer, byte for byte.
"english" | "typed-decisions" => testdata().join("tokenizer"),
"multilingual" => PathBuf::from(std::env::var_os("LAYA_ML_TOKENIZER_DIR")?),
other => panic!("unknown checkpoint {other}"),
};
Some(DecisionTokenizer::from_dir(&dir).expect("tokenizer"))
}
fn config_for(reference: &Json, checkpoint: &str) -> DecisionConfig {
let cfg = reference
.get("header")
.and_then(|h| h.get("checkpoints"))
.and_then(|c| c.get(checkpoint))
.and_then(|c| c.get("cfg"))
.expect("checkpoint cfg");
DecisionConfig::from_json(&serde_json::to_string(cfg).unwrap()).unwrap()
}
fn array(j: &Json) -> &[Json] {
match j {
Json::Array(a) => a,
other => panic!("expected a list, got {other:?}"),
}
}
fn num(j: &Json) -> f64 {
match j {
Json::Int(d) => d.parse().unwrap(),
Json::Float(x) => *x,
other => panic!("expected a number, got {other:?}"),
}
}
/// Replay every case whose tokenizer is available; return how many ran per
/// checkpoint.
fn replay(filter: impl Fn(&str) -> bool) -> Vec<(String, usize)> {
let reference = reference();
let mut ran: Vec<(String, usize)> = Vec::new();
for case in array(reference.get("cases").unwrap()) {
let name = case.get("name").and_then(Json::as_str).unwrap();
if !filter(name) {
continue;
}
let response = case.get("response").unwrap();
let routing = response.get("routing").unwrap();
let checkpoint = routing.get("model").and_then(Json::as_str).unwrap();
let Some(tok) = tokenizer_for(checkpoint) else {
continue;
};
let cfg = config_for(&reference, checkpoint);
let body = serde_json::to_vec(case.get("request").unwrap()).unwrap();
let req = SystemOneRequest::parse(&body, limits::DEFAULT_MAX_TOKEN_BUDGET)
.unwrap_or_else(|e| panic!("{name}: {e}"));
let budget = Budget {
max_len: req.max_len,
head_max_len: req.head_max_len,
};
let prepared = prepare(&req.state, &req.questions, &cfg, &tok, budget)
.unwrap_or_else(|e| panic!("{name}: {e}"));
let items = array(case.get("items").unwrap());
assert_eq!(prepared.rows.len(), items.len(), "{name}: row count");
let mut outputs = Vec::new();
for (i, (row, item)) in prepared.rows.iter().zip(items).enumerate() {
let ids: Vec<u32> = array(item.get("input_ids").unwrap())
.iter()
.map(|v| num(v) as u32)
.collect();
let markers: Vec<usize> = array(item.get("markers").unwrap())
.iter()
.map(|v| num(v) as usize)
.collect();
assert_eq!(row.input_ids, ids, "{name}: row {i} input_ids");
assert_eq!(row.markers, markers, "{name}: row {i} markers");
assert_eq!(row.qtype as usize, num(item.get("qtype").unwrap()) as usize);
let opts = item.get("options").unwrap();
let s = prepared.stats[i];
assert_eq!(
(s.options, s.options_distinct, s.tokens_per_option),
(
num(opts.get("options").unwrap()) as usize,
num(opts.get("options_distinct").unwrap()) as usize,
match opts.get("tokens_per_option").unwrap() {
Json::Null => None,
v => Some(num(v) as usize),
}
),
"{name}: row {i} option stats"
);
outputs.push(RowOutput {
logits: array(item.get("logits").unwrap())
.iter()
.map(|v| num(v) as f32)
.collect(),
act_probability: num(&array(item.get("act").unwrap())[0]) as f32,
});
}
let lang = routing
.get("detection")
.and_then(|d| d.get("language"))
.and_then(Json::as_str);
let (answers, usage) = shape(&prepared, &outputs, &cfg, lang);
// Serialized strings, not values: key order is part of the contract.
assert_eq!(
serde_json::to_string(&answers).unwrap(),
serde_json::to_string(response.get("answers").unwrap()).unwrap(),
"{name}: answers"
);
assert_eq!(
serde_json::to_value(&usage).unwrap(),
to_value(response.get("usage").unwrap()),
"{name}: usage"
);
match ran.iter_mut().find(|(c, _)| c == checkpoint) {
Some((_, n)) => *n += 1,
None => ran.push((checkpoint.to_string(), 1)),
}
}
ran
}
#[test]
fn every_english_fixture_matches_the_reference() {
let ran = replay(|_| true);
let count = |c: &str| ran.iter().find(|(n, _)| n == c).map_or(0, |(_, n)| *n);
// The corpus routes 19 cases to English and 1 to typed-decisions; a
// drop means a case silently stopped running.
assert_eq!(count("english"), 19, "{ran:?}");
assert_eq!(count("typed-decisions"), 1, "{ran:?}");
if std::env::var_os("LAYA_ML_TOKENIZER_DIR").is_some() {
assert_eq!(count("multilingual"), 8, "{ran:?}");
}
}
#[test]
fn empty_questions_need_no_forward() {
let reference = reference();
let tok = tokenizer_for("english").unwrap();
let cfg = config_for(&reference, "english");
let prepared = prepare(
&Json::String("x".into()),
&[],
&cfg,
&tok,
Budget::default(),
)
.unwrap();
assert!(prepared.rows.is_empty());
let (answers, usage) = shape(&prepared, &[], &cfg, None);
assert_eq!(serde_json::to_string(&answers).unwrap(), "{}");
assert_eq!(
serde_json::to_string(&usage).unwrap(),
r#"{"input_tokens":0,"output_tokens":0}"#
);
}
#[test]
fn options_that_cannot_all_fit_are_refused() {
let reference = reference();
let tok = tokenizer_for("english").unwrap();
let cfg = config_for(&reference, "english");
let questions = Json::parse(
br#"{"q": {"type": "choice", "instructions": "i", "criteria": ["a", "b", "c", "d"]}}"#,
)
.unwrap();
let Json::Object(questions) = questions else {
unreachable!()
};
let budget = Budget {
max_len: Some(10),
head_max_len: None,
};
let err = prepare(&Json::String("x".into()), &questions, &cfg, &tok, budget).unwrap_err();
assert_eq!(
(err.status, err.detail.as_str()),
(422, "question 'q' options exceed head_max_len=192")
);
}
@@ -0,0 +1,458 @@
//! Question validation, normalisation and option rendering.
//!
//! Every rejection here is a 422 whose message names the question and what
//! to fix — the same text the reference returns, so a client written
//! against it sees the same errors.
use super::{DecisionRequestError, unprocessable};
use cortex_core::decisions::{HashKey, Json};
use std::collections::HashMap;
/// The three decision primitives. The discriminant is the index the model's
/// type embedding and the calibration tables use.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QType {
/// Pick one of the caller's labels.
Choice = 0,
/// An ordinal level on the caller's rubric.
Score = 1,
/// Yes/no: the probability that a statement holds.
Noul = 2,
}
impl QType {
pub const NAMES: [&'static str; 3] = ["choice", "score", "noul"];
pub fn name(self) -> &'static str {
Self::NAMES[self as usize]
}
fn parse(s: &str) -> Option<Self> {
match s {
"choice" => Some(Self::Choice),
"score" => Some(Self::Score),
"noul" => Some(Self::Noul),
_ => None,
}
}
}
/// A validated question, normalised the way the reference normalises it.
#[derive(Debug, Clone, PartialEq)]
pub struct Question {
pub id: String,
pub kind: QType,
/// The instruction text: the caller's string, or the JSON rendering of
/// a structured value.
pub instructions: String,
/// Each option's rendered text, in answer-index order. For `noul` this
/// is always `[false, true]`, so index 1 is P(true).
pub options: Vec<String>,
/// `choice`: each option's label as the caller wrote it (the answer key).
/// `score`: each level's description (the legend). `noul`: empty.
pub labels: Vec<Json>,
}
impl Question {
/// Validate one question definition and normalise it.
pub fn from_definition(id: &str, def: &Json) -> Result<Self, DecisionRequestError> {
check_question(id, def)?;
Ok(to_internal(id, def))
}
}
/// Validate and normalise every question, in request order. The first
/// invalid question is reported; nothing is normalised until all pass.
pub fn questions_from_request(
questions: &[(String, Json)],
) -> Result<Vec<Question>, DecisionRequestError> {
for (id, def) in questions {
check_question(id, def)?;
}
Ok(questions
.iter()
.map(|(id, def)| to_internal(id, def))
.collect())
}
fn q(id: &str) -> String {
Json::String(id.to_string()).py_repr()
}
fn check_question(id: &str, def: &Json) -> Result<(), DecisionRequestError> {
let qid = q(id);
if !matches!(def, Json::Object(_)) {
return Err(unprocessable(format!(
"question {qid}: definition must be a dict, got {}",
def.py_type_name()
)));
}
let t = def.get("type").unwrap_or(&Json::Null);
let Some(kind) = t.as_str().and_then(QType::parse) else {
return Err(unprocessable(format!(
"question {qid}: unknown type {}; use one of ['choice', 'noul', 'score']",
t.py_repr()
)));
};
if def.get("instructions").is_none() {
return Err(unprocessable(format!(
"question {qid}: no 'instructions'; add the text the model should answer"
)));
}
let crit = def.get("criteria");
match kind {
QType::Choice => check_choice(&qid, crit)?,
QType::Score => {
let Some(Json::Array(levels)) = crit else {
return Err(unprocessable(format!(
"question {qid}: a score question takes 'criteria' as a list of level \
descriptions, index 0 first"
)));
};
if levels.is_empty() {
return Err(unprocessable(format!(
"question {qid}: a score question needs at least one level"
)));
}
if let Some(i) = levels.iter().position(|l| *l == Json::Null) {
return Err(unprocessable(format!(
"question {qid}: score level {i} is null; give every level a description, \
index 0 first"
)));
}
}
QType::Noul => match crit {
None | Some(Json::Null) => {}
Some(Json::Object(m)) => {
let mut keys: Vec<String> = m.iter().map(|(k, _)| k.to_lowercase()).collect();
keys.sort();
keys.dedup();
if keys.iter().any(|k| k != "true" && k != "false") {
let listed = Json::Array(keys.into_iter().map(Json::String).collect());
return Err(unprocessable(format!(
"question {qid}: a noul question takes 'criteria' keyed only \
'true'/'false' (either or both, and omitted is fine), got {}. Those \
keys are the option texts the model reads; any other key was silently \
dropped and replaced with the defaults. If you want the answer worded \
differently, keep 'criteria' keyed 'true'/'false' and set 'labels' \
instead.",
listed.py_repr()
)));
}
}
Some(_) => {
return Err(unprocessable(format!(
"question {qid}: a noul question takes 'criteria' as a dict with optional \
'true'/'false' descriptions, or omits it"
)));
}
},
}
if let Some(labels) = def.get("labels") {
if kind != QType::Noul {
return Err(unprocessable(format!(
"question {qid}: 'labels' is only supported for noul questions"
)));
}
resolve_noul_labels(labels).map_err(|e| unprocessable(format!("question {qid}: {e}")))?;
}
Ok(())
}
fn check_choice(qid: &str, crit: Option<&Json>) -> Result<(), DecisionRequestError> {
let labels: Vec<Json> = match crit {
Some(Json::Object(m)) => m.iter().map(|(k, _)| Json::String(k.clone())).collect(),
Some(Json::Array(a)) => a.clone(),
_ => {
return Err(unprocessable(format!(
"question {qid}: a choice question takes 'criteria' as a dict of label -> \
description, or a list of labels"
)));
}
};
if labels.is_empty() {
return Err(unprocessable(format!(
"question {qid}: a choice question needs at least one criterion"
)));
}
for (i, label) in labels.iter().enumerate() {
match label {
Json::Array(_) | Json::Object(_) => {
return Err(unprocessable(format!(
"question {qid}: choice label {i} is a {}; a label is rendered as option \
text and used as the answer key, so it must be a scalar (a string, number \
or bool), got {}",
label.py_type_name(),
label.py_repr()
)));
}
Json::Null => {
return Err(unprocessable(format!(
"question {qid}: choice label {i} is null; a label is rendered as option \
text and used as the answer key, so it must be a string, number or bool \
-- a null label renders as the text \"None\" while its answer key is \
\"null\""
)));
}
_ => {}
}
}
// Only the list form can repeat a key: an object's keys are unique.
if matches!(crit, Some(Json::Array(_))) {
let mut seen: HashMap<HashKey, usize> = HashMap::new();
for (i, label) in labels.iter().enumerate() {
let key = label.hash_key().expect("scalars are hashable");
if key == HashKey::Unique {
continue;
}
if let Some(first) = seen.get(&key) {
return Err(unprocessable(format!(
"question {qid}: choice label {i} ({}) repeats label {first}; the labels are \
the answer keys, so every option needs its own (1, 1.0 and True are one key)",
label.py_repr()
)));
}
seen.insert(key, i);
}
}
Ok(())
}
const DEFAULT_NOUL_FALSE: &str = "no, the statement does not hold";
const DEFAULT_NOUL_TRUE: &str = "yes, the statement holds";
/// The words a noul question shows the model for its two outcomes.
fn resolve_noul_labels(labels: &Json) -> Result<(String, String), &'static str> {
const BAD: &str =
"noul labels must map exactly 'false' and 'true' to distinct non-empty strings";
let Json::Object(m) = labels else {
return if *labels == Json::Null {
Ok(("false".into(), "true".into()))
} else {
Err(BAD)
};
};
let mut keys: Vec<&str> = m.iter().map(|(k, _)| k.as_str()).collect();
keys.sort_unstable();
if keys != ["false", "true"] {
return Err(BAD);
}
let (Some(Json::String(f)), Some(Json::String(t))) = (labels.get("false"), labels.get("true"))
else {
return Err(BAD);
};
let (f, t) = (py_strip(f), py_strip(t));
if f.is_empty() || t.is_empty() || f == t {
return Err(BAD);
}
Ok((f.to_string(), t.to_string()))
}
/// Python's `str.strip()` with no argument: Unicode whitespace.
fn py_strip(s: &str) -> &str {
s.trim_matches(|c: char| c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c))
}
/// `render_criterion`: a string as itself, anything else as JSON.
fn render_criterion(v: &Json) -> String {
match v {
Json::String(s) => s.clone(),
other => other.dumps(),
}
}
/// Only `None` and `""` mean "no description"; `0` and `false` are values.
fn is_blank(v: &Json) -> bool {
matches!(v, Json::Null) || matches!(v, Json::String(s) if s.is_empty())
}
fn to_internal(id: &str, def: &Json) -> Question {
let kind = def
.get("type")
.and_then(Json::as_str)
.and_then(QType::parse)
.expect("validated");
let instructions = match def.get("instructions").expect("validated") {
Json::String(s) => s.clone(),
other => other.dumps(),
};
let crit = def.get("criteria");
let (options, labels) = match kind {
QType::Choice => {
let pairs: Vec<(Json, Json)> = match crit {
Some(Json::Object(m)) => m
.iter()
.map(|(k, v)| (Json::String(k.clone()), v.clone()))
.collect(),
Some(Json::Array(a)) => a.iter().map(|l| (l.clone(), Json::Null)).collect(),
_ => unreachable!("validated"),
};
let options = pairs
.iter()
.map(|(k, v)| {
if is_blank(v) {
k.py_str()
} else {
format!("{}: {}", k.py_str(), render_criterion(v))
}
})
.collect();
(options, pairs.into_iter().map(|(k, _)| k).collect())
}
QType::Score => {
let Some(Json::Array(levels)) = crit else {
unreachable!("validated")
};
let options = levels
.iter()
.enumerate()
.map(|(i, c)| format!("level {i}: {}", render_criterion(c)))
.collect();
(options, levels.clone())
}
QType::Noul => {
// Keys are matched case-insensitively; a repeat after
// lower-casing keeps the later value, as a dict would.
let mut desc: HashMap<String, Json> = HashMap::new();
if let Some(Json::Object(m)) = crit {
for (k, v) in m {
desc.insert(k.to_lowercase(), v.clone());
}
}
let (fl, tl) =
resolve_noul_labels(def.get("labels").unwrap_or(&Json::Null)).expect("validated");
let render = |key: &str, default: &str| match desc.get(key) {
Some(v) if !is_blank(v) => render_criterion(v),
_ => default.to_string(),
};
(
vec![
format!("{fl}: {}", render("false", DEFAULT_NOUL_FALSE)),
format!("{tl}: {}", render("true", DEFAULT_NOUL_TRUE)),
],
Vec::new(),
)
}
};
Question {
id: id.to_string(),
kind,
instructions,
options,
labels,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn def(s: &str) -> Json {
Json::parse(s.as_bytes()).unwrap()
}
fn err(s: &str) -> String {
Question::from_definition("q", &def(s)).unwrap_err().detail
}
#[test]
fn options_render_as_the_reference_renders_them() {
let c = Question::from_definition(
"q",
&def(r#"{"type": "choice", "instructions": {"a": 1},
"criteria": {"x": "", "y": null, "z": 0, "w": {"d": [1, 2.0]}, "v": "text"}}"#),
)
.unwrap();
assert_eq!(c.instructions, r#"{"a": 1}"#);
assert_eq!(
c.options,
["x", "y", "z: 0", r#"w: {"d": [1, 2.0]}"#, "v: text"]
);
let l = Question::from_definition(
"q",
&def(r#"{"type": "choice", "instructions": "i", "criteria": [1, 2.5, false, "s"]}"#),
)
.unwrap();
assert_eq!(l.options, ["1", "2.5", "False", "s"]);
let s = Question::from_definition(
"q",
&def(r#"{"type": "score", "instructions": "i", "criteria": ["calm", {"k": false}]}"#),
)
.unwrap();
assert_eq!(s.options, ["level 0: calm", r#"level 1: {"k": false}"#]);
let n = Question::from_definition(
"q",
&def(
r#"{"type": "noul", "instructions": "i", "criteria": {"TRUE": "held", "false": ""},
"labels": {"false": " no ", "true": "yes"}}"#,
),
)
.unwrap();
assert_eq!(
n.options,
["no: no, the statement does not hold", "yes: held"]
);
}
#[test]
fn invalid_questions_name_the_question_and_the_fix() {
assert_eq!(
err("[]"),
"question 'q': definition must be a dict, got list"
);
assert_eq!(
err(r#"{"type": "pick"}"#),
"question 'q': unknown type 'pick'; use one of ['choice', 'noul', 'score']"
);
assert_eq!(
err(r#"{"type": "noul"}"#),
"question 'q': no 'instructions'; add the text the model should answer"
);
assert!(
err(r#"{"type": "choice", "instructions": "i", "criteria": []}"#)
.ends_with("needs at least one criterion")
);
assert!(
err(r#"{"type": "choice", "instructions": "i", "criteria": [[1]]}"#)
.starts_with("question 'q': choice label 0 is a list;")
);
assert!(
err(r#"{"type": "choice", "instructions": "i", "criteria": ["a", null]}"#)
.starts_with("question 'q': choice label 1 is null;")
);
assert!(
err(r#"{"type": "choice", "instructions": "i", "criteria": [1, "x", true]}"#)
.starts_with("question 'q': choice label 2 (True) repeats label 0;")
);
assert_eq!(
err(r#"{"type": "score", "instructions": "i", "criteria": ["a", null]}"#),
"question 'q': score level 1 is null; give every level a description, index 0 first"
);
assert!(
err(r#"{"type": "noul", "instructions": "i", "criteria": {"yes": "x"}}"#)
.contains("got ['yes'].")
);
assert_eq!(
err(r#"{"type": "score", "instructions": "i", "criteria": ["a"], "labels": {}}"#),
"question 'q': 'labels' is only supported for noul questions"
);
assert_eq!(
err(r#"{"type": "noul", "instructions": "i", "labels": {"false": "x", "true": " x"}}"#),
"question 'q': noul labels must map exactly 'false' and 'true' to distinct non-empty strings"
);
}
#[test]
fn nan_labels_never_collide() {
assert!(
Question::from_definition(
"q",
&def(r#"{"type": "choice", "instructions": "i", "criteria": [NaN, NaN]}"#),
)
.is_ok()
);
}
}
+301
View File
@@ -0,0 +1,301 @@
//! Choosing which checkpoint of a Laya family answers a request (#339).
//!
//! A released Laya repo carries three checkpoints: the English one at
//! its root, `multilingual/` and `typed-decisions/`. The English
//! checkpoint cannot read other scripts — upstream measured Khmer at
//! 0.000 accuracy with 0.952 confidence — so an unpinned request is
//! routed by the language of its state. This follows the reference's
//! `Router._route` (`laya/router.py`) and its HTTP server's
//! `_resolve_model` (`laya/serve.py`), including the `reason` strings.
//!
//! Precedence, as upstream: a checkpoint the caller names in `model`,
//! then the detected script and language, then the default (English).
//! Upstream's opt-in typed-decisions workflow routing and caller
//! language hints are not exposed over HTTP by the reference, so they
//! are not here either; the matched workflow is still reported.
use cortex_core::decisions::{DecisionRouting, Json};
use crate::harness::lang_detect;
/// The root checkpoint's routing name.
pub const ENGLISH: &str = "english";
pub const MULTILINGUAL: &str = "multilingual";
pub const TYPED_DECISIONS: &str = "typed-decisions";
/// Where an unpinned request with no usable language signal goes.
pub const DEFAULT_CHECKPOINT: &str = ENGLISH;
/// Every checkpoint a released family carries, root first.
pub const FAMILY: [&str; 3] = [ENGLISH, MULTILINGUAL, TYPED_DECISIONS];
/// The reference's `_ALIASES`: names people are likely to type.
const ALIASES: [(&str, &str); 10] = [
("en", ENGLISH),
("laya", ENGLISH),
("default", ENGLISH),
("multi", MULTILINGUAL),
("ml", MULTILINGUAL),
("laya-multilingual", MULTILINGUAL),
("typed", TYPED_DECISIONS),
("typed_decisions", TYPED_DECISIONS),
("laya-typed-decisions", TYPED_DECISIONS),
("decisions", TYPED_DECISIONS),
];
/// The reference server's `_PUBLISHED_MODEL_IDS`: the Hugging Face ids
/// the non-root checkpoints are published under.
const PUBLISHED: [(&str, &str); 2] = [
("convaiinnovations/laya-multilingual", MULTILINGUAL),
("convaiinnovations/laya-typed-decisions", TYPED_DECISIONS),
];
/// Question-id signatures of the four typed-decisions workflows.
const WORKFLOWS: [(&str, [&str; 5]); 4] = [
(
"agent_trace_observability",
["action", "needs_review", "outcome", "risk", "urgency"],
),
(
"customer_service",
["action", "category", "churn_risk", "needs_human", "urgency"],
),
(
"invoice_processing",
[
"discrepancy_severity",
"disposition",
"duplicate",
"matches_order",
"urgency",
],
),
(
"security_incidents",
[
"credential_compromise",
"disposition",
"severity",
"true_positive",
"urgency",
],
),
];
/// The checkpoint a request's `model` field pins, if it names one.
///
/// Anything else — a Jev service name like `jev-1`, the family's own
/// repo id, an empty string — pins nothing and the request is routed.
pub fn resolve_pin(model: Option<&str>) -> Option<&'static str> {
let model = model?;
if model.is_empty() {
return None;
}
let key = py_strip(model).to_lowercase();
if let Some((_, ckpt)) = PUBLISHED.iter().find(|(id, _)| *id == key) {
return Some(ckpt);
}
let key = ALIASES
.iter()
.find(|(alias, _)| *alias == key)
.map(|(_, ckpt)| *ckpt)
.unwrap_or(key.as_str());
FAMILY.iter().copied().find(|c| *c == key)
}
/// The typed-decisions workflow whose question ids these are exactly.
pub fn match_workflow(questions: &[(String, Json)]) -> Option<&'static str> {
let ids: std::collections::BTreeSet<&str> = questions.iter().map(|(q, _)| q.as_str()).collect();
WORKFLOWS.iter().find_map(|(name, sig)| {
let sig: std::collections::BTreeSet<&str> = sig.iter().copied().collect();
(ids == sig).then_some(*name)
})
}
/// Decide which checkpoint answers, and say why.
///
/// `model_id` is the served family's repo id, used for the `repo` field.
/// The result names a checkpoint whether or not this node has it
/// loaded; the caller decides what to do about one it lacks.
pub fn route(
model_id: &str,
state: &Json,
questions: &[(String, Json)],
pin: Option<&'static str>,
) -> DecisionRouting {
if let Some(key) = pin {
return DecisionRouting {
model: key.to_string(),
repo: repo_of(model_id, key),
reason: format!("explicit model={}", py_repr(key)),
detection: None,
workflow: None,
};
}
let workflow = match_workflow(questions).map(str::to_string);
let det = lang_detect::analyse(state);
let (key, reason) = if det.script == "unknown" {
(
DEFAULT_CHECKPOINT,
format!("no letters detected in state; using default ({DEFAULT_CHECKPOINT})"),
)
} else if det.script != "latin" {
(
MULTILINGUAL,
format!(
"non-Latin script ({}, {:.0}% of letters); the English checkpoint cannot read it",
det.script,
100.0 * det.non_latin_fraction
),
)
} else if !det.is_english {
let reason = if let Some(segment) = &det.mixed_segment {
format!(
"Latin script, mostly English, but a line or field reads as {} ({}); \
the English checkpoint cannot read it",
det.language.map(py_repr).unwrap_or_else(|| "None".into()),
py_repr(&segment.chars().take(60).collect::<String>()),
)
} else if let Some(lang) = det.language {
format!(
"Latin script but language looks like {}, not English",
py_repr(lang)
)
} else {
format!(
"Latin script, language not identified but {:.0}% non-English letters; \
not safe for the English checkpoint",
100.0 * det.diacritic_rate
)
};
(MULTILINGUAL, reason)
} else if det.language_undecided {
(
DEFAULT_CHECKPOINT,
format!(
"Latin script, language not identified and no non-English letters; \
using default ({DEFAULT_CHECKPOINT})"
),
)
} else {
(ENGLISH, "English Latin text".to_string())
};
DecisionRouting {
model: key.to_string(),
repo: repo_of(model_id, key),
reason,
detection: Some(analysis_json(&det)),
workflow,
}
}
/// The language per-language calibration keys on: the detected one.
pub fn detected_language(routing: &DecisionRouting) -> Option<&str> {
routing
.detection
.as_ref()
.and_then(|d| d.get("language"))
.and_then(Json::as_str)
}
/// `<repo>` for the root checkpoint, `<repo>/<subfolder>` otherwise.
pub fn repo_of(model_id: &str, checkpoint: &str) -> String {
if checkpoint == ENGLISH {
model_id.to_string()
} else {
format!("{model_id}/{checkpoint}")
}
}
/// The analysis as an order-preserving [`Json`], so the response keeps
/// the reference's field order.
fn analysis_json(det: &lang_detect::Analysis) -> Json {
let text = serde_json::to_string(det).unwrap_or_else(|_| "null".into());
Json::parse(text.as_bytes()).unwrap_or(Json::Null)
}
fn py_repr(s: &str) -> String {
Json::String(s.to_string()).py_repr()
}
/// Python's `str.strip()`: Unicode whitespace, which includes the
/// U+001C–U+001F separators Rust's `trim` leaves alone.
fn py_strip(s: &str) -> &str {
s.trim_matches(|c: char| c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pins_follow_the_reference_server() {
assert_eq!(resolve_pin(Some("multilingual")), Some(MULTILINGUAL));
assert_eq!(resolve_pin(Some(" ML ")), Some(MULTILINGUAL));
assert_eq!(
resolve_pin(Some("convaiinnovations/laya-typed-decisions")),
Some(TYPED_DECISIONS)
);
assert_eq!(resolve_pin(Some("laya")), Some(ENGLISH));
// A Jev service name, the family's own id, and nothing: no pin.
assert_eq!(resolve_pin(Some("jev-1")), None);
assert_eq!(resolve_pin(Some("convaiinnovations/laya")), None);
assert_eq!(resolve_pin(Some("")), None);
assert_eq!(resolve_pin(None), None);
}
#[test]
fn workflow_needs_the_exact_id_set() {
let qs = |ids: &[&str]| -> Vec<(String, Json)> {
ids.iter().map(|i| (i.to_string(), Json::Null)).collect()
};
assert_eq!(
match_workflow(&qs(&[
"urgency",
"action",
"category",
"churn_risk",
"needs_human"
])),
Some("customer_service")
);
assert_eq!(match_workflow(&qs(&["urgency"])), None);
}
/// Every case in the reference fixtures routes to the checkpoint, with
/// the reason and detection, the reference reported.
#[test]
fn routes_every_reference_case_like_the_reference() {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src/harness/testdata/laya/reference.json"),
)
.expect("read reference.json");
let reference = Json::parse(text.as_bytes()).expect("parse reference.json");
let Some(Json::Array(cases)) = reference.get("cases") else {
panic!("no cases");
};
for case in cases {
let name = case.get("name").and_then(Json::as_str).unwrap();
let request = case.get("request").unwrap();
let body = serde_json::to_vec(request).unwrap();
let req = cortex_core::decisions::SystemOneRequest::parse(
&body,
cortex_core::decisions::limits::DEFAULT_MAX_TOKEN_BUDGET,
)
.unwrap();
let routing = route(
"convaiinnovations/laya",
&req.state,
&req.questions,
resolve_pin(req.model.as_deref()),
);
let want = case.get("response").unwrap().get("routing").unwrap();
assert_eq!(
serde_json::to_string(&routing).unwrap(),
serde_json::to_string(want).unwrap(),
"{name}"
);
}
}
}
@@ -0,0 +1,316 @@
//! Sequence assembly: one token sequence per question, under the model's
//! token budgets.
//!
//! ```text
//! [CLS] "<type> question: <instructions>" [SEP] [MASK] opt0 [MASK] opt1 … [SEP] state [SEP]
//! ```
//!
//! The budget rules, in the order they apply:
//!
//! 1. Each option is `[MASK]` plus at most 48 tokens of its text.
//! 2. `head_max_len` bounds the instruction text and all options together.
//! If fewer than 16 tokens would remain for the instructions, every
//! option is cut to the same `max(4, (head_max_len - 16) / options)`
//! tokens (marker included).
//! 3. The instructions get what is left of `head_max_len`, but never fewer
//! than 8 tokens.
//! 4. The state fills the rest of `max_len`. A conversation (a JSON list)
//! keeps its newest turns, so it is cut from the left; anything else is
//! cut from the right.
//! 5. The whole sequence is cut to `max_len`, dropping any marker that no
//! longer fits — a question whose markers do not all fit is refused.
use super::question::{QType, Question, questions_from_request};
use super::tokenizer::TextEncoder;
use super::{DecisionConfig, DecisionRequestError, unprocessable};
use cortex_core::decisions::Json;
/// Option text tokens kept before the head budget is considered.
pub const MAX_OPTION_TOKENS: usize = 48;
/// Head tokens the instructions are guaranteed before options are shrunk.
const MIN_INSTRUCTION_ROOM: usize = 16;
/// Per-option floor (marker included) when options are shrunk.
const MIN_TOKENS_PER_OPTION: usize = 4;
/// Instruction tokens kept however many the options take.
const MIN_INSTRUCTION_TOKENS: usize = 8;
/// Per-request overrides of the checkpoint's token budgets.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Budget {
pub max_len: Option<usize>,
pub head_max_len: Option<usize>,
}
/// One model input row: one question over the state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
pub input_ids: Vec<u32>,
/// Position of each option's `[MASK]` marker, in option order.
pub markers: Vec<usize>,
pub qtype: QType,
}
/// What the head budget did to a question's options.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OptionStats {
pub options: usize,
/// Options whose token span is still unique after the cut. Fewer than
/// `options` means the model can no longer tell some of them apart.
pub options_distinct: usize,
/// The per-option cap applied, when the options had to be shrunk.
pub tokens_per_option: Option<usize>,
}
/// A request turned into model input: one [`Row`] per question, in request
/// order. With no questions there are no rows and nothing to run.
#[derive(Debug, Clone, PartialEq)]
pub struct Prepared {
pub questions: Vec<Question>,
pub rows: Vec<Row>,
pub stats: Vec<OptionStats>,
/// Every token the forward pass encodes: the metered input.
pub input_tokens: usize,
/// The id batched rows are padded with.
pub pad_id: u32,
}
/// The text a state is tokenized from: a string as-is, anything else as
/// `json.dumps(state, ensure_ascii=False)`.
pub fn serialize_state(state: &Json) -> String {
match state {
Json::String(s) => s.clone(),
other => other.dumps(),
}
}
/// Validate a request's questions and build their model input.
///
/// `questions` are the request's definitions in request order; `cfg` and
/// `tok` belong to the checkpoint the request was routed to.
pub fn prepare(
state: &Json,
questions: &[(String, Json)],
cfg: &DecisionConfig,
tok: &dyn TextEncoder,
budget: Budget,
) -> Result<Prepared, DecisionRequestError> {
let special = tok.special();
if questions.is_empty() {
return Ok(Prepared {
questions: Vec::new(),
rows: Vec::new(),
stats: Vec::new(),
input_tokens: 0,
pad_id: special.pad_id,
});
}
let questions = questions_from_request(questions)?;
let max_len = budget.max_len.unwrap_or(cfg.max_len);
let head_max_len = budget.head_max_len.unwrap_or(cfg.head_max_len);
let encode = |text: &str| {
tok.encode(text)
.map_err(|e| DecisionRequestError::new(500, format!("tokenizer failed: {e}")))
};
// Tokenized once and sliced per question.
let state_ids = encode(&serialize_state(state).replace(&special.mask_token, " "))?;
let truncate_left = matches!(state, Json::Array(_));
let frame = Frame {
cls: special.cls_id,
sep: special.sep_id,
max_len,
head_max_len,
};
let mut rows = Vec::with_capacity(questions.len());
let mut stats = Vec::with_capacity(questions.len());
for question in &questions {
let instructions = question.instructions.replace(&special.mask_token, " ");
let head_ids = encode(&format!(
"{} question: {instructions}",
question.kind.name()
))?;
let mut option_ids = Vec::with_capacity(question.options.len());
for option in &question.options {
let mut ids = vec![special.mask_id];
let text = encode(&format!(" {}", option.replace(&special.mask_token, " ")))?;
ids.extend(text.into_iter().take(MAX_OPTION_TOKENS));
option_ids.push(ids);
}
let (row, s) = frame.assemble(head_ids, option_ids, &state_ids, truncate_left);
if row.markers.len() != question.options.len() {
return Err(unprocessable(format!(
"question {} options exceed head_max_len={head_max_len}",
Json::String(question.id.clone()).py_repr()
)));
}
rows.push(Row {
qtype: question.kind,
..row
});
stats.push(s);
}
let input_tokens = rows.iter().map(|r| r.input_ids.len()).sum();
Ok(Prepared {
questions,
rows,
stats,
input_tokens,
pad_id: special.pad_id,
})
}
/// The fixed parts of every sequence for one request.
struct Frame {
cls: u32,
sep: u32,
max_len: usize,
head_max_len: usize,
}
impl Frame {
/// The pure budget arithmetic of one sequence, over already-tokenized parts.
fn assemble(
&self,
mut head_ids: Vec<u32>,
mut option_ids: Vec<Vec<u32>>,
state_ids: &[u32],
truncate_left: bool,
) -> (Row, OptionStats) {
let Frame {
cls,
sep,
max_len,
head_max_len,
} = *self;
let used = |o: &[Vec<u32>]| o.iter().map(Vec::len).sum::<usize>();
// Signed: options can overrun the head budget.
let mut opt_budget = head_max_len as isize - used(&option_ids) as isize;
let mut tokens_per_option = None;
if opt_budget < MIN_INSTRUCTION_ROOM as isize {
let per = MIN_TOKENS_PER_OPTION
.max(head_max_len.saturating_sub(MIN_INSTRUCTION_ROOM) / option_ids.len().max(1));
tokens_per_option = Some(per);
for o in &mut option_ids {
o.truncate(per);
}
opt_budget = head_max_len as isize - used(&option_ids) as isize;
}
head_ids.truncate(opt_budget.max(MIN_INSTRUCTION_TOKENS as isize) as usize);
let mut ids = Vec::with_capacity(max_len);
ids.push(cls);
ids.extend(head_ids);
ids.push(sep);
let mut markers = Vec::with_capacity(option_ids.len());
for o in &option_ids {
markers.push(ids.len());
ids.extend_from_slice(o);
}
ids.push(sep);
let room = max_len.saturating_sub(ids.len() + 1);
let state = if truncate_left {
&state_ids[state_ids.len().saturating_sub(room)..]
} else {
&state_ids[..room.min(state_ids.len())]
};
ids.extend_from_slice(state);
ids.push(sep);
ids.truncate(max_len);
markers.retain(|&m| m < max_len);
let mut distinct = option_ids.clone();
distinct.sort();
distinct.dedup();
(
Row {
input_ids: ids,
markers,
qtype: QType::Choice,
},
OptionStats {
options: option_ids.len(),
options_distinct: distinct.len(),
tokens_per_option,
},
)
}
}
#[cfg(test)]
mod tests {
use super::*;
const CLS: u32 = 1;
const SEP: u32 = 2;
const M: u32 = 9;
fn frame(max_len: usize, head_max_len: usize) -> Frame {
Frame {
cls: CLS,
sep: SEP,
max_len,
head_max_len,
}
}
fn opts(lens: &[usize]) -> Vec<Vec<u32>> {
lens.iter()
.enumerate()
.map(|(i, &n)| {
let mut o = vec![M];
o.extend((0..n - 1).map(|j| 100 + (i * 50 + j) as u32));
o
})
.collect()
}
#[test]
fn roomy_budget_keeps_everything() {
let (row, s) = frame(64, 32).assemble(vec![10, 11], opts(&[2, 3]), &[50, 51], false);
assert_eq!(
row.input_ids,
[CLS, 10, 11, SEP, M, 100, M, 150, 151, SEP, 50, 51, SEP]
);
assert_eq!(row.markers, [4, 6]);
assert_eq!(
(s.options, s.options_distinct, s.tokens_per_option),
(2, 2, None)
);
}
#[test]
fn crowded_head_shrinks_options_evenly_and_keeps_eight_instruction_tokens() {
// 3 options of 10 = 30 tokens against a 40-token head: 10 left < 16.
let head: Vec<u32> = (10..40).collect();
let (row, s) = frame(128, 40).assemble(head, opts(&[10, 10, 10]), &[], false);
// per = max(4, (40 - 16) / 3) = 8; 40 - 24 = 16 instruction tokens.
assert_eq!(s.tokens_per_option, Some(8));
assert_eq!(row.markers, [18, 26, 34]);
let (row, s) = frame(256, 40).assemble((10..40).collect(), opts(&[10; 12]), &[], false);
// per = max(4, 24 / 12 = 2) = 4; the budget goes negative, 8 tokens survive.
assert_eq!(s.tokens_per_option, Some(4));
assert_eq!(row.markers[0], 1 + 8 + 1);
}
#[test]
fn state_is_cut_from_the_side_the_state_shape_asks_for() {
let state: Vec<u32> = (50..60).collect();
let (right, _) = frame(8, 32).assemble(vec![], opts(&[1]), &state, false);
let (left, _) = frame(8, 32).assemble(vec![], opts(&[1]), &state, true);
// [CLS] [SEP] [MASK] [SEP] = 4 tokens, 1 reserved for the last [SEP]: room 3.
assert_eq!(right.input_ids, [CLS, SEP, M, SEP, 50, 51, 52, SEP]);
assert_eq!(left.input_ids, [CLS, SEP, M, SEP, 57, 58, 59, SEP]);
// No room at all: none of the state, not all of it.
let (none, _) = frame(5, 32).assemble(vec![], opts(&[1]), &state, true);
assert_eq!(none.input_ids, [CLS, SEP, M, SEP, SEP]);
}
#[test]
fn markers_past_max_len_are_dropped_and_collisions_counted() {
let same = vec![vec![M, 7, 7], vec![M, 7, 7]];
let (row, s) = frame(5, 64).assemble(vec![], same, &[], false);
// [CLS] [SEP] then markers at 2 and 5; only the first fits in 5.
assert_eq!(row.markers, [2]);
assert_eq!((s.options, s.options_distinct), (2, 1));
}
}
+318
View File
@@ -0,0 +1,318 @@
//! Per-option logits → calibrated, typed answers.
//!
//! The reference computes this in numpy on the float32 logits the model
//! returns, and publishes each probability rounded to 4 decimal places.
//! Reproducing the rounded values exactly means reproducing the float32
//! arithmetic, in numpy's order: division by a float32 temperature, a
//! max-subtracted `exp`, and numpy's *pairwise* summation (not a running
//! sum) for every reduction. The expected score is the exception: numpy
//! promotes `arange(k) * p` to float64.
use super::question::QType;
use super::sequence::Prepared;
use super::{DECISION_MODEL_NAME, DecisionConfig};
use cortex_core::decisions::{
CollapsedOptions, DecisionAction, DecisionAnswer, DecisionUsage, OrderedMap, SystemOneResponse,
};
/// The model's output for one [`super::Row`].
#[derive(Debug, Clone, PartialEq)]
pub struct RowOutput {
/// The scorer's raw logit at each option marker, before calibration.
/// Entries past the row's marker count (batch padding) are ignored.
pub logits: Vec<f32>,
/// The act head's probability of acting: `softmax(act_logits)[0]`.
pub act_probability: f32,
}
/// Build the answers and usage for a prepared request from the model's
/// per-row outputs (`outputs[i]` belongs to `prepared.rows[i]`).
///
/// `lang` selects per-language calibration where the checkpoint has it:
/// the language the request was declared or detected as.
pub fn shape(
prepared: &Prepared,
outputs: &[RowOutput],
cfg: &DecisionConfig,
lang: Option<&str>,
) -> (OrderedMap<DecisionAnswer>, DecisionUsage) {
assert_eq!(
outputs.len(),
prepared.rows.len(),
"one model output per prepared row"
);
let temps = cfg.temperatures_for(lang);
let mut answers = Vec::with_capacity(outputs.len());
let mut collapsed = Vec::new();
for ((question, row), (out, stats)) in prepared
.questions
.iter()
.zip(&prepared.rows)
.zip(outputs.iter().zip(&prepared.stats))
{
let k = row.markers.len();
let p = calibrate(
&out.logits[..k],
temps.for_question(question.kind as usize, k),
);
let answer_confidence = round4(
p.iter()
.copied()
.fold(f32::NEG_INFINITY, f32::max)
.clamp(0.0, 1.0) as f64,
);
let action = DecisionAction {
act_probability: round4(out.act_probability as f64),
};
let probabilities = |key: &dyn Fn(usize) -> String| {
OrderedMap(
p.iter()
.enumerate()
.map(|(i, &v)| (key(i), round4(v as f64)))
.collect(),
)
};
let answer = match question.kind {
QType::Choice => DecisionAnswer::Choice {
choice: question.labels[argmax(&p)].clone(),
probabilities: probabilities(&|i| {
question.labels[i]
.json_key()
.expect("choice labels are scalars")
}),
confidence: round4(entropy_confidence(&p) as f64),
answer_confidence,
action,
},
QType::Score => {
// int64 * float32 promotes to float64 in numpy.
let expected: Vec<f64> = p
.iter()
.enumerate()
.map(|(i, &v)| i as f64 * v as f64)
.collect();
DecisionAnswer::Score {
score: round4(pairwise_sum(&expected)),
legend: OrderedMap(
question
.labels
.iter()
.enumerate()
.map(|(i, c)| (i.to_string(), c.clone()))
.collect(),
),
probabilities: probabilities(&|i| i.to_string()),
confidence: round4(entropy_confidence(&p) as f64),
answer_confidence,
action,
}
}
QType::Noul => {
let yes = p[1] as f64;
DecisionAnswer::Noul {
noul: round4(yes),
confidence: round4(yes.max(1.0 - yes)),
answer_confidence,
action,
}
}
};
answers.push((question.id.clone(), answer));
if stats.options_distinct < stats.options {
collapsed.push((
question.id.clone(),
CollapsedOptions {
total: stats.options,
distinct: stats.options_distinct,
tokens_per_option: stats.tokens_per_option,
},
));
}
}
let usage = DecisionUsage {
input_tokens: prepared.input_tokens,
output_tokens: 0,
options: (!collapsed.is_empty()).then_some(OrderedMap(collapsed)),
};
(OrderedMap(answers), usage)
}
/// Assemble the full response once routing is known.
pub fn response(
answers: OrderedMap<DecisionAnswer>,
usage: DecisionUsage,
routing: Option<cortex_core::decisions::DecisionRouting>,
) -> SystemOneResponse {
SystemOneResponse {
model: DECISION_MODEL_NAME.to_string(),
answers,
usage,
routing,
}
}
/// Temperature-scaled softmax, as the reference evaluates it in numpy:
/// `z = logits / t`, `e = exp(z - z.max())`, `p = e / e.sum()`.
///
/// numpy's SIMD `expf` and libm's disagree by one ulp on about a third of
/// inputs. That is invisible after the 4-decimal rounding on every
/// reference fixture, and far below the engine's own distance from the
/// reference's logits, so it is not reproduced; the steps either side of
/// the `exp` are.
fn calibrate(logits: &[f32], t: f64) -> Vec<f32> {
let z = scale(logits, t);
let max = z.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let e: Vec<f32> = z.iter().map(|&v| (v - max).exp()).collect();
normalize(&e)
}
/// `logits / t` in float32: numpy casts the Python-float temperature to the
/// array's dtype rather than promoting the array.
fn scale(logits: &[f32], t: f64) -> Vec<f32> {
let t = t as f32;
logits.iter().map(|&l| l / t).collect()
}
/// `e / e.sum()` with numpy's pairwise float32 sum.
fn normalize(e: &[f32]) -> Vec<f32> {
let sum = pairwise_sum(e);
e.iter().map(|&v| v / sum).collect()
}
/// `1 - H(p) / log(k)`, clipped to [0, 1]: how concentrated the whole
/// distribution is. Not calibrated; see `answer_confidence` for that.
fn entropy_confidence(p: &[f32]) -> f32 {
let k = p.len();
if k < 2 {
return 1.0;
}
let terms: Vec<f32> = p.iter().map(|&v| v * v.clamp(1e-12, 1.0).ln()).collect();
let ent = -pairwise_sum(&terms);
(1.0 - ent / (k as f64).ln() as f32).clamp(0.0, 1.0)
}
/// The first index of the largest value, as `argmax` picks it.
fn argmax(p: &[f32]) -> usize {
let mut best = 0;
for (i, &v) in p.iter().enumerate() {
if v > p[best] {
best = i;
}
}
best
}
/// Python's `round(x, 4)`: correctly rounded, ties to even on the exact
/// binary value. Rust's fixed-precision formatting rounds the same way.
fn round4(x: f64) -> f64 {
format!("{x:.4}").parse().expect("formatted float parses")
}
/// numpy's pairwise summation over a contiguous array, which the rounded
/// outputs depend on: a plain loop below 8 elements, eight interleaved
/// accumulators up to 128, and recursive halving (at a multiple of 8)
/// beyond.
fn pairwise_sum<T>(a: &[T]) -> T
where
T: Copy + std::ops::Add<Output = T> + Default,
{
const BLOCK: usize = 128;
let n = a.len();
if n < 8 {
let mut res = T::default();
for &v in a {
res = res + v;
}
res
} else if n <= BLOCK {
let mut r = [a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7]];
let mut i = 8;
while i < n - n % 8 {
for j in 0..8 {
r[j] = r[j] + a[i + j];
}
i += 8;
}
let mut res = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7]));
while i < n {
res = res + a[i];
i += 1;
}
res
} else {
let mut n2 = n / 2;
n2 -= n2 % 8;
pairwise_sum(&a[..n2]) + pairwise_sum(&a[n2..])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pairwise_sum_differs_from_a_running_sum_where_numpy_does() {
// 1e8 swamps every 1.0 added to it one at a time in f32; pairwise
// grouping keeps them. `int(np.float32 array.sum())` of this array is 100000008.
let mut a = vec![1.0f32; 16];
a[0] = 1e8;
let running = a.iter().fold(0.0f32, |s, &v| s + v);
assert_eq!(running, 1e8);
assert_eq!(pairwise_sum(&a), 100_000_008.0);
}
/// Rows of `testdata/laya/numpy_softmax.txt`: the reference's
/// calibration run in numpy on random float32 logits, with the bits of
/// every intermediate. Bit equality pins the evaluation order — a
/// running sum instead of numpy's pairwise one, or dividing by the
/// temperature in float64, changes low bits that the 4-decimal rounding
/// usually hides.
fn numpy_rows() -> Vec<(f64, [Vec<f32>; 4])> {
include_str!("../testdata/laya/numpy_softmax.txt")
.lines()
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
.map(|l| {
let mut f = l.split('|');
let t: f64 = f.next().unwrap().parse().unwrap();
let bits = |s: &str| -> Vec<f32> {
s.split(',')
.map(|h| f32::from_bits(u32::from_str_radix(&h[2..], 16).unwrap()))
.collect()
};
let v: Vec<Vec<f32>> = f.map(bits).collect();
(t, [v[0].clone(), v[1].clone(), v[2].clone(), v[3].clone()])
})
.collect()
}
fn bits(v: &[f32]) -> Vec<u32> {
v.iter().map(|x| x.to_bits()).collect()
}
#[test]
fn calibration_matches_numpy_either_side_of_exp() {
let rows = numpy_rows();
assert_eq!(rows.len(), 4);
for (t, [logits, z, e, p]) in rows {
assert_eq!(
bits(&scale(&logits, t)),
bits(&z),
"k={} t={t}: z",
logits.len()
);
assert_eq!(
bits(&normalize(&e)),
bits(&p),
"k={} t={t}: p",
logits.len()
);
}
}
#[test]
fn round4_ties_to_even_on_the_binary_value() {
assert_eq!(round4(0.03125), 0.0312);
assert_eq!(round4(1.00005), 1.0001); // 1.00005 is just above the tie in binary
assert_eq!(round4(0.99995), 1.0);
}
}
@@ -0,0 +1,81 @@
//! The tokenizer a decision checkpoint ships, and its special tokens.
use std::path::Path;
/// The special tokens the sequence layout uses, resolved to ids.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpecialTokens {
/// The option marker's text (`[MASK]`, `<mask>` for mmBERT). Literal
/// occurrences in caller text are replaced with a space before
/// tokenizing, so no caller can forge a marker.
pub mask_token: String,
pub cls_id: u32,
pub sep_id: u32,
pub mask_id: u32,
/// Padding for batched rows; not part of any sequence.
pub pad_id: u32,
}
/// Text → token ids, with no special tokens added.
pub trait TextEncoder {
fn encode(&self, text: &str) -> Result<Vec<u32>, String>;
fn special(&self) -> &SpecialTokens;
}
/// A checkpoint's `tokenizer/` directory: `tokenizer.json` plus the special
/// token names in `tokenizer_config.json`.
pub struct DecisionTokenizer {
tok: tokenizers::Tokenizer,
special: SpecialTokens,
}
impl DecisionTokenizer {
pub fn from_dir(dir: &Path) -> anyhow::Result<Self> {
let mut tok = tokenizers::Tokenizer::from_file(dir.join("tokenizer.json"))
.map_err(|e| anyhow::anyhow!("{}: {e}", dir.join("tokenizer.json").display()))?;
// The Python tokenizer call the reference makes pads and truncates
// nothing unless asked; a tokenizer.json that carries either setting
// would otherwise apply it here.
tok.with_truncation(None)
.map_err(|e| anyhow::anyhow!("disabling truncation: {e}"))?;
tok.with_padding(None);
let cfg_path = dir.join("tokenizer_config.json");
let cfg: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&cfg_path)?)
.map_err(|e| anyhow::anyhow!("{}: {e}", cfg_path.display()))?;
let name = |key: &str| -> anyhow::Result<String> {
cfg.get(key)
.and_then(|v| {
v.as_str()
.or_else(|| v.get("content").and_then(|c| c.as_str()))
})
.map(str::to_owned)
.ok_or_else(|| anyhow::anyhow!("{}: no {key}", cfg_path.display()))
};
let id = |token: &str| -> anyhow::Result<u32> {
tok.token_to_id(token)
.ok_or_else(|| anyhow::anyhow!("special token {token:?} is not in the vocabulary"))
};
let mask_token = name("mask_token")?;
let special = SpecialTokens {
cls_id: id(&name("cls_token")?)?,
sep_id: id(&name("sep_token")?)?,
mask_id: id(&mask_token)?,
pad_id: id(&name("pad_token")?)?,
mask_token,
};
Ok(Self { tok, special })
}
}
impl TextEncoder for DecisionTokenizer {
fn encode(&self, text: &str) -> Result<Vec<u32>, String> {
self.tok
.encode(text, false)
.map(|e| e.get_ids().to_vec())
.map_err(|e| e.to_string())
}
fn special(&self) -> &SpecialTokens {
&self.special
}
}
+252
View File
@@ -0,0 +1,252 @@
//! A loaded Laya decision model, as the rest of neuron sees it (#336).
//!
//! The weights live in a device worker's decision slab (CUDA) or in an
//! `Arc` scored on the blocking pool (CPU); either way the caller holds
//! only this struct and asks it to [`DecisionCheckpoint::decide`] a
//! batch of already-assembled questions. Turning a `/v1/systemone`
//! request into that batch — rendering options, budgets, tokenisation —
//! and turning the scores back into calibrated answers happen outside
//! this module.
//!
//! One Hugging Face repo can carry several checkpoints (the released
//! `convaiinnovations/laya` has the English one at its root and
//! `multilingual/` and `typed-decisions/` beside it), so a loaded model
//! is a list of checkpoints, each named by its subfolder.
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use anyhow::{Context, Result};
use candle_core::DType;
use super::arch::laya::{CheckpointConfig, DecisionBatch, DecisionRow, LayaModel};
use super::decision::{DecisionConfig, DecisionTokenizer};
use super::device_worker::{DecisionHandle, DeviceWorkerHandle};
use cortex_core::harness::ModelSpec;
/// Routing name of the checkpoint at the repo root. A released Laya
/// family's root is its English checkpoint, and upstream routes to it
/// under that name.
pub const ROOT_CHECKPOINT: &str = super::decision::route::ENGLISH;
/// Where one checkpoint's weights live and how to reach them.
enum Backend {
/// Owned by a CUDA device worker; scored through its channel.
Worker {
worker: Arc<DeviceWorkerHandle>,
handle: DecisionHandle,
},
/// CPU fallback. `LayaModel::forward` takes `&self`, so concurrent
/// requests share it without a lock.
Cpu(Arc<LayaModel>),
}
/// The files one checkpoint needs, already on local disk.
#[derive(Debug, Clone)]
pub struct CheckpointFiles {
/// The checkpoint directory: holds `rl_agent_config.json`,
/// `encoder/config.json` and `model.safetensors`.
pub dir: PathBuf,
pub tokenizer: PathBuf,
}
pub struct DecisionCheckpoint {
/// The checkpoint's routing name: [`ROOT_CHECKPOINT`] for the repo
/// root, else its subfolder (`multilingual`, `typed-decisions`).
pub name: String,
pub config: CheckpointConfig,
/// Budgets and fitted temperatures from `rl_agent_config.json`, as
/// the prompt builder and calibration read them.
pub calibration: DecisionConfig,
pub tokenizer: DecisionTokenizer,
/// Precision the weights were loaded in.
pub dtype: DType,
backend: Backend,
}
impl DecisionCheckpoint {
/// Load one checkpoint onto `worker`'s device, or onto the CPU when
/// `worker` is `None`.
pub async fn load(
name: String,
files: CheckpointFiles,
model_id: &str,
worker: Option<Arc<DeviceWorkerHandle>>,
) -> Result<Self> {
let config = CheckpointConfig::from_dir(&files.dir)?;
let calibration_path = files.dir.join("rl_agent_config.json");
let calibration = DecisionConfig::from_json(
&std::fs::read_to_string(&calibration_path)
.with_context(|| format!("read {}", calibration_path.display()))?,
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let tokenizer_dir = files
.tokenizer
.parent()
.context("tokenizer.json has no parent directory")?;
let tokenizer = DecisionTokenizer::from_dir(tokenizer_dir)?;
let (dtype, backend) = match worker {
Some(worker) => {
let wanted = device_dtype(config.laya.amp_dtype.as_deref());
let (handle, dtype) = worker
.load_decision(files.dir.clone(), model_id.to_string(), wanted)
.await
.map_err(|e| anyhow::anyhow!("worker load_decision: {e:#}"))?;
(dtype, Backend::Worker { worker, handle })
}
None => {
let dir = files.dir.clone();
let model = tokio::task::spawn_blocking(move || {
LayaModel::load(&dir, &candle_core::Device::Cpu, DType::F32)
})
.await
.context("decision load task")??;
(DType::F32, Backend::Cpu(Arc::new(model)))
}
};
Ok(Self {
name,
config,
calibration,
tokenizer,
dtype,
backend,
})
}
/// Score one batch: one [`DecisionRow`] per sequence, in order.
pub async fn decide(&self, batch: DecisionBatch) -> Result<Vec<DecisionRow>> {
match &self.backend {
Backend::Worker { worker, handle } => worker
.decide(*handle, batch)
.await
.map_err(|e| anyhow::anyhow!("{e:#}")),
Backend::Cpu(model) => {
let model = Arc::clone(model);
tokio::task::spawn_blocking(move || model.forward(&batch))
.await
.context("decision forward task")?
}
}
}
/// Release the weights. On a worker this drops them on the thread
/// that owns the device context.
pub async fn release(&self) {
if let Backend::Worker { worker, handle } = &self.backend
&& let Err(e) = worker.drop_decision(*handle).await
{
tracing::warn!(
checkpoint = %self.name,
error = %e,
"decision unload: DropDecision RPC failed (weights may leak in worker slab)"
);
}
}
}
/// The precision to serve in on a GPU: the autocast dtype the reference
/// itself runs there (`amp_dtype` in `rl_agent_config.json`), else bf16.
fn device_dtype(amp_dtype: Option<&str>) -> DType {
match amp_dtype {
Some("fp16") | Some("float16") => DType::F16,
Some("fp32") | Some("float32") => DType::F32,
_ => DType::BF16,
}
}
pub struct LoadedDecisionModel {
pub model_id: String,
pub spec: ModelSpec,
pub devices: Vec<u32>,
/// At least one; the first is the default.
pub checkpoints: Vec<DecisionCheckpoint>,
/// Set when a forward returns a driver-level error, mirroring the
/// other model kinds: `/models` reports it and requests fast-reject.
pub poisoned: Arc<AtomicBool>,
pub admission: super::admission::AdmissionController,
}
impl LoadedDecisionModel {
/// The checkpoint called `name`, or the default one for `None`.
pub fn checkpoint(&self, name: Option<&str>) -> Option<&DecisionCheckpoint> {
match name {
None => self.checkpoints.first(),
Some(n) => self.checkpoints.iter().find(|c| c.name == n),
}
}
pub async fn release(&self) {
for c in &self.checkpoints {
c.release().await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tiny() -> CheckpointFiles {
let dir =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/harness/testdata/laya/tiny");
CheckpointFiles {
tokenizer: dir.join("tokenizer.json"),
dir,
}
}
#[test]
fn gpu_dtype_follows_the_reference_autocast() {
assert_eq!(device_dtype(Some("bf16")), DType::BF16);
assert_eq!(device_dtype(Some("fp16")), DType::F16);
assert_eq!(device_dtype(None), DType::BF16);
}
/// The whole async path on the CPU backend, and the device-worker
/// path on a CPU build (the worker runs, with a CPU device), must
/// give the same scores as the model called directly.
#[tokio::test]
async fn both_backends_score_like_the_model() {
let files = tiny();
let direct = LayaModel::load(&files.dir, &candle_core::Device::Cpu, DType::F32).unwrap();
let batch = DecisionBatch {
seqs: vec![super::super::arch::laya::DecisionSeq {
input_ids: vec![5, 9, 11, 3, 7, 2],
markers: vec![1, 3],
qtype: 0,
}],
};
let want = direct.forward(&batch).unwrap();
let cpu = DecisionCheckpoint::load("root".into(), files.clone(), "tiny", None)
.await
.unwrap();
assert_eq!(cpu.decide(batch.clone()).await.unwrap(), want);
// A CPU build's worker holds a CPU device, so this exercises the
// job round-trip (load, decide, drop) without a GPU — and the
// worker must fall back to f32 there, reporting that it did.
let worker = DeviceWorkerHandle::spawn(0).unwrap();
let on_worker =
DecisionCheckpoint::load("root".into(), files, "tiny", Some(worker.clone()))
.await
.unwrap();
#[cfg(not(feature = "cuda"))]
{
assert_eq!(on_worker.dtype, DType::F32);
assert_eq!(on_worker.decide(batch).await.unwrap(), want);
}
#[cfg(feature = "cuda")]
{
let got = on_worker.decide(batch).await.unwrap();
for (g, w) in got[0].logits.iter().zip(&want[0].logits) {
assert!((g - w).abs() < 5e-2, "{g} vs {w}");
}
}
on_worker.release().await;
assert!(on_worker.decide(DecisionBatch::default()).await.is_err());
worker.shutdown().unwrap();
}
}
@@ -13,12 +13,13 @@
//! ARCH model state in this state slab will gain a companion
//! `tp_models: HashMap<TpHandle, Box<TpLeaderModel>>`.
use crate::harness::arch::laya::LayaModel;
use crate::harness::arch::snapshot::KvCacheSnapshot;
use crate::harness::candle::ModelArch;
#[cfg(feature = "cuda")]
use crate::harness::device_worker::jobs::TpHandle;
use crate::harness::device_worker::jobs::{
ArchHandle, DenseLoad, ImageHandle, ImageInput, Job, KvSnapshotId,
ArchHandle, DecisionHandle, DenseLoad, ImageHandle, ImageInput, Job, KvSnapshotId,
};
use crate::harness::image::ZImagePipeline;
#[cfg(feature = "cuda")]
@@ -65,6 +66,11 @@ struct DeviceWorkerState {
image_models: HashMap<ImageHandle, Box<ZImagePipeline>>,
/// Counter for minting fresh `ImageHandle`s.
next_image_handle: u64,
/// Laya decision-model slab (#336). Same lifecycle discipline as
/// `image_models`, in its own namespace.
decision_models: HashMap<DecisionHandle, Box<LayaModel>>,
/// Counter for minting fresh `DecisionHandle`s.
next_decision_handle: u64,
/// Leader's NCCL state. Populated by `Job::NcclInit`; the
/// underlying `Comm`'s libnccl handle lives bound to this thread
/// for its entire lifetime. Subprocess workers maintain their own
@@ -322,6 +328,15 @@ pub(crate) fn run(device_index: u32, rx: Receiver<Job>, poisoned: Arc<AtomicBool
}
let _ = reply.send(result);
}
Job::ForwardLogitsMulti {
handle,
tokens,
offset,
reply,
} => {
let result = forward_logits_multi(&mut state, handle, &tokens, offset);
let _ = reply.send(result);
}
Job::ForwardLogitsBatch {
handle,
tokens,
@@ -599,6 +614,61 @@ pub(crate) fn run(device_index: u32, rx: Receiver<Job>, poisoned: Arc<AtomicBool
let _ = reply.send(result);
}
#[cfg(feature = "cuda")]
Job::TpLoadMtpHead {
handle,
config_json,
safetensors_paths,
reply,
} => {
let result = tp_load_mtp_head(&mut state, handle, &config_json, &safetensors_paths);
let _ = reply.send(result);
}
#[cfg(feature = "cuda")]
Job::TpMtpPrefillChunk {
handle,
shifted,
start_pos,
reply,
} => {
let result = tp_mtp(&mut state, handle, |m| {
m.mtp_prefill_chunk(&shifted, start_pos)
});
let _ = reply.send(result);
}
#[cfg(feature = "cuda")]
Job::TpMtpDraft {
handle,
first_token,
start_pos,
k,
reply,
} => {
let result = tp_mtp(&mut state, handle, |m| {
m.mtp_draft(first_token, start_pos, k)
});
let _ = reply.send(result);
}
#[cfg(feature = "cuda")]
Job::TpMtpAdvance {
handle,
token,
pos,
reply,
} => {
let result = tp_mtp(&mut state, handle, |m| m.mtp_advance(token, pos));
let _ = reply.send(result);
}
#[cfg(feature = "cuda")]
Job::TpForwardLogitsMulti {
handle,
tokens,
offset,
reply,
} => {
let result = tp_forward_logits_multi(&mut state, handle, &tokens, offset);
let _ = reply.send(result);
}
#[cfg(feature = "cuda")]
Job::TpForwardLogitsWithImages {
handle,
tokens,
@@ -687,6 +757,62 @@ pub(crate) fn run(device_index: u32, rx: Receiver<Job>, poisoned: Arc<AtomicBool
trim_device_pool(&state);
let _ = reply.send(result);
}
Job::LoadDecision {
dir,
model_id,
dtype,
reply,
} => {
let dtype = if state.device.is_cpu() {
candle_core::DType::F32
} else {
dtype
};
let result = LayaModel::load(&dir, &state.device, dtype)
.with_context(|| format!("load laya checkpoint {}", dir.display()))
.map(|model| {
let handle = DecisionHandle(state.next_decision_handle);
state.next_decision_handle = state.next_decision_handle.wrapping_add(1);
state.decision_models.insert(handle, Box::new(model));
tracing::info!(
device_index,
model_id,
checkpoint = %dir.display(),
handle = handle.0,
"device worker: decision model loaded"
);
(handle, dtype)
});
trim_device_pool(&state);
let _ = reply.send(result);
}
Job::DropDecision { handle, reply } => {
let removed = state.decision_models.remove(&handle);
let was_present = removed.is_some();
// Explicit drop on this thread, as for DropArch/DropImage.
drop(removed);
if was_present {
trim_device_pool(&state);
}
tracing::debug!(
device_index,
handle = handle.0,
was_present,
"device worker: decision model dropped"
);
let _ = reply.send(());
}
Job::Decide {
handle,
batch,
reply,
} => {
let result = match state.decision_models.get(&handle) {
Some(model) => model.forward(&batch),
None => Err(anyhow::anyhow!("no decision model for handle {}", handle.0)),
};
let _ = reply.send(result);
}
Job::Shutdown => unreachable!("Shutdown should break above"),
}
}
@@ -759,6 +885,8 @@ fn init_state(device_index: u32) -> DeviceWorkerState {
next_kv_snapshot_id: 1,
image_models: HashMap::new(),
next_image_handle: 1,
decision_models: HashMap::new(),
next_decision_handle: 1,
nccl: NcclState::new(),
tp_models: HashMap::new(),
next_tp_handle: 1,
@@ -777,6 +905,8 @@ fn init_state(device_index: u32) -> DeviceWorkerState {
next_kv_snapshot_id: 1,
image_models: HashMap::new(),
next_image_handle: 1,
decision_models: HashMap::new(),
next_decision_handle: 1,
nccl: NcclState::new(),
}
}
@@ -1305,6 +1435,85 @@ fn tp_forward_logits(
Ok(values)
}
/// Run something on the leader's TP draft head (#96).
///
/// The head only exists on the `qwen3_5` arch and only on a model that
/// was asked to load one, so both misses are reported rather than
/// silently skipped.
#[cfg(feature = "cuda")]
fn tp_mtp<T>(
state: &mut DeviceWorkerState,
handle: TpHandle,
f: impl FnOnce(&mut super::super::tp::tp_qwen3_5::TpQwen3_5ForCausalLM) -> anyhow::Result<T>,
) -> anyhow::Result<T> {
let model = state
.tp_models
.get_mut(&handle)
.ok_or_else(|| anyhow::anyhow!("tp mtp: no model for handle {}", handle.0))?;
match &mut **model {
super::super::tp::TpLeaderModel::Qwen3_5(m) => f(m),
super::super::tp::TpLeaderModel::Qwen3(_) => {
anyhow::bail!("tp mtp: the qwen3 arch ships no MTP head")
}
}
}
/// Load the draft head onto the leader's TP model (#96).
#[cfg(feature = "cuda")]
fn tp_load_mtp_head(
state: &mut DeviceWorkerState,
handle: TpHandle,
config_json: &str,
safetensors_paths: &[String],
) -> anyhow::Result<()> {
use candle_core::DType;
let cfg = super::super::tp::tp_qwen3_5::Config::from_config_json(config_json)
.context("tp mtp: parse config.json")?;
let paths: Vec<std::path::PathBuf> = safetensors_paths.iter().map(Into::into).collect();
let device = state.device.clone();
// SAFETY: the same checkpoint files the shard load already mmapped.
let vb = unsafe {
candle_nn::var_builder::ShardedSafeTensors::var_builder(&paths, DType::BF16, &device)?
};
tp_mtp(state, handle, |m| m.load_mtp_head(&cfg.text_config, &vb))
}
/// TP mirror of [`forward_logits_multi`] on the leader's shard (#96).
/// The caller has already fanned the matching `GenerateStepMulti` out
/// to the subprocess ranks; the `AllReduce` collectives inside this
/// forward block until they arrive.
#[cfg(feature = "cuda")]
fn tp_forward_logits_multi(
state: &mut DeviceWorkerState,
handle: TpHandle,
tokens: &[u32],
offset: usize,
) -> anyhow::Result<Vec<Vec<f32>>> {
use candle_core::{DType, Tensor};
anyhow::ensure!(
!tokens.is_empty(),
"TpForwardLogitsMulti: empty token slice"
);
let input = Tensor::new(tokens, &state.device)?.unsqueeze(0)?;
let model = state
.tp_models
.get_mut(&handle)
.ok_or_else(|| anyhow::anyhow!("TpForwardLogitsMulti: no model for handle {}", handle.0))?;
// (1, L, vocab) → one CPU row per position.
let logits = model.forward_multi(&input, offset)?.to_dtype(DType::F32)?;
let (_, positions, vocab) = logits.dims3()?;
anyhow::ensure!(
positions == tokens.len(),
"TpForwardLogitsMulti: model returned {positions} rows for {} tokens",
tokens.len()
);
let flat: Vec<f32> = logits.flatten_all()?.to_vec1()?;
Ok(flat.chunks(vocab).map(<[f32]>::to_vec).collect())
}
/// TP-equivalent of [`forward_logits_batch`] on the leader's shard
/// (#98). The caller has already fanned the matching
/// `GenerateStepBatch` out to the subprocess ranks.
@@ -1575,6 +1784,40 @@ fn extract_kv_rows(
/// on the worker's device, derives per-row positions and the padding
/// mask, and copies each row's logits back to CPU — same
/// "tensors never escape the worker" contract as `forward_logits`.
/// Logits at every position of a multi-token forward (#96).
///
/// The tensors stay on the worker thread; only CPU rows cross back, one
/// per position. At K+1 = 5 positions and a 248k vocabulary that is
/// ~5 MB a round — acceptable while this is a measurement path, and the
/// first thing to replace with a device-side argmax if the draft/verify
/// loop ever runs on it in production.
fn forward_logits_multi(
state: &mut DeviceWorkerState,
handle: ArchHandle,
tokens: &[u32],
offset: usize,
) -> anyhow::Result<Vec<Vec<f32>>> {
use candle_core::{DType, Tensor};
anyhow::ensure!(!tokens.is_empty(), "ForwardLogitsMulti: empty token slice");
let input = Tensor::new(tokens, &state.device)?.unsqueeze(0)?;
let arch = state
.models
.get_mut(&handle)
.ok_or_else(|| anyhow::anyhow!("ForwardLogitsMulti: no model for handle {}", handle.0))?;
// (1, L, vocab) → one CPU row per position.
let logits = arch.forward_multi(&input, offset)?.to_dtype(DType::F32)?;
let (_, positions, vocab) = logits.dims3()?;
anyhow::ensure!(
positions == tokens.len(),
"ForwardLogitsMulti: model returned {positions} rows for {} tokens",
tokens.len()
);
let flat: Vec<f32> = logits.flatten_all()?.to_vec1()?;
Ok(flat.chunks(vocab).map(<[f32]>::to_vec).collect())
}
fn forward_logits_batch(
state: &mut DeviceWorkerState,
handle: ArchHandle,
@@ -1771,6 +2014,9 @@ fn drain_poisoned(job: Job, device_index: u32) {
Job::AssembleKvBatch { reply, .. } => {
let _ = reply.send(Err(err()));
}
Job::ForwardLogitsMulti { reply, .. } => {
let _ = reply.send(Err(err()));
}
Job::ForwardLogitsBatch { reply, .. } => {
let _ = reply.send(Err(err()));
}
@@ -1789,6 +2035,15 @@ fn drain_poisoned(job: Job, device_index: u32) {
Job::GenerateImage { reply, .. } => {
let _ = reply.send(Err(err()));
}
Job::LoadDecision { reply, .. } => {
let _ = reply.send(Err(err()));
}
Job::DropDecision { reply, .. } => {
let _ = reply.send(());
}
Job::Decide { reply, .. } => {
let _ = reply.send(Err(err()));
}
Job::ForwardLogitsWithImages { reply, .. } => {
let _ = reply.send(Err(err()));
}
@@ -1847,6 +2102,26 @@ fn drain_poisoned(job: Job, device_index: u32) {
let _ = reply.send(());
}
#[cfg(feature = "cuda")]
Job::TpLoadMtpHead { reply, .. } => {
let _ = reply.send(Err(err()));
}
#[cfg(feature = "cuda")]
Job::TpMtpPrefillChunk { reply, .. } => {
let _ = reply.send(Err(err()));
}
#[cfg(feature = "cuda")]
Job::TpMtpDraft { reply, .. } => {
let _ = reply.send(Err(err()));
}
#[cfg(feature = "cuda")]
Job::TpMtpAdvance { reply, .. } => {
let _ = reply.send(Err(err()));
}
#[cfg(feature = "cuda")]
Job::TpForwardLogitsMulti { reply, .. } => {
let _ = reply.send(Err(err()));
}
#[cfg(feature = "cuda")]
Job::TpForwardLogits { reply, .. } => {
let _ = reply.send(Err(err()));
}
@@ -59,6 +59,15 @@ pub struct TpHandle(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ImageHandle(pub u64);
/// Opaque handle to a Laya decision model ([`LayaModel`]) stored in the
/// worker thread's decision slab (#336). Same discipline as
/// [`ImageHandle`]: construction, every forward and `Drop` happen on the
/// worker thread, and `Job::DropDecision` is the only way to release it.
///
/// [`LayaModel`]: crate::harness::arch::laya::LayaModel
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct DecisionHandle(pub u64);
/// Opaque handle to a prefix-cache snapshot (#11) stored worker-side
/// next to the model slab. Scoped to the `ArchHandle` it was captured
/// from — `Job::DropArch` drops every snapshot under its handle. The
@@ -225,6 +234,19 @@ pub enum Job {
/// values `AssembleKvBatch` was built from) and replies one CPU
/// `[vocab]` logits row per batch row, ready for per-slot
/// sampling on the async side.
/// Logits at every position of a multi-token forward (#96).
///
/// `ForwardLogits` returns one `[vocab]` row because a decode step
/// only has one. A speculative verify pass forwards K+1 tokens and
/// needs the target's token at each of them, so this returns one
/// row per position — `Vec<Vec<f32>>`, like `ForwardLogitsBatch`,
/// but indexed by position rather than by batch row.
ForwardLogitsMulti {
handle: ArchHandle,
tokens: Vec<u32>,
offset: usize,
reply: oneshot::Sender<Result<Vec<Vec<f32>>>>,
},
ForwardLogitsBatch {
handle: ArchHandle,
tokens: Vec<u32>,
@@ -381,6 +403,59 @@ pub enum Job {
/// without holding a device tensor. The caller is also
/// responsible for fan-out to subprocess ranks and drain — only
/// the leader's forward moves into the worker thread.
/// Load the MTP draft head onto the leader's TP model (#96).
///
/// Leader-only and deliberately not part of `TpLoadShard`: the
/// subprocess ranks have no use for a head they never run, and an
/// unsharded head on every rank would cost 810 MB each to sit idle.
#[cfg(feature = "cuda")]
TpLoadMtpHead {
handle: TpHandle,
config_json: String,
safetensors_paths: Vec<String>,
reply: oneshot::Sender<Result<()>>,
},
/// Walk the leader's draft head over a prompt chunk (#96), using
/// the hidden state the target's forward just produced.
#[cfg(feature = "cuda")]
TpMtpPrefillChunk {
handle: TpHandle,
/// The chunk's tokens shifted one left — the head trails the
/// target by a token.
shifted: Vec<u32>,
start_pos: usize,
reply: oneshot::Sender<Result<()>>,
},
/// Draft `k` tokens on the leader and put the head's cache back
/// (#96). Commits nothing: the snapshot/restore pair lives inside
/// the job so a caller cannot forget it.
#[cfg(feature = "cuda")]
TpMtpDraft {
handle: TpHandle,
first_token: u32,
start_pos: usize,
k: usize,
reply: oneshot::Sender<Result<Vec<u32>>>,
},
/// Advance the leader's draft head over one committed token (#96).
#[cfg(feature = "cuda")]
TpMtpAdvance {
handle: TpHandle,
token: u32,
pos: usize,
reply: oneshot::Sender<Result<()>>,
},
/// TP mirror of `ForwardLogitsMulti` (#96): logits at every
/// position of a multi-token forward on the leader's shard. The
/// caller has already fanned the matching `GenerateStepMulti` out
/// to the subprocess ranks, whose collectives this one waits on.
#[cfg(feature = "cuda")]
TpForwardLogitsMulti {
handle: TpHandle,
tokens: Vec<u32>,
offset: usize,
reply: oneshot::Sender<Result<Vec<Vec<f32>>>>,
},
#[cfg(feature = "cuda")]
TpForwardLogits {
handle: TpHandle,
@@ -483,6 +558,33 @@ pub enum Job {
max_dim: usize,
reply: oneshot::Sender<Result<crate::harness::image::ImageGenResult>>,
},
/// Load one Laya checkpoint directory (#336) — the repo root or one
/// of its subfolders, already on local disk — into the decision
/// slab. File resolution (hf-hub) happens on the async side.
///
/// `dtype` is the precision wanted on a GPU; a worker whose device
/// is the CPU loads in f32 instead (the CPU backend has no bf16
/// matmul). The reply carries the dtype actually used.
LoadDecision {
dir: PathBuf,
model_id: String,
dtype: candle_core::DType,
reply: oneshot::Sender<Result<(DecisionHandle, candle_core::DType)>>,
},
/// Remove a decision model from the slab and drop it on the worker
/// thread. Mirrors `DropArch` / `DropImage`.
DropDecision {
handle: DecisionHandle,
reply: oneshot::Sender<()>,
},
/// Score one batch of assembled questions (#336). One job per
/// request: the whole batch is a single encoder forward, and the
/// reply is CPU-side scores — no device tensor crosses back.
Decide {
handle: DecisionHandle,
batch: crate::harness::arch::laya::DecisionBatch,
reply: oneshot::Sender<Result<Vec<crate::harness::arch::laya::DecisionRow>>>,
},
/// Tell the worker to break its dispatch loop and exit. Any jobs
/// queued after this in the channel reply `Err` to their oneshot
/// senders (the senders are dropped on the worker's exit, which
+245 -1
View File
@@ -51,7 +51,7 @@ use tokio::sync::oneshot;
#[cfg(feature = "cuda")]
pub use jobs::TpHandle;
pub use jobs::{ArchHandle, DenseLoad, ImageHandle, Job, KvSnapshotId};
pub use jobs::{ArchHandle, DecisionHandle, DenseLoad, ImageHandle, Job, KvSnapshotId};
/// Errors returned by `DeviceWorkerHandle` submit methods.
#[derive(Debug, thiserror::Error)]
@@ -337,6 +337,85 @@ impl DeviceWorkerHandle {
}
}
/// Load one Laya checkpoint directory (#336) on the worker thread.
/// Returns the handle and the dtype the weights were loaded in.
pub async fn load_decision(
&self,
dir: std::path::PathBuf,
model_id: String,
dtype: candle_core::DType,
) -> Result<(DecisionHandle, candle_core::DType), WorkerError> {
if self.poisoned.load(Ordering::Acquire) {
return Err(WorkerError::Poisoned {
device_index: self.device_index,
});
}
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(Job::LoadDecision {
dir,
model_id,
dtype,
reply: reply_tx,
})
.map_err(|_| WorkerError::Gone {
device_index: self.device_index,
})?;
match reply_rx.await {
Ok(result) => result.map_err(WorkerError::from),
Err(_) => Err(WorkerError::Gone {
device_index: self.device_index,
}),
}
}
/// Drop the decision model for `handle` on the worker thread. Same
/// poison-tolerant contract as [`Self::drop_arch`].
pub async fn drop_decision(&self, handle: DecisionHandle) -> Result<(), WorkerError> {
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(Job::DropDecision {
handle,
reply: reply_tx,
})
.map_err(|_| WorkerError::Gone {
device_index: self.device_index,
})?;
reply_rx.await.map_err(|_| WorkerError::Gone {
device_index: self.device_index,
})
}
/// Score one batch of assembled questions (#336). The reply is
/// CPU-side scores; no device tensor crosses back.
pub async fn decide(
&self,
handle: DecisionHandle,
batch: crate::harness::arch::laya::DecisionBatch,
) -> Result<Vec<crate::harness::arch::laya::DecisionRow>, WorkerError> {
if self.poisoned.load(Ordering::Acquire) {
return Err(WorkerError::Poisoned {
device_index: self.device_index,
});
}
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(Job::Decide {
handle,
batch,
reply: reply_tx,
})
.map_err(|_| WorkerError::Gone {
device_index: self.device_index,
})?;
match reply_rx.await {
Ok(result) => result.map_err(WorkerError::from),
Err(_) => Err(WorkerError::Gone {
device_index: self.device_index,
}),
}
}
/// Tell the worker to drop the `ModelArch` for `handle` on the
/// worker thread (so CUDA tensors release on the right context).
/// Returns `Ok(())` even if the handle wasn't in the slab — Drop
@@ -578,6 +657,40 @@ impl DeviceWorkerHandle {
}
}
/// Multi-position forward (#96): logits at every position of
/// `tokens`, one CPU `[vocab]` row each. Same no-device-tensor
/// contract as [`Self::forward_logits`]; the speculative verify
/// pass reads the target's token at each drafted position.
pub async fn forward_logits_multi(
&self,
handle: ArchHandle,
tokens: Vec<u32>,
offset: usize,
) -> Result<Vec<Vec<f32>>, WorkerError> {
if self.poisoned.load(Ordering::Acquire) {
return Err(WorkerError::Poisoned {
device_index: self.device_index,
});
}
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(Job::ForwardLogitsMulti {
handle,
tokens,
offset,
reply: reply_tx,
})
.map_err(|_| WorkerError::Gone {
device_index: self.device_index,
})?;
match reply_rx.await {
Ok(result) => result.map_err(WorkerError::from),
Err(_) => Err(WorkerError::Gone {
device_index: self.device_index,
}),
}
}
/// One lockstep batched decode step (#98): row i's next token at
/// position `prefix_lens[i] + step`. Returns one CPU `[vocab]`
/// logits row per batch row, ready for per-slot sampling — same
@@ -934,6 +1047,137 @@ impl DeviceWorkerHandle {
/// logits as `Vec<f32>` ready for sampling. The caller is
/// responsible for fan-out / drain of the subprocess workers
/// concurrently with this call.
/// Load the MTP draft head onto the leader's TP model (#96).
#[cfg(feature = "cuda")]
pub async fn tp_load_mtp_head(
&self,
handle: TpHandle,
config_json: String,
safetensors_paths: Vec<String>,
) -> Result<(), WorkerError> {
self.tp_mtp_job(|reply| Job::TpLoadMtpHead {
handle,
config_json,
safetensors_paths,
reply,
})
.await
}
/// Walk the leader's draft head over a prompt chunk (#96).
#[cfg(feature = "cuda")]
pub async fn tp_mtp_prefill_chunk(
&self,
handle: TpHandle,
shifted: Vec<u32>,
start_pos: usize,
) -> Result<(), WorkerError> {
self.tp_mtp_job(|reply| Job::TpMtpPrefillChunk {
handle,
shifted,
start_pos,
reply,
})
.await
}
/// Draft `k` tokens on the leader, rewinding the head afterwards
/// (#96). Commits nothing.
#[cfg(feature = "cuda")]
pub async fn tp_mtp_draft(
&self,
handle: TpHandle,
first_token: u32,
start_pos: usize,
k: usize,
) -> Result<Vec<u32>, WorkerError> {
self.tp_mtp_job(|reply| Job::TpMtpDraft {
handle,
first_token,
start_pos,
k,
reply,
})
.await
}
/// Advance the leader's draft head over one committed token (#96).
#[cfg(feature = "cuda")]
pub async fn tp_mtp_advance(
&self,
handle: TpHandle,
token: u32,
pos: usize,
) -> Result<(), WorkerError> {
self.tp_mtp_job(|reply| Job::TpMtpAdvance {
handle,
token,
pos,
reply,
})
.await
}
/// Shared submit/await for the leader-only draft-head jobs (#96):
/// same poison check and channel handling as every other job, in
/// one place because these four differ only in their payload.
#[cfg(feature = "cuda")]
async fn tp_mtp_job<T>(
&self,
build: impl FnOnce(oneshot::Sender<anyhow::Result<T>>) -> Job,
) -> Result<T, WorkerError> {
if self.poisoned.load(Ordering::Acquire) {
return Err(WorkerError::Poisoned {
device_index: self.device_index,
});
}
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(build(reply_tx))
.map_err(|_| WorkerError::Gone {
device_index: self.device_index,
})?;
match reply_rx.await {
Ok(result) => result.map_err(WorkerError::from),
Err(_) => Err(WorkerError::Gone {
device_index: self.device_index,
}),
}
}
/// TP mirror of [`Self::forward_logits_multi`] (#96): one CPU
/// `[vocab]` row per position from the leader's shard.
#[cfg(feature = "cuda")]
pub async fn tp_forward_logits_multi(
&self,
handle: TpHandle,
tokens: Vec<u32>,
offset: usize,
) -> Result<Vec<Vec<f32>>, WorkerError> {
if self.poisoned.load(Ordering::Acquire) {
return Err(WorkerError::Poisoned {
device_index: self.device_index,
});
}
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(Job::TpForwardLogitsMulti {
handle,
tokens,
offset,
reply: reply_tx,
})
.map_err(|_| WorkerError::Gone {
device_index: self.device_index,
})?;
match reply_rx.await {
Ok(result) => result.map_err(WorkerError::from),
Err(_) => Err(WorkerError::Gone {
device_index: self.device_index,
}),
}
}
#[cfg(feature = "cuda")]
pub async fn tp_forward_logits(
&self,
+2
View File
@@ -663,6 +663,7 @@ async fn finish_slot(slot: &mut Slot, reason: FinishReason, rate: &PrefillRateEm
prefill_ms: slot.prefill_ms,
decode_ms: slot.decode_start.elapsed().as_millis() as u32,
prefill_tokens: slot.prefill_tokens,
phases: None,
},
})
.await;
@@ -1151,6 +1152,7 @@ mod tests {
prefill_ms: 6000,
decode_ms: 900_000,
prefill_tokens: 7413,
phases: None,
},
})
.await
File diff suppressed because it is too large Load Diff
+3
View File
@@ -6,10 +6,13 @@ pub mod candle;
pub mod capability;
pub mod chat_template;
pub mod context_limit;
pub mod decision;
pub mod decision_model;
pub mod device_worker;
pub mod engine;
pub mod image;
pub mod isq_cache;
pub mod lang_detect;
pub mod prefix_cache;
pub mod preflight;
pub mod preprocess;
+116
View File
@@ -54,6 +54,11 @@ pub enum SourceFormat {
/// Takes precedence over the safetensors sniff because diffusers
/// repos also contain `.safetensors` shards (in subdirs).
Diffusers,
/// A Laya decision-model checkpoint (#336): `rl_agent_config.json`
/// plus `encoder/config.json` at the root, no top-level
/// `config.json`. Served by the decision path — single device,
/// unquantized. Takes precedence over the safetensors sniff.
DecisionModel,
}
/// Output of `preflight` for a load that can proceed. Carries the
@@ -115,6 +120,12 @@ pub enum PreflightError {
available: Vec<String>,
nearest: Option<String>,
},
/// The repo's format cannot be served with an option the spec
/// asked for (e.g. `tensor_parallel` or `quant` on a decision
/// model). `reason` says which and what to set instead.
#[error("cannot load '{model_id}' as requested: {reason}")]
Unsupported { model_id: String, reason: String },
}
/// Run the placement check.
@@ -230,6 +241,24 @@ pub async fn preflight(
// The architecture compatibility check stays where it is —
// `check_dense_config_supported` runs once `config.json` is
// on disk, since it needs the parsed JSON.
// Decision models (#336) load unquantized on one device.
(SourceFormat::DecisionModel, tp, _) if tp > 1 => Err(PreflightError::Unsupported {
model_id: source_id.to_string(),
reason: format!(
"decision models load on a single device; tensor_parallel={tp} is not supported"
),
}),
(SourceFormat::DecisionModel, _, Some(q)) => Err(PreflightError::Unsupported {
model_id: source_id.to_string(),
reason: format!("decision models load unquantized; remove quant={q:?}"),
}),
(SourceFormat::DecisionModel, _, None) => Ok(PlacementPlan {
model_id: source_id.to_string(),
format: format.clone(),
tp_size,
picked_quant_file: None,
}),
(SourceFormat::DenseSafetensors { .. } | SourceFormat::Mixed { .. }, _, _) => {
Ok(PlacementPlan {
model_id: source_id.to_string(),
@@ -295,6 +324,9 @@ pub fn classify(filenames: &[&str]) -> SourceFormat {
if crate::harness::image::is_diffusers_layout(filenames) {
return SourceFormat::Diffusers;
}
if is_decision_layout(filenames) {
return SourceFormat::DecisionModel;
}
let mut gguf_quants: Vec<String> = filenames
.iter()
.filter(|f| f.to_lowercase().ends_with(".gguf"))
@@ -318,6 +350,16 @@ pub fn classify(filenames: &[&str]) -> SourceFormat {
}
}
/// A Laya decision checkpoint at the repo root: its decision config and
/// its encoder config, with no top-level `config.json` (a causal LM
/// that happened to ship a file with the same name still classifies as
/// a text model).
pub fn is_decision_layout(filenames: &[&str]) -> bool {
filenames.contains(&"rl_agent_config.json")
&& filenames.contains(&"encoder/config.json")
&& !filenames.contains(&"config.json")
}
/// Mirror of the quant-matching logic in `candle.rs::resolve_files` so
/// preflight picks the same file the downstream loader would. Empty
/// quant returns the first `.gguf` (any quant). Lowercased substring
@@ -651,6 +693,20 @@ mod tests {
tp_size,
picked_quant_file: None,
}),
(SourceFormat::DecisionModel, tp, _) if tp > 1 => Err(PreflightError::Unsupported {
model_id: source_id.to_string(),
reason: format!("decision models load on a single device; tensor_parallel={tp}"),
}),
(SourceFormat::DecisionModel, _, Some(q)) => Err(PreflightError::Unsupported {
model_id: source_id.to_string(),
reason: format!("decision models load unquantized; remove quant={q:?}"),
}),
(SourceFormat::DecisionModel, _, None) => Ok(PlacementPlan {
model_id: source_id.to_string(),
format: format.clone(),
tp_size,
picked_quant_file: None,
}),
(SourceFormat::DenseSafetensors { .. } | SourceFormat::Mixed { .. }, _, _) => {
Ok(PlacementPlan {
model_id: source_id.to_string(),
@@ -674,6 +730,66 @@ mod tests {
assert_eq!(classify(&files), SourceFormat::Diffusers);
}
/// The released `convaiinnovations/laya` listing: the English
/// checkpoint at the root, two more in subfolders, and no root
/// `config.json`.
const LAYA_FILES: [&str; 12] = [
"README.md",
"rl_agent_config.json",
"encoder/config.json",
"model.safetensors",
"tokenizer/tokenizer.json",
"tokenizer/tokenizer_config.json",
"multilingual/rl_agent_config.json",
"multilingual/encoder/config.json",
"multilingual/model.safetensors",
"typed-decisions/rl_agent_config.json",
"typed-decisions/encoder/config.json",
"typed-decisions/model.safetensors",
];
#[test]
fn classify_laya_layout() {
assert_eq!(classify(&LAYA_FILES), SourceFormat::DecisionModel);
// A causal LM that also ships an rl config is still a text model.
let lm = [
"config.json",
"rl_agent_config.json",
"encoder/config.json",
"model.safetensors",
];
assert!(matches!(
classify(&lm),
SourceFormat::DenseSafetensors { .. }
));
}
#[test]
fn feasibility_decision_model() {
let fmt = classify(&LAYA_FILES);
let plan = decide(
&spec("convaiinnovations/laya", None, None),
&fmt,
&LAYA_FILES,
)
.expect("single-device unquantized decision load should pass");
assert_eq!(plan.format, SourceFormat::DecisionModel);
let err = decide(
&spec("convaiinnovations/laya", Some(2), None),
&fmt,
&LAYA_FILES,
)
.expect_err("tp>1 must be refused");
assert!(matches!(err, PreflightError::Unsupported { .. }));
let err = decide(
&spec("convaiinnovations/laya", None, Some("q8_0")),
&fmt,
&LAYA_FILES,
)
.expect_err("quant must be refused");
assert!(matches!(err, PreflightError::Unsupported { .. }));
}
#[test]
fn feasibility_diffusers_single_gpu_ok() {
let files = ["model_index.json", "transformer/config.json"];
+347
View File
@@ -0,0 +1,347 @@
{
"header": {
"laya_version": "0.3.21",
"torch_version": "2.14.0+cu130",
"device": "cpu",
"dtype": "float32",
"checkpoints": {
"english": {
"repo": "convaiinnovations/laya",
"revision": "55cf4c4ebb4ebe31b2550e8bdf3bd21b99753851",
"cfg": {
"max_len": 512,
"head_max_len": 192,
"temperature": [
1.6369030475616455,
1.2514300346374512,
1.983399510383606
],
"temperature_by_options": {
"choice:3-5": 1.7601518630981445,
"choice:6-10": 1.0000158548355103,
"score:3-5": 1.2514300346374512,
"noul:2": 1.983399510383606,
"choice:11+": 0.10058280825614929,
"choice:2": 1.9063563346862793
}
},
"special_ids": {
"cls": 50281,
"sep": 50282,
"mask": 50284,
"pad": 50283
}
},
"multilingual": {
"repo": "convaiinnovations/laya/multilingual",
"revision": "55cf4c4ebb4ebe31b2550e8bdf3bd21b99753851",
"cfg": {
"max_len": 1024,
"head_max_len": 256,
"temperature": [
1.0,
1.0,
1.0
],
"temperature_by_options": {}
},
"special_ids": {
"cls": 2,
"sep": 1,
"mask": 4,
"pad": 0
}
},
"typed-decisions": {
"repo": "convaiinnovations/laya/typed-decisions",
"revision": "55cf4c4ebb4ebe31b2550e8bdf3bd21b99753851",
"cfg": {
"max_len": 1024,
"head_max_len": 256,
"temperature": [
1.0148024559020996,
1.0374259948730469,
1.0575125217437744
],
"temperature_by_options": {
"choice:3-5": 1.7601518630981445,
"choice:6-10": 1.0000158548355103,
"score:3-5": 1.2514300346374512,
"noul:2": 1.983399510383606,
"choice:11+": 0.10058280825614929,
"choice:2": 1.9063563346862793
}
},
"special_ids": {
"cls": 50281,
"sep": 50282,
"mask": 50284,
"pad": 50283
}
}
}
},
"cases": [
{
"state": "I was charged twice this month, I want my money back",
"analysis": {
"script": "latin",
"script_profile": {
"latin": 1.0
},
"language": "en",
"is_english": true,
"language_undecided": false,
"diacritic_rate": 0.0,
"non_latin_fraction": 0.0,
"mixed_segment": null
}
},
{
"state": "Me han cobrado dos veces este mes",
"analysis": {
"script": "latin",
"script_profile": {
"latin": 1.0
},
"language": "es",
"is_english": false,
"language_undecided": false,
"diacritic_rate": 0.0,
"non_latin_fraction": 0.0,
"mixed_segment": null
}
},
{
"state": "Die Rechnung ist falsch, bitte korrigieren",
"analysis": {
"script": "latin",
"script_profile": {
"latin": 1.0
},
"language": "de",
"is_english": false,
"language_undecided": false,
"diacritic_rate": 0.0,
"non_latin_fraction": 0.0,
"mixed_segment": null
}
},
{
"state": "Je voudrais annuler ma commande",
"analysis": {
"script": "latin",
"script_profile": {
"latin": 1.0
},
"language": "fr",
"is_english": false,
"language_undecided": false,
"diacritic_rate": 0.0,
"non_latin_fraction": 0.0,
"mixed_segment": null
}
},
{
"state": "मुझे पैसे वापस चाहिए",
"analysis": {
"script": "devanagari",
"script_profile": {
"devanagari": 1.0
},
"language": null,
"is_english": false,
"language_undecided": true,
"diacritic_rate": 0.0,
"non_latin_fraction": 1.0,
"mixed_segment": null
}
},
{
"state": "ខ្ញុំចង់បានប្រាក់វិញ",
"analysis": {
"script": "khmer",
"script_profile": {
"khmer": 1.0
},
"language": null,
"is_english": false,
"language_undecided": true,
"diacritic_rate": 0.0,
"non_latin_fraction": 1.0,
"mixed_segment": null
}
},
{
"state": "这个月我被扣了两次钱",
"analysis": {
"script": "han",
"script_profile": {
"han": 1.0
},
"language": null,
"is_english": false,
"language_undecided": true,
"diacritic_rate": 0.0,
"non_latin_fraction": 1.0,
"mixed_segment": null
}
},
{
"state": "注文をキャンセルしたいです",
"analysis": {
"script": "kana",
"script_profile": {
"han": 0.15384615384615385,
"kana": 0.8461538461538461
},
"language": null,
"is_english": false,
"language_undecided": true,
"diacritic_rate": 0.0,
"non_latin_fraction": 1.0,
"mixed_segment": null
}
},
{
"state": "تم خصم المبلغ مرتين",
"analysis": {
"script": "arabic",
"script_profile": {
"arabic": 1.0
},
"language": null,
"is_english": false,
"language_undecided": true,
"diacritic_rate": 0.0,
"non_latin_fraction": 1.0,
"mixed_segment": null
}
},
{
"state": "Мне дважды списали деньги",
"analysis": {
"script": "cyrillic",
"script_profile": {
"cyrillic": 1.0
},
"language": null,
"is_english": false,
"language_undecided": true,
"diacritic_rate": 0.0,
"non_latin_fraction": 1.0,
"mixed_segment": null
}
},
{
"state": "Order #5521 — 注文をキャンセルしたいです. Please help.",
"analysis": {
"script": "kana",
"script_profile": {
"latin": 0.5357142857142857,
"han": 0.07142857142857142,
"kana": 0.39285714285714285
},
"language": null,
"is_english": false,
"language_undecided": true,
"diacritic_rate": 0.0,
"non_latin_fraction": 0.4643,
"mixed_segment": null
}
},
{
"state": "ok",
"analysis": {
"script": "latin",
"script_profile": {
"latin": 1.0
},
"language": null,
"is_english": true,
"language_undecided": true,
"diacritic_rate": 0.0,
"non_latin_fraction": 0.0,
"mixed_segment": null
}
},
{
"state": "",
"analysis": {
"script": "unknown",
"script_profile": {},
"language": null,
"is_english": true,
"language_undecided": true,
"diacritic_rate": 0.0,
"non_latin_fraction": 0.0,
"mixed_segment": null
}
},
{
"state": "12345 67890",
"analysis": {
"script": "unknown",
"script_profile": {},
"language": null,
"is_english": true,
"language_undecided": true,
"diacritic_rate": 0.0,
"non_latin_fraction": 0.0,
"mixed_segment": null
}
},
{
"state": "café résumé naïve",
"analysis": {
"script": "latin",
"script_profile": {
"latin": 1.0
},
"language": null,
"is_english": false,
"language_undecided": true,
"diacritic_rate": 0.2353,
"non_latin_fraction": 0.0,
"mixed_segment": null
}
},
{
"state": {
"subject": "Invoice",
"body": "Bitte zahlen Sie bis Freitag"
},
"analysis": {
"script": "latin",
"script_profile": {
"latin": 1.0
},
"language": null,
"is_english": true,
"language_undecided": true,
"diacritic_rate": 0.0,
"non_latin_fraction": 0.0,
"mixed_segment": null
}
},
{
"state": [
"hello",
"bonjour",
"hola"
],
"analysis": {
"script": "latin",
"script_profile": {
"latin": 1.0
},
"language": null,
"is_english": true,
"language_undecided": true,
"diacritic_rate": 0.0,
"non_latin_fraction": 0.0,
"mixed_segment": null
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
{
"attention_bias": false,
"attention_dropout": 0.0,
"bos_token_id": 1,
"classifier_activation": "gelu",
"classifier_bias": false,
"classifier_dropout": 0.0,
"classifier_pooling": "cls",
"cls_token_id": 1,
"decoder_bias": true,
"deterministic_flash_attn": false,
"embedding_dropout": 0.0,
"eos_token_id": 2,
"global_attn_every_n_layers": 3,
"hidden_activation": "gelu",
"hidden_size": 32,
"initializer_cutoff_factor": 2.0,
"initializer_range": 0.3,
"intermediate_size": 48,
"layer_types": [
"full_attention",
"sliding_attention",
"sliding_attention",
"full_attention"
],
"local_attention": 8,
"max_position_embeddings": 128,
"mlp_bias": false,
"mlp_dropout": 0.0,
"model_type": "modernbert",
"norm_bias": false,
"norm_eps": 1e-05,
"num_attention_heads": 2,
"num_hidden_layers": 4,
"pad_token_id": 0,
"rope_parameters": {
"full_attention": {
"rope_theta": 1000.0,
"rope_type": "default"
},
"sliding_attention": {
"rope_theta": 300.0,
"rope_type": "default"
}
},
"sep_token_id": 2,
"sparse_pred_ignore_index": -100,
"sparse_prediction": false,
"tie_word_embeddings": true,
"transformers_version": "5.17.0",
"vocab_size": 97
}
@@ -0,0 +1,182 @@
{
"seed": 7,
"seqs": [
{
"input_ids": [
1,
93,
82,
87,
51,
46,
89,
6,
68,
94,
86,
43,
23,
70,
42,
60,
35,
3,
86,
7,
20,
18,
90,
84,
62,
30,
42,
65,
34,
37,
30,
67,
2,
34,
77,
96,
24,
89,
19,
33
],
"markers": [
3,
9,
17
],
"qtype": 0,
"logits": [
-0.4152399003505707,
-0.5505563020706177,
-0.600245475769043
],
"act": [
0.4727683663368225,
0.5272316336631775
]
},
{
"input_ids": [
4,
5,
43,
95,
77,
35,
61,
13,
42,
24,
21,
7,
11,
38,
80,
54,
4,
29,
5,
80,
54,
78,
46
],
"markers": [
4,
11
],
"qtype": 1,
"logits": [
-0.1313231736421585,
-0.3265465199947357
],
"act": [
0.40568628907203674,
0.5943136811256409
]
},
{
"input_ids": [
67,
40,
83,
41,
77,
96,
58,
39,
76
],
"markers": [
2
],
"qtype": 2,
"logits": [
0.534652590751648
],
"act": [
0.4226877987384796,
0.5773122310638428
]
},
{
"input_ids": [
96,
32,
60,
44,
35,
47,
45,
51,
60,
51,
73,
50,
25,
79,
74,
24,
83,
7,
9,
91,
21,
87,
29,
25,
74,
18,
31,
46,
46,
30,
52
],
"markers": [
5,
7,
12,
20,
26
],
"qtype": 0,
"logits": [
-0.4001395106315613,
-0.5266168117523193,
-0.44161877036094666,
-0.43973028659820557,
-0.5682165622711182
],
"act": [
0.46282079815864563,
0.537179172039032
]
}
]
}
Binary file not shown.
@@ -0,0 +1,16 @@
{
"encoder": "tiny-modernbert",
"head_layers": 2,
"max_len": 64,
"head_max_len": 24,
"act_costs": {
"escalate": 0.5
},
"amp_dtype": "bf16",
"temperature": [
1.0,
1.0,
1.0
],
"temperature_by_options": {}
}
@@ -0,0 +1 @@
{"version": "1.0", "truncation": null, "padding": null, "added_tokens": [], "normalizer": null, "pre_tokenizer": {"type": "Whitespace"}, "post_processor": null, "decoder": null, "model": {"type": "WordLevel", "vocab": {"[PAD]": 0, "[UNK]": 1, "[CLS]": 2, "[SEP]": 3, "[MASK]": 4, "a": 5, "b": 6}, "unk_token": "[UNK]"}}
@@ -0,0 +1,7 @@
{
"cls_token": "[CLS]",
"sep_token": "[SEP]",
"mask_token": "[MASK]",
"pad_token": "[PAD]",
"unk_token": "[UNK]"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
{
"clean_up_tokenization_spaces": true,
"cls_token": "[CLS]",
"mask_token": "[MASK]",
"model_input_names": [
"input_ids",
"attention_mask"
],
"model_max_length": 8192,
"pad_token": "[PAD]",
"sep_token": "[SEP]",
"tokenizer_class": "PreTrainedTokenizerFast",
"unk_token": "[UNK]"
}
+122
View File
@@ -67,6 +67,22 @@ impl TpLeaderModel {
}
}
/// Logits at every position (#96). Only the `qwen3_5` arch has a
/// multi-position path; the older dense `qwen3` arch says so rather
/// than silently returning the last row.
pub fn forward_multi(
&mut self,
input: &candle_core::Tensor,
offset: usize,
) -> candle_core::Result<candle_core::Tensor> {
match self {
TpLeaderModel::Qwen3_5(m) => m.forward_multi(input, offset),
TpLeaderModel::Qwen3(_) => Err(candle_core::Error::Msg(
"forward_multi: the tp qwen3 arch has no multi-position logits path".into(),
)),
}
}
/// Chunked image prefill on rank 0. Only the vision-capable
/// `qwen3_5` arch supports it; the dense `qwen3` arch has no tower.
pub fn prefill_with_images_chunked(
@@ -996,6 +1012,112 @@ impl WorkerPool {
}
}
/// Multi-position forward across every rank (#96).
///
/// Same fan-out / leader-forward / always-drain shape as
/// [`Self::generate_step`], and for the same reason: the
/// row-parallel `AllReduce`s only complete when every rank has
/// issued them, and a leader error must still drain the workers or
/// the stale replies poison the next request.
///
/// Returns one `[vocab]` row per position from the leader's rank-0
/// shard, which is the whole answer because `lm_head` is
/// replicated.
#[cfg(feature = "cuda")]
pub async fn generate_step_multi(
&mut self,
model_id: &str,
leader_handle: super::device_worker::TpHandle,
tokens: Vec<u32>,
offset: usize,
) -> Result<Vec<Vec<f32>>> {
let step_start = std::time::Instant::now();
let tokens_len = tokens.len();
tracing::debug!(
model = %model_id,
tokens = tokens_len,
offset,
"WorkerPool::generate_step_multi: fan-out"
);
for w in &mut self.workers {
w.send_only(&WorkerRequest::GenerateStepMulti {
model_id: model_id.to_string(),
tokens: tokens.clone(),
offset,
})
.await?;
}
let leader_start = std::time::Instant::now();
let timeout = tp_step_timeout();
let leader_fut = self
.leader_worker
.tp_forward_logits_multi(leader_handle, tokens, offset);
let leader_result = match tokio::time::timeout(timeout, leader_fut).await {
Ok(r) => r,
Err(_elapsed) => {
self.watchdog_abort_leader_comm(model_id, timeout.as_secs());
anyhow::bail!(
"tp watchdog: leader multi-position forward exceeded {}s deadline; \
aborted wedged NCCL comm — model will auto-recover",
timeout.as_secs()
);
}
};
let leader_ok = leader_result.is_ok();
if !leader_ok {
let detail = leader_result
.as_ref()
.err()
.map(|e| format!("{e:#}"))
.unwrap_or_default();
tracing::warn!(
model = %model_id,
tokens = tokens_len,
offset,
leader_ms = leader_start.elapsed().as_millis(),
error = %detail,
"WorkerPool::generate_step_multi: leader forward failed"
);
}
let worker_errors = drain_workers(&mut self.workers, |r| match r {
WorkerResponse::GenerateStepOk => Ok(()),
WorkerResponse::Error { kind, message } => Err(format!("[{kind}]: {message}")),
other => Err(format!("expected GenerateStepOk, got {other:?}")),
})
.await;
tracing::debug!(
model = %model_id,
errors = worker_errors.len(),
total_ms = step_start.elapsed().as_millis(),
"WorkerPool::generate_step_multi: workers drained"
);
match leader_result {
Ok(values) => {
if worker_errors.is_empty() {
Ok(values)
} else {
anyhow::bail!(
"GenerateStepMulti: leader succeeded but workers failed: {}",
worker_errors.join("; ")
)
}
}
Err(e) => {
if worker_errors.is_empty() {
Err(anyhow::Error::new(e).context("GenerateStepMulti: leader forward failed"))
} else {
Err(anyhow::Error::new(e).context(format!(
"GenerateStepMulti: leader forward failed and workers also failed: {}",
worker_errors.join("; ")
)))
}
}
}
}
/// One lockstep batched decode step across every rank (#98).
/// Same fan-out / leader-forward / always-drain shape as
/// [`Self::generate_step`]; every rank derives positions + mask
+12
View File
@@ -78,6 +78,18 @@ pub enum WorkerRequest {
/// same op. The leader does *not* receive logits back over RPC; it
/// runs its own rank-0 forward in parallel and uses its own logits
/// for sampling.
/// Multi-position forward (#96): like `GenerateStep`, but every
/// rank keeps the logits at *all* `tokens.len()` positions rather
/// than the last. The collectives are identical, so the ranks stay
/// in lockstep; only the leader's logits are read. Worker replies
/// with `GenerateStepOk`.
GenerateStepMulti {
model_id: String,
/// Identical on every rank, as for `GenerateStep`.
tokens: Vec<u32>,
offset: usize,
},
GenerateStep {
model_id: String,
/// Input token ids for this step. For prefill, the whole prompt;
+234
View File
@@ -1881,6 +1881,27 @@ impl TpQwen3_5Model {
}
}
/// Additive causal mask for the draft head, `(1, 1, l, l + offset)`.
/// The head keeps its own KV, so its mask is built from its own
/// position count rather than the target's.
fn mtp_causal_mask(
l: usize,
offset: usize,
dtype: DType,
dev: &Device,
) -> candle_core::Result<Tensor> {
let total = l + offset;
let mut data = vec![0f32; l * total];
for i in 0..l {
for j in 0..total {
if j > i + offset {
data[i * total + j] = f32::NEG_INFINITY;
}
}
}
Tensor::from_vec(data, (1, 1, l, total), dev)?.to_dtype(dtype)
}
pub struct TpQwen3_5ForCausalLM {
base: TpQwen3_5Model,
lm_head: super::tp_linear::MaybeQuantLinear,
@@ -1894,6 +1915,23 @@ pub struct TpQwen3_5ForCausalLM {
/// `<|image_pad|>` sentinel id (mirrors `Config::image_token_id`);
/// the splice target for `forward_with_vision`.
image_token_id: Option<u32>,
/// The MTP draft head (#96), on the **leader only**.
///
/// Unsharded, unlike every other weight here, and that is correct
/// rather than lazy: after each row-parallel `AllReduce` every rank
/// holds the *same* hidden state, and `lm_head` is replicated. So a
/// head fed the leader's hidden produces exactly what a sharded one
/// would, with no collectives — which also means drafting cannot
/// wedge the ranks, since the other ranks are not in it.
///
/// `None` on the subprocess ranks and on any load that did not ask
/// for it. Loaded by [`Self::load_mtp_head`], never by `load`.
mtp: Option<crate::harness::arch::qwen3_5::mtp::MtpHead>,
/// The last forward's hidden state, `(1, L, hidden)` — what the
/// draft head consumes. Kept because the head runs *after* the
/// target's step, outside the collective, so it cannot recompute
/// it. One `Arc` clone per forward; no copy.
last_hidden: Option<Tensor>,
}
/// Load the replicated vision tower from the unsharded `model.visual.*`
@@ -1944,6 +1982,8 @@ impl TpQwen3_5ForCausalLM {
lm_head,
vision,
image_token_id,
mtp: None,
last_hidden: None,
};
log_construction_complete(cfg, rank, world_size, quant, model.device());
Ok(model)
@@ -1969,6 +2009,8 @@ impl TpQwen3_5ForCausalLM {
lm_head,
vision,
image_token_id,
mtp: None,
last_hidden: None,
};
log_construction_complete(cfg, rank, world_size, quant, model.device());
Ok(model)
@@ -2003,9 +2045,201 @@ impl TpQwen3_5ForCausalLM {
pub fn forward(&mut self, input: &Tensor, offset: usize) -> candle_core::Result<Tensor> {
let (_, l) = input.dims2()?;
let hidden = self.base.forward(input, offset)?;
self.last_hidden = Some(hidden.clone());
hidden.i((.., l - 1.., ..))?.apply(&self.lm_head)
}
/// Logits at **every** position: `(B, L, vocab)`.
///
/// The TP mirror of `Qwen3_5ForCausalLM::forward_multi` (#96). A
/// speculative verify pass forwards the drafted block in one pass
/// and needs the target's own token at each drafted position;
/// [`Self::forward`] slices the last one off before the LM head.
///
/// Every rank runs this, because the row-parallel `o_proj` and the
/// MLP's `down_proj` issue `AllReduce` collectives that only
/// complete when all ranks arrive. The leader's copy is the one
/// whose logits are read — `lm_head` is replicated, so rank 0's
/// answer is the whole answer.
/// Load the MTP draft head onto this rank, unsharded (#96).
///
/// Only the leader should call this. See the `mtp` field for why an
/// unsharded head is numerically right; the practical consequence
/// is that drafting issues no collectives and therefore cannot
/// leave the other ranks waiting.
pub fn load_mtp_head(&mut self, cfg: &TextConfig, vb: &ShardedVarBuilder) -> Result<()> {
let head = crate::harness::arch::qwen3_5::mtp::MtpHead::load(
cfg,
Arc::clone(&self.base.rotary),
vb,
)
.context("load mtp head on the tp leader")?;
self.mtp = Some(head);
Ok(())
}
/// Whether this rank carries a draft head.
pub fn has_mtp(&self) -> bool {
self.mtp.is_some()
}
/// Walk the draft head over a prompt chunk, using the hidden state
/// the target's forward just produced.
///
/// The head trails the target by one token: at position `i` it
/// consumes the embedding of token `i+1` with the target's hidden
/// state at `i`. `shifted` is therefore the chunk's tokens moved
/// one left, and must be one shorter than the retained hidden block
/// unless the caller has the next chunk's first token.
pub fn mtp_prefill_chunk(&mut self, shifted: &[u32], start_pos: usize) -> Result<()> {
let hidden = self
.last_hidden
.as_ref()
.context("mtp_prefill_chunk: no hidden state retained; run the target forward first")?
.clone();
let l = shifted.len();
anyhow::ensure!(l > 0, "mtp_prefill_chunk: empty chunk");
let (_, have, _) = hidden.dims3()?;
anyhow::ensure!(
have >= l,
"mtp_prefill_chunk: {l} tokens against {have} retained hidden positions"
);
let device = self.base.device.clone();
let dtype = self.base.dtype;
let rotary = Arc::clone(&self.base.rotary);
let tokens = Tensor::new(shifted, &device)?.unsqueeze(0)?;
let embeds = self.base.embed_tokens.forward(&tokens)?;
let hid = hidden.i((.., ..l, ..))?;
let mask = mtp_causal_mask(l, start_pos, dtype, &device)?;
let positions: Vec<usize> = (start_pos..start_pos + l).collect();
let (cos, sin) = rotary.cos_sin_at(&positions)?;
let head = self
.mtp
.as_mut()
.context("mtp_prefill_chunk: no draft head on this rank")?;
head.forward(&embeds, &hid, Some(&mask), &cos, &sin)?;
Ok(())
}
/// Draft `k` tokens, then put the head's cache back.
///
/// The head is walked over tokens that may be rejected, so the
/// snapshot/restore pair around the chain is what keeps this an
/// observation rather than a commitment. Returns the drafted token
/// ids, argmax-sampled through the target's own (replicated)
/// `lm_head`.
pub fn mtp_draft(&mut self, first_token: u32, start_pos: usize, k: usize) -> Result<Vec<u32>> {
anyhow::ensure!(k > 0, "mtp_draft: k must be positive");
let mut hidden = self
.last_hidden
.as_ref()
.context("mtp_draft: no hidden state retained; run the target forward first")?
.clone();
let (_, have, _) = hidden.dims3()?;
if have > 1 {
hidden = hidden.i((.., have - 1.., ..))?;
}
let device = self.base.device.clone();
let rotary = Arc::clone(&self.base.rotary);
let snap = {
let head = self
.mtp
.as_ref()
.context("mtp_draft: no draft head on this rank")?;
head.snapshot_kv()?
};
let mut drafted = Vec::with_capacity(k);
let mut token = first_token;
let mut cur = hidden;
let mut err: Option<anyhow::Error> = None;
for step in 0..k {
let ids = match Tensor::new(&[token], &device).and_then(|t| t.unsqueeze(0)) {
Ok(t) => t,
Err(e) => {
err = Some(e.into());
break;
}
};
let out = (|| -> Result<Tensor> {
let embeds = self.base.embed_tokens.forward(&ids)?;
let (cos, sin) = rotary.cos_sin_at(&[start_pos + step])?;
let head = self
.mtp
.as_mut()
.context("mtp_draft: no draft head on this rank")?;
head.forward(&embeds, &cur, None, &cos, &sin)
})();
let out = match out {
Ok(t) => t,
Err(e) => {
err = Some(e);
break;
}
};
let next = match self.lm_head.forward(&out).and_then(|logits| {
logits
.i((0, 0, ..))?
.to_dtype(DType::F32)?
.argmax(candle_core::D::Minus1)?
.to_scalar::<u32>()
}) {
Ok(t) => t,
Err(e) => {
err = Some(e.into());
break;
}
};
drafted.push(next);
token = next;
cur = out;
}
// Restore even when a step failed: a half-walked head is worse
// than no draft, because every later round inherits it.
if let Some(head) = self.mtp.as_mut() {
head.restore_kv(&snap)?;
}
match err {
Some(e) => Err(e),
None => Ok(drafted),
}
}
/// Walk the head over one token the target actually committed, so
/// its cache stays level with the target's.
pub fn mtp_advance(&mut self, token: u32, pos: usize) -> Result<()> {
let hidden = self
.last_hidden
.as_ref()
.context("mtp_advance: no hidden state retained")?
.clone();
let (_, have, _) = hidden.dims3()?;
let hidden = if have > 1 {
hidden.i((.., have - 1.., ..))?
} else {
hidden
};
let device = self.base.device.clone();
let rotary = Arc::clone(&self.base.rotary);
let ids = Tensor::new(&[token], &device)?.unsqueeze(0)?;
let embeds = self.base.embed_tokens.forward(&ids)?;
let (cos, sin) = rotary.cos_sin_at(&[pos])?;
let head = self
.mtp
.as_mut()
.context("mtp_advance: no draft head on this rank")?;
head.forward(&embeds, &hidden, None, &cos, &sin)?;
Ok(())
}
pub fn forward_multi(&mut self, input: &Tensor, offset: usize) -> candle_core::Result<Tensor> {
let hidden = self.base.forward(input, offset)?;
self.last_hidden = Some(hidden.clone());
hidden.apply(&self.lm_head)
}
/// Lockstep batched decode step (#98): `(B, 1)` input, per-row
/// positions, padding mask from
/// [`TpQwen3_5Model::batch_decode_mask`]. Returns `(B, 1, vocab)`.
+94
View File
@@ -52,6 +52,21 @@ impl WorkerModel {
}
}
/// Logits at every position (#96). This rank discards them; what
/// matters is the identical collective sequence.
fn forward_multi(
&mut self,
input: &candle_core::Tensor,
offset: usize,
) -> candle_core::Result<candle_core::Tensor> {
match self {
WorkerModel::Qwen3_5(m) => m.forward_multi(input, offset),
WorkerModel::Qwen3(_) => Err(candle_core::Error::Msg(
"forward_multi: the tp qwen3 arch has no multi-position logits path".into(),
)),
}
}
/// Chunked image prefill on this rank. Only the vision-capable
/// `qwen3_5` arch has a replicated tower; the dense `qwen3` arch
/// errors. The returned logits are discarded by the caller (the
@@ -269,6 +284,11 @@ impl WorkerState {
tokens,
offset,
} => self.handle_generate_step(&model_id, tokens, offset),
WorkerRequest::GenerateStepMulti {
model_id,
tokens,
offset,
} => self.handle_generate_step_multi(&model_id, tokens, offset),
WorkerRequest::GenerateStepWithImages {
model_id,
tokens,
@@ -594,6 +614,80 @@ impl WorkerState {
}
}
/// Multi-position forward (#96). Identical collectives to
/// `handle_generate_step` — the only difference is that the model
/// keeps every position's logits instead of the last. This rank
/// discards them either way; what matters is that it issues the
/// same `AllReduce` sequence, or the leader's forward never
/// completes.
#[cfg(feature = "cuda")]
fn handle_generate_step_multi(
&mut self,
model_id: &str,
tokens: Vec<u32>,
offset: usize,
) -> WorkerResponse {
use candle_core::Tensor;
let Some(model) = self.models.get_mut(model_id) else {
return WorkerResponse::Error {
kind: "model_not_loaded".into(),
message: format!("model '{model_id}' not loaded on rank {}", self.config.rank),
};
};
let device = model.device().clone();
let input = match Tensor::new(tokens.as_slice(), &device).and_then(|t| t.unsqueeze(0)) {
Ok(t) => t,
Err(e) => {
return WorkerResponse::Error {
kind: "forward_failed".into(),
message: format!("build input tensor: {e}"),
};
}
};
let start = std::time::Instant::now();
tracing::debug!(
rank = self.config.rank,
model = %model_id,
tokens = tokens.len(),
offset,
"worker GenerateStepMulti: forward starting"
);
if let Err(e) = model.forward_multi(&input, offset) {
tracing::warn!(
rank = self.config.rank,
model = %model_id,
elapsed_ms = start.elapsed().as_millis(),
error = %e,
"worker GenerateStepMulti: forward failed"
);
return WorkerResponse::Error {
kind: "forward_failed".into(),
message: format!("TP multi-position forward: {e}"),
};
}
tracing::debug!(
rank = self.config.rank,
model = %model_id,
elapsed_ms = start.elapsed().as_millis(),
"worker GenerateStepMulti: forward done"
);
WorkerResponse::GenerateStepOk
}
#[cfg(not(feature = "cuda"))]
fn handle_generate_step_multi(
&mut self,
_model_id: &str,
_tokens: Vec<u32>,
_offset: usize,
) -> WorkerResponse {
WorkerResponse::Error {
kind: "cuda_feature_not_enabled".into(),
message: "GenerateStepMulti requires --features cuda".into(),
}
}
/// One lockstep batched decode step on this rank (#98). Mirrors
/// the leader's `forward_logits_batch`: derives per-row positions
/// and the padding mask locally from the broadcast geometry, runs
+1
View File
@@ -5,6 +5,7 @@ pub mod cuda;
pub mod discovery;
pub mod harness;
pub mod health;
pub mod metrics;
pub mod startup;
pub mod version;
pub mod wire;
+6
View File
@@ -220,6 +220,12 @@ async fn daemon(args: Args) -> Result<()> {
// host look down to anything probing `/health` during pre-warm.
// The pre-warm task runs in the background instead — `/health`
// surfaces its progress via the activation field.
// Before the listener binds, so the very first scrape after a
// restart finds a recorder rather than a 503.
if let Err(e) = neuron::metrics::install() {
tracing::warn!(error = %e, "metrics recorder not installed; /metrics will 503");
}
let app = api::neuron_routes().with_state(Arc::clone(&state));
let addr: std::net::SocketAddr = format!("0.0.0.0:{port}").parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
+173
View File
@@ -0,0 +1,173 @@
//! Prometheus metrics for neuron.
//!
//! neuron had no `/metrics` surface at all: every signal it produced
//! reached Prometheus only by cortex polling `/health` every ~10 s and
//! republishing it as gauges. That is enough for routing decisions and
//! useless for asking where a request's time went — an EMA sampled at
//! 10 s cannot see inside a one-second forward, and the finest split
//! that existed anywhere was prefill versus decode.
//!
//! Served from the main API port rather than a second listener: neuron
//! already runs an axum server, and a separate port is one more thing
//! to open in the firewall for no benefit.
use anyhow::Result;
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle};
use std::sync::OnceLock;
static HANDLE: OnceLock<PrometheusHandle> = OnceLock::new();
/// Install the process-wide recorder. Idempotent in effect: a second
/// call leaves the first handle in place.
pub fn install() -> Result<()> {
let handle = with_buckets(PrometheusBuilder::new())?
.install_recorder()
.map_err(|e| anyhow::anyhow!("failed to install Prometheus recorder: {e}"))?;
describe();
let _ = HANDLE.set(handle);
Ok(())
}
/// Render the current metrics, or `None` if the recorder was never
/// installed. A `None` here must surface as an error rather than an
/// empty body: an empty scrape is indistinguishable from a healthy
/// process with nothing to report.
pub fn render() -> Option<String> {
HANDLE.get().map(|h| h.render())
}
/// Explicit buckets, or `metrics-exporter-prometheus` renders every
/// histogram as a **summary** — `{quantile=...}` series, no `_bucket`,
/// no aggregation across hosts, and every quantile decaying to `0`
/// when idle so "no traffic" looks like "instant". The same trap
/// cortex's exporter documents; the fix has to be repeated per metric.
fn with_buckets(builder: PrometheusBuilder) -> Result<PrometheusBuilder> {
// Per-request phase sums: microseconds for an emit on a fast model,
// minutes for the forward on a long turn.
let phase_seconds = &[
0.001, 0.005, 0.025, 0.1, 0.25, 1.0, 2.5, 5.0, 15.0, 30.0, 60.0, 300.0,
];
let seconds_short = &[
0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 30.0, 60.0, 120.0,
];
let seconds_long = &[
0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1200.0,
];
builder
.set_buckets_for_metric(
Matcher::Full("neuron_decode_phase_seconds".into()),
phase_seconds,
)?
.set_buckets_for_metric(
Matcher::Full("neuron_prefill_seconds".into()),
seconds_short,
)?
.set_buckets_for_metric(Matcher::Full("neuron_decode_seconds".into()), seconds_long)
.map_err(|e| anyhow::anyhow!("failed to configure histogram buckets: {e}"))
}
fn describe() {
metrics::describe_histogram!(
"neuron_decode_phase_seconds",
"Decode-loop time by phase, summed per request. The `_sum` \
series is the point: ratio one phase against the total to see \
where decode goes, without needing per-step resolution."
);
metrics::describe_histogram!("neuron_prefill_seconds", "Prefill wall-clock per request");
metrics::describe_histogram!("neuron_decode_seconds", "Decode wall-clock per request");
metrics::describe_counter!(
"neuron_completion_tokens_total",
"Tokens generated, the denominator for per-token phase cost"
);
}
/// Record one finished inference.
///
/// `phases` is optional because not every serving path measures them,
/// and a path that does not must emit **nothing** rather than zeros —
/// a zero sample pulls the histogram's mean down and reads as "this
/// phase is free here", which is the opposite of "nobody looked".
///
/// The residual is emitted as its own phase. Whatever the brackets
/// failed to cover is then visible as a labelled series instead of
/// being silently divided among the phases that were measured.
pub fn record_finish(
model: &str,
prefill_ms: u32,
decode_ms: u32,
completion_tokens: u32,
phases: Option<(u32, u32, u32)>,
) {
let m = model.to_string();
metrics::histogram!("neuron_prefill_seconds", "model" => m.clone())
.record(prefill_ms as f64 / 1e3);
metrics::histogram!("neuron_decode_seconds", "model" => m.clone())
.record(decode_ms as f64 / 1e3);
metrics::counter!("neuron_completion_tokens_total", "model" => m.clone())
.increment(completion_tokens as u64);
let Some((forward_ms, sample_ms, emit_ms)) = phases else {
return;
};
for (phase, ms) in [
("forward", forward_ms),
("sample", sample_ms),
("emit", emit_ms),
] {
metrics::histogram!("neuron_decode_phase_seconds", "model" => m.clone(), "phase" => phase)
.record(ms as f64 / 1e3);
}
// Saturating: the phases are measured inside the decode window, so
// they cannot legitimately exceed it, but clock granularity can
// round them a millisecond over and an underflow here would report
// an enormous residual.
let residual =
decode_ms.saturating_sub(forward_ms.saturating_add(sample_ms).saturating_add(emit_ms));
metrics::histogram!("neuron_decode_phase_seconds", "model" => m, "phase" => "residual")
.record(residual as f64 / 1e3);
}
#[cfg(test)]
mod tests {
use super::*;
/// An unmeasured path must contribute no phase samples at all.
/// Zeros would drag the histogram's mean toward zero and read as
/// "this phase costs nothing on this model".
#[test]
fn phases_absent_means_no_phase_series() {
let handle = with_buckets(PrometheusBuilder::new())
.expect("buckets")
.install_recorder()
.expect("recorder");
describe();
record_finish("measured", 100, 1000, 64, Some((900, 50, 10)));
record_finish("unmeasured", 100, 1000, 64, None);
let out = handle.render();
assert!(
out.contains("neuron_decode_phase_seconds") && out.contains(r#"model="measured""#),
"measured model must emit phases:\n{out}"
);
for line in out
.lines()
.filter(|l| l.contains("neuron_decode_phase_seconds"))
{
assert!(
!line.contains(r#"model="unmeasured""#),
"unmeasured model must emit no phase series, got: {line}"
);
}
// Buckets, not a summary: `histogram_quantile()` needs `_bucket`.
assert!(
out.contains("neuron_decode_phase_seconds_bucket"),
"must export as a histogram, not a summary:\n{out}"
);
// 1000 - (900 + 50 + 10) = 40 ms of residual, as its own series.
assert!(
out.contains(r#"phase="residual""#),
"residual must be its own series:\n{out}"
);
}
}
+1
View File
@@ -158,6 +158,7 @@ fn preflight_kind(err: &PreflightError) -> &'static str {
PreflightError::EmptyRepo { .. } => "empty_repo",
PreflightError::TpRequiresSafetensors { .. } => "tp_requires_safetensors",
PreflightError::QuantNotFound { .. } => "quant_not_found",
PreflightError::Unsupported { .. } => "unsupported_load_option",
}
}
+35
View File
@@ -121,6 +121,41 @@ pub struct FinishTiming {
/// the elapsed `prefill_ms` drops while this stays the full prompt
/// length, so a high implied rate is itself the cache-hit signal.
pub prefill_tokens: u32,
/// How the decode loop's wall-clock divides, when the serving path
/// measured it. `None` means "this path does not report phases",
/// which is deliberately distinct from "measured, and they were
/// zero" — a phase split that cannot express its own absence
/// invites reading an unmeasured path as an idle one.
pub phases: Option<PhaseTiming>,
}
/// Where a decode loop's wall-clock actually went, summed over every
/// step of the request.
///
/// These are sums across steps, not a breakdown of one step, and each
/// boundary is a point the loop already synchronises at: the forward
/// hands back CPU-side logits, so no device synchronisation is added
/// and the numbers are elapsed work rather than kernel-launch time.
///
/// The three should roughly account for `decode_ms`; a large residual
/// is itself a finding (it is time inside the loop that none of the
/// brackets cover).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PhaseTiming {
/// Submitting the step to the device worker and getting logits
/// back: the channel hop, the model forward, and the device-to-host
/// copy of the logits row. For a large vocabulary that copy is not
/// negligible -- 248,320 f32 is ~993 KB per step.
pub forward_ms: u32,
/// Wrapping the logits in a CPU tensor and sampling: repeat/presence
/// penalties plus the `LogitsProcessor`. Scales with vocabulary,
/// not with model size, and top-p sorts the distribution.
pub sample_ms: u32,
/// Detokenising the sampled token and handing the delta to the
/// consumer. **Includes backpressure**: the send awaits a bounded
/// channel, so a slow reader is charged here. This is emit cost,
/// not detokenisation cost, and the two are not separated.
pub emit_ms: u32,
}
/// Why a stream stopped. Stays small on purpose — anything that
+1 -1
View File
@@ -22,6 +22,6 @@ pub mod openai_chat;
pub mod openai_responses;
pub use event::{
FinishReason, FinishTiming, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
FinishReason, FinishTiming, InferenceEvent, PhaseTiming, ReasoningTokenPair, ToolCallTokenPair,
detect_reasoning_token_pair, detect_tool_call_token_pair,
};
+68
View File
@@ -444,6 +444,9 @@ fn usage_chunk(
prefill_ms: t.prefill_ms as u64,
decode_ms: t.decode_ms as u64,
prefill_tokens: t.prefill_tokens as u64,
forward_ms: t.phases.map(|p| p.forward_ms as u64),
sample_ms: t.phases.map(|p| p.sample_ms as u64),
emit_ms: t.phases.map(|p| p.emit_ms as u64),
}),
}),
extra: serde_json::Value::Object(Default::default()),
@@ -596,6 +599,9 @@ pub async fn collect_chat_completion(
prefill_ms: t.prefill_ms as u64,
decode_ms: t.decode_ms as u64,
prefill_tokens: t.prefill_tokens as u64,
forward_ms: t.phases.map(|p| p.forward_ms as u64),
sample_ms: t.phases.map(|p| p.sample_ms as u64),
emit_ms: t.phases.map(|p| p.emit_ms as u64),
}),
}),
extra: serde_json::Value::Object(Default::default()),
@@ -685,6 +691,7 @@ mod tests {
);
}
use super::*;
use crate::wire::event::PhaseTiming;
// ── Non-streaming collector (#285) ────────────────────────────
@@ -799,6 +806,7 @@ mod tests {
prefill_ms: 12,
decode_ms: 34,
prefill_tokens: 100,
phases: None,
}),
})
.await
@@ -813,6 +821,65 @@ mod tests {
assert_eq!((t.prefill_ms, t.decode_ms, t.prefill_tokens), (12, 34, 100));
}
/// The phase split must survive to the wire, and an unmeasured path
/// must serialise as **absent** rather than as zeros.
///
/// That distinction is the reason the field is an `Option` at all:
/// a consumer reading `forward_ms: 0` off a path that never
/// measured would conclude the forward is free. The serialised
/// form is asserted, not just the struct, because
/// `skip_serializing_if` is what actually enforces it and a plain
/// field comparison cannot see it.
#[tokio::test]
async fn phase_timing_reaches_the_wire_and_absence_is_not_zero() {
async fn timing_json(phases: Option<PhaseTiming>) -> serde_json::Value {
let (tx, rx) = mpsc::channel(8);
tx.send(InferenceEvent::Finish {
reason: FinishReason::Stop,
prompt_tokens: 10,
completion_tokens: 5,
reasoning_tokens: 0,
cached_tokens: 0,
timing: Some(FinishTiming {
prefill_ms: 12,
decode_ms: 340,
prefill_tokens: 10,
phases,
}),
})
.await
.expect("send");
drop(tx);
let r = collect_chat_completion(rx, "id".into(), 1, "m".into())
.await
.expect("collected");
serde_json::to_value(r.usage.expect("usage").helexa_timing.expect("timing"))
.expect("serialise")
}
let measured = timing_json(Some(PhaseTiming {
forward_ms: 300,
sample_ms: 25,
emit_ms: 4,
}))
.await;
assert_eq!(measured["forward_ms"], 300);
assert_eq!(measured["sample_ms"], 25);
assert_eq!(measured["emit_ms"], 4);
let unmeasured = timing_json(None).await;
for f in ["forward_ms", "sample_ms", "emit_ms"] {
assert!(
unmeasured.get(f).is_none(),
"unmeasured {f} must be absent, not {:?} -- a zero here reads as \
'this phase is free' rather than 'nobody measured it'",
unmeasured.get(f)
);
}
// The prefill/decode split is unconditional and must be unaffected.
assert_eq!(unmeasured["decode_ms"], 340);
}
/// A dropped engine slot ends the channel with no Finish. Returning
/// a 200 with partial text would hide a fatal worker fault.
#[tokio::test]
@@ -898,6 +965,7 @@ mod tests {
prefill_ms: 200,
decode_ms: 1500,
prefill_tokens: 128,
phases: None,
}),
})
.await
+46 -63
View File
@@ -219,9 +219,6 @@ pub fn request_to_chat(req: ResponsesRequest) -> Result<ChatCompletionRequest, T
}
}
// Only the newest turn's thinking is context; the rest is noise.
prune_stale_reasoning(&mut messages);
// Carry the caller's extension fields across the hop (#277).
//
// This used to start from an empty map and insert exactly one key,
@@ -455,54 +452,6 @@ fn merge_tool_call_into(prev: &mut ChatMessage, msg: &mut ChatMessage) -> bool {
true
}
/// Keep replayed reasoning on the most recent assistant turn only.
///
/// #277 restored the round-trip, and then over-corrected: a client that
/// replays every turn verbatim — which is what an agentic harness does —
/// had the model's entire thinking history rendered back into the
/// prompt, because Qwen3.8's template defaults `preserve_thinking` to
/// true. Measured on a live session: **62% of the assistant content
/// being replayed was prior think blocks** (95,311 characters of
/// thinking against 467 characters of actual output), and the model's
/// per-turn reasoning collapsed to tens of tokens while it made
/// increasingly shallow passes at the task.
///
/// Reasoning models are trained on histories that carry *final content*,
/// not the deliberation behind it — Qwen's own multi-turn convention
/// strips prior think blocks. But the newest block is different: when a
/// turn was cut off mid-thought and the caller says "continue", that
/// thinking is the work being resumed, which is what #277 exists to
/// preserve.
///
/// So: newest assistant turn keeps its reasoning, everything older
/// loses it. Note the template's own `preserve_thinking: false` is
/// exactly backwards for this — it keeps thinking on turns *after* the
/// last user message (the agentic case, where it is noise) and drops it
/// on turns *before* (the continuation case, where it is the point).
fn prune_stale_reasoning(messages: &mut [ChatMessage]) {
let newest = messages
.iter()
.rposition(|m| m.role == "assistant" && m.extra.get("reasoning_content").is_some());
let Some(newest) = newest else { return };
let mut dropped = 0usize;
for (i, m) in messages.iter_mut().enumerate() {
if i == newest || m.role != "assistant" {
continue;
}
if let Some(obj) = m.extra.as_object_mut()
&& obj.remove("reasoning_content").is_some()
{
dropped += 1;
}
}
if dropped > 0 {
tracing::debug!(
dropped,
"responses: replaying reasoning for the newest assistant turn only"
);
}
}
/// The text of a replayed reasoning item, under either spelling.
///
/// `summary` is what neuron emits (`summary_text` parts) and `content`
@@ -1934,14 +1883,28 @@ mod tests {
);
}
/// An agentic harness replays every turn verbatim. Rendering all of
/// that thinking back into the prompt made the context mostly
/// self-quotation — 62% of replayed assistant content on a live
/// session — and the model started making shallow passes. Only the
/// newest turn's reasoning is context; the rest is noise the model
/// was never trained to read.
/// Prior-turn reasoning reaches the model; the model's own chat
/// template decides what to do with it (#329).
///
/// The translator used to keep reasoning on the newest assistant
/// turn and delete it from the rest. That was a third policy, owned
/// by us, that no model vendor implements: every vendor's rule is
/// tool-loop aware (keep thinking within the current tool-calling
/// episode, drop it across user turns), which is exactly what
/// Qwen's `last_query_index` does and what Anthropic requires
/// inside a tool-use turn.
///
/// It also made the prompt non-append-only: the block the model
/// generated into the KV cache was deleted on the next turn, so
/// turn N's token sequence was no longer a prefix of turn N+1's.
/// Measured across the change on a live fleet, prefix-cache reuse
/// on this surface went 53.9% -> 0.5%.
///
/// Policy now lives where it is expressible: the template, through
/// `chat_template_kwargs.preserve_thinking`, with precedence
/// request > operator > the template's own default.
#[test]
fn only_the_newest_turns_reasoning_is_replayed() {
fn prior_turn_reasoning_is_left_for_the_template_to_judge() {
let raw = r#"{
"model": "Qwen/Qwen3.8-27B",
"input": [
@@ -1962,10 +1925,9 @@ mod tests {
.collect();
assert_eq!(
carried,
["newer plan"],
"older turns must keep their content but lose their thinking"
["old plan", "newer plan"],
"every replayed turn's thinking must survive translation"
);
// The turns themselves survive — only the thinking is pruned.
assert_eq!(
chat.messages
.iter()
@@ -1975,8 +1937,29 @@ mod tests {
);
}
/// The continuation case still works: a turn cut off mid-thought is
/// the newest assistant turn, so its reasoning is what survives.
/// The caller's own policy still reaches the template unaltered —
/// this is the toggle #329 specifies, and the one a client can
/// actually set (pi sends it verbatim via `samplingParams`).
#[test]
fn a_callers_preserve_thinking_reaches_the_template_kwargs() {
let raw = r#"{
"model": "Qwen/Qwen3.8-27B",
"input": [{"type": "message", "role": "user", "content": "hi"}],
"chat_template_kwargs": {"preserve_thinking": false}
}"#;
let req: ResponsesRequest = serde_json::from_str(raw).expect("parse");
let chat = request_to_chat(req).expect("translate");
assert_eq!(
chat.extra
.get("chat_template_kwargs")
.and_then(|k| k.get("preserve_thinking")),
Some(&serde_json::Value::Bool(false))
);
}
/// The continuation case (#277): a turn cut off mid-thought is
/// replayed as reasoning with no assistant turn after it, and that
/// thinking — the work being resumed — must reach the model.
#[test]
fn a_truncated_turn_keeps_its_reasoning_through_a_continue() {
let raw = r#"{
+43
View File
@@ -99,6 +99,49 @@ async fn test_version_endpoint() {
assert!(!body.git_sha.is_empty());
}
#[tokio::test]
async fn test_metrics_endpoint_serves_prometheus_text() {
// The recorder is process-wide and other tests in this binary may
// have installed it already; either way the route must answer, and
// must never answer 200-with-empty-body (indistinguishable from a
// healthy process reporting nothing).
let _ = neuron::metrics::install();
neuron::metrics::record_finish("probe-model", 100, 1000, 64, Some((900, 50, 10)));
let url = spawn_neuron(fake_discovery()).await;
let resp = reqwest::Client::new()
.get(format!("{url}/metrics"))
.send()
.await
.expect("request should succeed");
assert_eq!(resp.status(), 200);
let ct = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(
ct.starts_with("text/plain"),
"Prometheus needs a text/plain scrape, got {ct:?}"
);
let body = resp.text().await.expect("body");
// The exporter renders only metrics that carry samples -- a
// `describe_*` on its own produces nothing -- so the scrape has to
// be checked after a recorded request, which is also the path that
// actually matters: record here, readable there.
assert!(
body.contains("neuron_decode_phase_seconds"),
"a recorded request must appear in the scrape:\n{body}"
);
assert!(
body.contains(r#"phase="forward""#) && body.contains(r#"phase="residual""#),
"phase labels must survive to the scrape:\n{body}"
);
}
#[tokio::test]
async fn test_health_endpoint() {
let url = spawn_neuron(fake_discovery()).await;
+3
View File
@@ -28,6 +28,9 @@ region = "eu-central"
[aliases]
"helexa/small" = "Qwen/Qwen3-8B"
"helexa/balanced" = "Qwen/Qwen3.8-27B"
# System-One decisions (#333). Also the model /v1/systemone falls back
# to when a request names none or names a service no operator serves.
"helexa/one" = "convaiinnovations/laya"
# hanzalova operator cortex, plaintext over the WireGuard mesh (the mesh
# is the trust boundary; no TLS pin needed for http://).
@@ -358,6 +358,20 @@ Whether narrowing replay *helps* the model is unmeasured. Set it in both
`/v1/models` and logged at load so a session can be attributed to the
arm it ran under.
A caller can override it per request with
`chat_template_kwargs.preserve_thinking`, on `/v1/chat/completions` and
`/v1/responses` alike — precedence is request > operator > template
default.
The setting also decides whether a multi-turn prompt is append-only, and
therefore whether the prefix cache can work at all. Replay is
byte-stable: the think block the model generated is re-rendered
unchanged, so turn N's tokens stay a prefix of turn N+1's. Anything that
deletes a prior turn's reasoning rewrites the middle of the prompt and
invalidates the cache from that point. Measured on this fleet when the
Responses path pruned prior reasoning unconditionally, reuse fell from
53.9% to 0.5% and stayed there (#329).
**`reasoning_budget.answer_reserve_tokens`** (default `4096`) holds back
part of the caller's output budget for the answer:
+26
View File
@@ -167,6 +167,28 @@ min_device_vram_mb = 4000
cost.input = 0.015
cost.output = 0.06
# System-One decisions (#333): Laya, a 421M non-autoregressive decision
# model (ModernBERT encoder + option-scoring head) served on
# /v1/systemone. One repo, three checkpoints -- English at the root,
# multilingual/ and typed-decisions/ beside it -- all resident (~2.3 GB
# bf16) so neuron can route each request by language. Not a text model:
# chat endpoints answer it with wrong_modality.
#
# Resident on quadbrat, replacing the Qwen3-1.7B pre-warm (operator
# decision 2026-09-29, #341); pinned there so a cold-loaded helexa/small
# can share the card but never evict it. Metered like TypeSafe Jev,
# input tokens only (every question re-encodes the state, and each
# encoded token counts), at Jev's published $0.042 per million.
[[models]]
id = "convaiinnovations/laya"
harness = "candle"
vram_mb = 3500
min_devices = 1
min_device_vram_mb = 4000
pinned_on = ["quadbrat"]
cost.input = 0.042
cost.output = 0.0
# -- Tier aliases ------------------------------------------------------------
# Clients can request inference against an alias and cortex transparently
# routes to the concrete model id below. Useful so client code can pick a
@@ -177,6 +199,10 @@ cost.output = 0.06
"helexa/balanced" = "Qwen/Qwen3-8B"
"helexa/large" = "Qwen/Qwen3.8-27B"
"helexa/image" = "Tongyi-MAI/Z-Image-Turbo"
# The System-One decision class: the default /v1/systemone resolves to
# for a request with no model, or one naming a service this fleet does
# not serve (a Jev client's own model name).
"helexa/one" = "convaiinnovations/laya"
# Text-to-image (#197/#203): Z-Image-Turbo, 6B S3-DiT + Qwen3-4B text
# encoder (runs on host CPU by default — see [harness.candle.image] in
+160
View File
@@ -0,0 +1,160 @@
"""Generate a tiny Laya parity fixture from the upstream reference (#336).
Builds the `laya` SDK's own `DecisionModel` (laya/common.py) around a
tiny ModernBERT, then records its outputs for a right-padded batch.
Writes, into --out:
rl_agent_config.json the decision-model config, as neuron parses it
encoder/config.json the ModernBERT config, saved by transformers
model.safetensors the reference's own weights, float32
tokenizer.json a placeholder word-level tokenizer (the fixture
is scored on token ids; loading needs a file)
expected.json the batch (ids, markers, question types) and the
reference's per-row scorer logits and act softmax
The shape is chosen so every mechanism is exercised at toy size:
- four encoder layers with a global layer on each side of two sliding
ones, and a sliding window (local_attention = 8, so +-4 positions)
far shorter than the 40-token row, so a window bug changes the result;
- two different rotary bases for global and sliding layers, so using
the wrong one for either kind is visible;
- rows of different lengths, so padding is present and must be masked;
- a one-option row, which takes the reference's single-marker branch
of the act features;
- every question type, so the type embedding is indexed three ways.
Weights are initialised well above transformers' default scale so
attention is sharp rather than near-uniform, and norm weights and biases
are perturbed away from their ones/zeros init: at init a missing norm
and a present one are indistinguishable.
Usage, in a venv holding the `laya` SDK and CPU torch:
python script/generate_laya_fixture.py \
--out crates/neuron/src/harness/testdata/laya/tiny
"""
import argparse
import json
import os
import torch
from safetensors.torch import save_file
from transformers import ModernBertConfig, AutoModel
from laya.common import DecisionModel
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True)
ap.add_argument("--seed", type=int, default=7)
args = ap.parse_args()
torch.manual_seed(args.seed)
os.makedirs(os.path.join(args.out, "encoder"), exist_ok=True)
ecfg = ModernBertConfig(
vocab_size=97,
hidden_size=32,
num_hidden_layers=4,
num_attention_heads=2,
intermediate_size=48,
max_position_embeddings=128,
local_attention=8,
global_attn_every_n_layers=3,
pad_token_id=0,
bos_token_id=1,
eos_token_id=2,
cls_token_id=1,
sep_token_id=2,
norm_eps=1e-5,
hidden_activation="gelu",
# Well above the 0.02 default: at 0.02 attention is near-uniform and
# a window or rotary bug moves the logits only in the sixth decimal.
initializer_range=0.3,
rope_parameters={
"full_attention": {"rope_theta": 1000.0, "rope_type": "default"},
"sliding_attention": {"rope_theta": 300.0, "rope_type": "default"},
},
)
encoder = AutoModel.from_config(ecfg, attn_implementation="sdpa")
model = DecisionModel(encoder, head_layers=2, n_act=2).eval()
with torch.no_grad():
for name, p in model.named_parameters():
if "norm" in name or name.startswith("scorer.0"):
p.add_(0.3 * torch.randn_like(p))
elif name.startswith("type_emb"):
p.mul_(3.0)
ecfg.save_pretrained(os.path.join(args.out, "encoder"))
rl_cfg = {
"encoder": "tiny-modernbert",
"head_layers": 2,
"max_len": 64,
"head_max_len": 24,
"act_costs": {"escalate": 0.5},
"amp_dtype": "bf16",
"temperature": [1.0, 1.0, 1.0],
"temperature_by_options": {},
}
with open(os.path.join(args.out, "rl_agent_config.json"), "w") as f:
json.dump(rl_cfg, f, indent=1)
# A placeholder: the fixture is scored on token ids, but a checkpoint
# directory is loaded with its tokenizer, so one must exist. It carries
# the special tokens a decision tokenizer is looked up by, with [PAD] at
# the encoder's pad_token_id (0).
with open(os.path.join(args.out, "tokenizer.json"), "w") as f:
json.dump({"version": "1.0", "truncation": None, "padding": None, "added_tokens": [],
"normalizer": None, "pre_tokenizer": {"type": "Whitespace"},
"post_processor": None, "decoder": None,
"model": {"type": "WordLevel",
"vocab": {"[PAD]": 0, "[UNK]": 1, "[CLS]": 2, "[SEP]": 3, "[MASK]": 4,
"a": 5, "b": 6},
"unk_token": "[UNK]"}}, f)
with open(os.path.join(args.out, "tokenizer_config.json"), "w") as f:
json.dump({"cls_token": "[CLS]", "sep_token": "[SEP]", "mask_token": "[MASK]",
"pad_token": "[PAD]", "unk_token": "[UNK]"}, f, indent=1)
state = {k: v.detach().contiguous().float() for k, v in model.state_dict().items()}
save_file(state, os.path.join(args.out, "model.safetensors"))
# (length, marker positions, qtype). Ids avoid 0 (pad) inside a row.
rows = [
(40, [3, 9, 17], 0),
(23, [4, 11], 1),
(9, [2], 2),
(31, [5, 7, 12, 20, 26], 0),
]
seqs = []
for n, markers, qtype in rows:
ids = torch.randint(1, ecfg.vocab_size, (n,)).tolist()
seqs.append({"input_ids": ids, "markers": markers, "qtype": qtype})
b = len(seqs)
L = max(len(s["input_ids"]) for s in seqs)
K = max(len(s["markers"]) for s in seqs)
ids = torch.zeros((b, L), dtype=torch.long)
att = torch.zeros((b, L), dtype=torch.long)
mpos = torch.zeros((b, K), dtype=torch.long)
mmask = torch.zeros((b, K), dtype=torch.bool)
for i, s in enumerate(seqs):
n = len(s["input_ids"])
ids[i, :n] = torch.tensor(s["input_ids"])
att[i, :n] = 1
k = len(s["markers"])
mpos[i, :k] = torch.tensor(s["markers"])
mmask[i, :k] = True
qtype = torch.tensor([s["qtype"] for s in seqs])
with torch.no_grad():
logits, act = model(ids, att, mpos, mmask, qtype)
act = torch.softmax(act.float(), -1)
for i, s in enumerate(seqs):
k = len(s["markers"])
s["logits"] = logits[i, :k].tolist()
s["act"] = act[i].tolist()
with open(os.path.join(args.out, "expected.json"), "w") as f:
json.dump({"seed": args.seed, "seqs": seqs}, f, indent=1)
print("wrote", args.out, "params:", sum(v.numel() for v in state.values()))
+361
View File
@@ -0,0 +1,361 @@
#!/usr/bin/env python3
"""Record Laya's language/script detector over a broad corpus (#339).
neuron routes each decision request to the English or multilingual Laya
checkpoint with a port of `laya.lang.analyse`. That function is a pile of
heuristics sitting on Python's Unicode semantics -- `str.isalpha`, `re`'s
`\\w`, `unicodedata.combining`, `str.lower`, `str.split` -- and a port that
reads the same can still route differently. This records the reference's
answer over a corpus built to reach every rule and every place those
semantics could diverge:
- every script range, real sentences and generated letter runs
- each function-word language, accented and ASCII-stripped, long and short
- English with loanwords, names, symbols and IPA; code, URLs, e-mails,
identifiers, acronyms and slash compounds
- mixed-script and mixed-language states, multi-line and structured
- the 4000-character budgets, the nesting depth limit, non-string leaves
- combining marks, decomposed text, case-mapping oddities (İ, ß, final
sigma, titlecase digraphs), fullwidth forms, numerals, emoji, and the
whitespace Python and Rust disagree about
- a seeded fuzz section mixing all of the above
Every mapping in the corpus has its keys in sorted order, so a consumer
whose JSON maps iterate in key order (serde_json without `preserve_order`)
sees the same leaf order Python does.
Characters are limited to those assigned in the Unicode version of the
Python that records the corpus, so a newer Unicode table on the consuming
side cannot disagree about a code point Python considers unassigned.
Usage (any Python 3.10+ with the laya SDK importable; no model, no network):
python script/laya-lang-reference.py \\
--out crates/neuron/src/harness/testdata/laya/lang_corpus.json
"""
import argparse
import json
import random
import sys
import unicodedata
import laya
from laya.lang import _WORD, _iter_text, analyse
SENTENCES = {
"en": "Please refund the duplicate charge, I was billed twice this month and it is not fair.",
"en_short": "refund please",
"en_loanword": "I would like a café table and my résumé is attached for the role.",
"en_name": "José and Zoë have the tickets that were sent to you from the office.",
"en_two_loanwords": "The naïve café owner sent the résumé to you and it was not what we wanted.",
"en_symbols": "Set α to 0.05 and β to 0.9, then run the job again with the new values.",
"en_russian_name": "The report from Дмитрий Петрович Савицкий was sent to the team yesterday.",
"en_ipa": "Vladimir is pronounced [vlɐˈdʲimʲɪr] in Russian and that is how we say it.",
"en_stress": "The name Влади́мир has a stress mark on the second syllable for you.",
"fr": "Bonjour, je voudrais annuler ma commande car elle est arrivée très en retard.",
"fr_short": "merci beaucoup",
"de": "Die Rechnung ist falsch, bitte korrigieren Sie den Betrag für diesen Monat.",
"de_short": "Danke schön",
"de_eszett": "Ich möchte die Straße und die Größe meiner Bestellung ändern, bitte.",
"es": "Me han cobrado dos veces este mes y quiero que me devuelvan el dinero ya.",
"pt": "Não consigo acessar a minha conta, já tentei redefinir a senha várias vezes.",
"pt_br": "Voce pode me mandar a nota fiscal? Nao recebi nada ate agora.",
"pt_ticket": "Deu erro 500 no endpoint de login depois do update de ontem.",
"it": "Vorrei sapere quando arriverà il mio ordine, è già in ritardo di una settimana.",
"nl": "Het pakket is niet aangekomen en ik wil graag een terugbetaling voor deze bestelling.",
"ro": "Vreau să anulez comanda pentru că produsul este deteriorat și nu funcționează.",
"bn": "ami taka ferot chai, amar order ekhono ashe nai, ki korbo bolun",
"az": "Mən sifarişi ləğv etmək istəyirəm, çünki məhsul çox gec gəldi.",
"tr": "Siparişimi iptal etmek istiyorum çünkü ürün çok geç geldi ve kırık.",
"pl": "Chciałbym zwrócić pieniądze, ponieważ paczka dotarła uszkodzona.",
"cs": "Chtěl bych vrátit peníze, protože zásilka dorazila poškozená.",
"hu": "Szeretném visszakapni a pénzemet, mert a csomag sérülten érkezett.",
"vi": "Tôi muốn được hoàn tiền vì gói hàng đã đến bị hư hỏng.",
"sv": "Jag vill ha pengarna tillbaka eftersom paketet kom fram skadat.",
"da": "Jeg vil gerne have pengene tilbage, fordi pakken kom i stykker.",
"sw": "Nataka kurudishiwa pesa zangu kwa sababu kifurushi kiliharibika.",
"la_shared_only": "la la e e o o un un",
"el": "Θέλω να ακυρώσω την παραγγελία μου γιατί ήρθε πολύ αργά.",
"el_upper_sigma": "ΘΕΛΩ ΤΗΝ ΕΠΙΣΤΡΟΦΗ ΧΡΗΜΑΤΩΝ ΤΟΥ ΛΟΓΑΡΙΑΣΜΟΥ ΣΑΣ",
"ru": "Мне дважды списали деньги в этом месяце, верните, пожалуйста.",
"uk": "Мені двічі списали гроші цього місяця, поверніть, будь ласка.",
"hy": "Ես ուզում եմ չեղարկել պատվերս, քանի որ այն ուշ հասավ։",
"he": "אני רוצה לבטל את ההזמנה שלי כי היא הגיעה באיחור.",
"he_niqqud": "שָׁלוֹם, אֲנִי רוֹצֶה לְבַטֵּל אֶת הַהַזְמָנָה",
"ar": "تم خصم المبلغ مرتين هذا الشهر، أريد استرداد أموالي من فضلكم.",
"fa": "می‌خواهم سفارشم را لغو کنم چون خیلی دیر رسید.",
"hi": "मुझे इस महीने दो बार शुल्क लिया गया, मुझे पैसे वापस चाहिए।",
"bn_script": "এই মাসে আমার কাছ থেকে দুবার টাকা কাটা হয়েছে, টাকা ফেরত চাই।",
"pa": "ਮੈਨੂੰ ਇਸ ਮਹੀਨੇ ਦੋ ਵਾਰ ਚਾਰਜ ਕੀਤਾ ਗਿਆ, ਪੈਸੇ ਵਾਪਸ ਚਾਹੀਦੇ ਹਨ।",
"gu": "મને આ મહિને બે વાર ચાર્જ કરવામાં આવ્યો, પૈસા પાછા જોઈએ છે.",
"or": "ମୋତେ ଏହି ମାସରେ ଦୁଇଥର ଚାର୍ଜ କରାଯାଇଛି।",
"ta": "இந்த மாதம் எனக்கு இரண்டு முறை கட்டணம் வசூலிக்கப்பட்டது.",
"te": "ఈ నెలలో నాకు రెండుసార్లు ఛార్జ్ చేశారు, డబ్బు తిరిగి ఇవ్వండి.",
"kn": "ಈ ತಿಂಗಳು ನನಗೆ ಎರಡು ಬಾರಿ ಶುಲ್ಕ ವಿಧಿಸಲಾಗಿದೆ.",
"ml": "ഈ മാസം എനിക്ക് രണ്ടുതവണ ചാർജ് ഈടാക്കി.",
"si": "මෙම මාසයේ මට දෙවරක් ගාස්තු අය කළා.",
"th": "เดือนนี้ฉันถูกเรียกเก็บเงินสองครั้ง ขอเงินคืนด้วยค่ะ",
"lo": "ເດືອນນີ້ຂ້ອຍຖືກເກັບເງິນສອງເທື່ອ",
"bo": "ང་ལ་ཟླ་བ་འདིར་ཐེངས་གཉིས་རིན་བསྡུས་སོང་།",
"my": "ဒီလမှာ ကျွန်တော့်ကို နှစ်ကြိမ် ငွေတောင်းခံခဲ့တယ်။",
"ka": "ამ თვეში ორჯერ ჩამომეჭრა თანხა, გთხოვთ დამიბრუნოთ.",
"am": "በዚህ ወር ሁለት ጊዜ ክፍያ ተቀንሶብኛል፣ እባክዎ ይመልሱልኝ።",
"km": "ខ្ញុំត្រូវបានគិតប្រាក់ពីរដងក្នុងខែនេះ ខ្ញុំចង់បានប្រាក់វិញ",
"ko": "이번 달에 두 번 청구되었습니다. 환불해 주세요.",
"ja": "今月二回請求されました。返金をお願いします。",
"ja_kana": "ありがとうございます。キャンセルしたいです。",
"zh": "这个月我被扣了两次钱,我要退款,请尽快处理。",
"zh_trad": "這個月我被扣了兩次錢,我要退款。",
"yue_ext_b": "𠮷野家の𩸽を食べたい",
"mn_cyr": "Би захиалгаа цуцлахыг хүсч байна.",
"bopomofo": "ㄅㄆㄇㄈ ㄉㄊㄋㄌ",
"halfwidth_kana": "キャンセルシタイデス",
"cherokee": "ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ",
}
EXTRA = [
"", " ", " \n\t ", "12345 67890", "3.14 2.71 1.41", "!!! ??? ...", "ok", "a", "no",
"🙂🙂🙂 👍", "refund 🙂 please 👍 thanks", "café", "résumé naïve café",
"ß", "Straße", "İstanbul İzmir", "ıi İI", "Džemal Ljubljana Nj", "ⓐⓑⓒ ⒶⒷⒸ", "𝐀𝐁𝐂 𝐚𝐛𝐜 test",
"Ⅰ Ⅱ Ⅲ ⅳ ⅴ", "x² y³ ½ ¾", "٣٤٥ ١٢", "१२३ ४५६", "FULLWIDTH text here",
"ʰʲʷ ˈˌ", "ɐɑɒ ʃʒ θð", "ƀƁƂ ǝǞ ȀȁȂ", "Ȿ ɀ", "ḀḁḂḃ ẞ ỳỹ",
"a\x1cb\x1dc\x1ed\x1ff", "word\u00a0word\u00a0word\u00a0word", "一\u3000二\u3000三",
"tab\tseparated\tvalues\there", "zero\u200bwidth\u200bjoiner", "soft\u00adhyphen",
"e\u0301 e\u0300 a\u0308 o\u0303", "Tôi muốn được hoàn tiền",
unicodedata.normalize("NFD", "Tôi muốn được hoàn tiền vì gói hàng đã đến bị hư hỏng."),
unicodedata.normalize("NFD", "Não consigo acessar a minha conta, já tentei várias vezes."),
unicodedata.normalize("NFD", "Vreau să anulez comanda pentru că produsul este deteriorat."),
"ΟΔΟΣ ΟΔΟΣ ΟΔΟΣ", "Σ", "ΑΣ ΑΣ", "ὈΔΥΣΣΕΎΣ",
]
CODE_AND_IDENTIFIERS = [
"see https://github.com/acme/os.path and user@acme.com for details",
"email me at joao.silva@empresa.com.br or maria@exemplo.pt please",
"version v1.2.3 of the lib, see U.S.A. rules and e.g. the docs",
"x = round(el, 2); return non_english",
"import os.path\nfrom foo import bar",
"def f(x):\n return {'la': 1, 'le': 2}",
"Nav/Com OS/2 C:\\DOS\\mode ESA/UN are names not sentences at all",
"MON LA EST COM DES are teams, states and radio bands in the listing",
"COM COM COM COM radio listing for the English region",
"O CLIENTE ESTÁ MUITO IRRITADO COM O ATRASO DO PEDIDO",
"call foo(bar) then baz(qux) and see what the output of the call is",
"Deu erro (500) no login depois do update de ontem",
"a.b.c.d e@f g.h-i j_k.l m-n.o",
"-leading.dash and trailing.dot. and @handle and .hidden files here",
"..... @@@@ .@. a.@b",
"x" * 300 + ".com",
"path/to/file.py line 42 in <module> raise ValueError('não funciona')",
]
MULTILINE = [
"Hello team,\nPlease see below.\nNão consigo acessar a minha conta, já tentei redefinir a senha.\nThanks",
"Traceback (most recent call last):\n File \"app.py\", line 3, in <module>\n x = foo()\nO sistema não "
"está funcionando desde ontem e preciso de ajuda urgente com isso",
"Line one in English is here.\nLa fattura è sbagliata, per favore correggetela subito.",
"Short line\n\n\nAnother short\nDie Rechnung ist falsch und ich bitte um eine Korrektur heute.",
"English only line one.\nEnglish only line two.\nAnd the third line of English text.",
"José\nMaría\nFrançois\nplease refund the order that was sent to them",
"Ticket #1\nこんにちは、注文をキャンセルしたいです\nThanks for the help with this",
# Targeted: each of the next cases flips if one rule is ported wrong.
# All-caps function words in mixed-case text are acronyms, not French.
"Please refund the duplicate charge, I was billed twice this month and it is not fair.\n"
"Scores: MON LA EST DES COM LES ET tonight",
# U+001C splits tokens for str.split(), so the identifier is dropped
# alone rather than taking the Portuguese words with it.
"Please refund the duplicate charge, I was billed twice this month.\n"
"Não\x1cconsigo\x1cacessar\x1cminha\x1cconta\x1cjá\x1cx.y",
# A combining stress mark must not split a capitalised name into a
# lowercase "word".
"The customer name on the invoice is Влади́мир "
"Петро́вич and we need it fixed today",
# One loanword: English function words rescue the marginal diacritic rate.
"We will meet at the café for you",
"I think the naïve plan is fine for all of us here",
]
def budget_cases():
en = "Please refund the duplicate charge on my card for this month. "
pt = "Não consigo acessar a minha conta, já tentei redefinir a senha várias vezes. "
out = []
for n in (3990, 3999, 4000, 4001, 4010, 8000):
out.append((en * (n // len(en) + 1))[:n])
out.append((en * 70)[:3995] + " " + pt)
out.append((en * 70) + pt)
out.append((en * 100) + "\n" + pt)
out.append("x" * 3999 + "\n" + pt)
out.append("x" * 4000 + "\n" + pt)
out.append(("é" * 5000))
out.append(("ab " * 1400) + pt)
return out
def structured_cases():
en = SENTENCES["en"]
pt = SENTENCES["pt"]
de = SENTENCES["de"]
ja = SENTENCES["ja"]
deep = "Não consigo acessar a minha conta, já tentei redefinir a senha."
nested = deep
deeps = []
for depth in range(9):
nested = [nested]
deeps.append(nested)
return [
{"body": pt, "subject": "Refund request"},
{"body": en, "note": pt},
{"a_note": en * 60, "b_message": de},
{"a_note": en * 60, "b_message": "Hallo, bitte."},
{"customer": "José", "message": en},
{"name": "Дмитрий Петрович", "text": en},
{"message": ja, "subject": "Order"},
{"count": 3, "flag": True, "missing": None, "text": en},
{"count": 3, "flag": False, "text": pt},
{"items": [en, pt, {"deep": de}], "meta": {"lang": "?"}},
{"conversation": [{"content": en, "role": "user"}, {"content": pt, "role": "user"}]},
{"a": {"b": {"c": {"d": {"e": {"f": {"g": pt}}}}}}},
{"a": {"b": {"c": {"d": {"e": {"f": pt}}}}}},
{"code": "x = round(el, 2)\nreturn os.path", "text": en},
{"body": "Traceback:\n File x.py\nO sistema não está funcionando desde ontem e preciso de ajuda", "id": 7},
{"a": "José", "b": "María", "c": "François", "d": en},
{"a": en * 70, "b": "Vreau să anulez comanda pentru că produsul este deteriorat."},
{"a": en * 70, "b": "Мне дважды списали деньги в этом месяце, верните."},
{"a": "ok", "b": "no", "c": "yes"},
{},
[],
[en, pt],
[pt, en],
[en, en, en, "Die Rechnung ist falsch, bitte korrigieren."],
["hello", "bonjour", "hola"],
[1, 2, 3],
[None, True, 2.5],
[[["deep", ["deeper", [pt]]]]],
*deeps,
12345,
3.5,
True,
None,
]
def fuzz_cases(n, seed):
rng = random.Random(seed)
pools = [v for v in SENTENCES.values()] + EXTRA + CODE_AND_IDENTIFIERS
words = []
for s in pools:
words.extend(s.split())
specials = list("\n\t .,;:=()[]{}/\\@_-'\"") + ["\x1c", "\u00a0", "\u3000", "\u0301", "\u0308",
"\u200b", "İ", "ı", "ß", "Σ", "Dž", "Ⅲ", "²",
"🙂", "1", "A", "a", "ʰ", "ɐ", "é", "ñ"]
out = []
for _ in range(n):
k = rng.randint(1, 40)
parts = []
for _ in range(k):
r = rng.random()
if r < 0.75:
parts.append(rng.choice(words))
else:
parts.append(rng.choice(specials))
sep = rng.choice([" ", " ", " ", "\n", ""])
out.append(sep.join(parts))
return out
def generated_script_runs():
"""A run of assigned letters from every script range the detector names."""
from laya.lang import _SCRIPT_RANGES
out = []
for name, ranges in _SCRIPT_RANGES:
for lo, hi in ranges:
letters = [chr(cp) for cp in range(lo, hi + 1)
if unicodedata.category(chr(cp)) != "Cn"]
if not letters:
continue
step = max(1, len(letters) // 24)
sample = letters[::step][:24]
out.append("".join(sample))
out.append(" ".join("".join(sample[i:i + 4]) for i in range(0, len(sample), 4)))
return out
def assigned(s):
return all(unicodedata.category(c) != "Cn" and not (0xD800 <= ord(c) <= 0xDFFF) for c in s)
def sorted_keys(v):
if isinstance(v, dict):
return list(v) == sorted(v) and all(sorted_keys(x) for x in v.values())
if isinstance(v, list):
return all(sorted_keys(x) for x in v)
return True
def all_strings(v):
if isinstance(v, str):
yield v
elif isinstance(v, dict):
for x in v.values():
yield from all_strings(x)
elif isinstance(v, list):
for x in v:
yield from all_strings(x)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True)
ap.add_argument("--fuzz", type=int, default=400)
ap.add_argument("--seed", type=int, default=339)
args = ap.parse_args()
states = []
for name, s in SENTENCES.items():
states.append(s)
ascii_s = "".join(c for c in unicodedata.normalize("NFKD", s) if not unicodedata.combining(c))
if ascii_s != s:
states.append(ascii_s)
states.append(s.upper())
states.append(" ".join(s.split()[:3]))
states += EXTRA + CODE_AND_IDENTIFIERS + MULTILINE + budget_cases()
states += generated_script_runs()
states += structured_cases()
# Pairwise mixes: one English and one foreign line, both orders.
for name, s in SENTENCES.items():
if not name.startswith("en"):
states.append(SENTENCES["en"] + "\n" + s)
states.append(s + " " + SENTENCES["en"])
states += fuzz_cases(args.fuzz, args.seed)
cases, seen = [], set()
for st in states:
key = json.dumps(st, ensure_ascii=False, sort_keys=False)
if key in seen:
continue
seen.add(key)
assert sorted_keys(st), "mapping keys must be sorted: %r" % (st,)
if not all(assigned(s) for s in all_strings(st)):
continue
det = analyse(st)
# Recorded separately because a JSON reader that does not keep key
# order loses the profile's order, which the reference defines.
rec = {"state": st, "leaves": _iter_text(st), "analysis": det,
"profile_order": list(det["script_profile"])}
if isinstance(st, str):
rec["words"] = _WORD.findall(st)
cases.append(rec)
header = {
"laya_version": laya.__version__,
"python": sys.version.split()[0],
"unidata_version": unicodedata.unidata_version,
"fuzz_seed": args.seed,
}
with open(args.out, "w") as f:
json.dump({"header": header, "cases": cases}, f, ensure_ascii=False, indent=0)
print("%d cases" % len(cases), file=sys.stderr)
if __name__ == "__main__":
main()
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env python3
"""Record Laya reference outputs for neuron's parity tests (#334).
Runs the upstream `laya` SDK (the reference implementation, not the
`rl_common.py` shipped in the model repo, which has diverged) over a fixed
corpus and writes, per case:
- the request exactly as a `/v1/systemone` client would send it
- per question: `input_ids`, `[MASK]` marker positions, question type and
the option-collapse stats, straight out of `Agent._encode_state`
- the raw scorer logits and act probabilities out of `Agent._forward`,
before any temperature is applied
- the final Jev-shaped response (`answers`, `usage`, `routing`)
A separate section records `laya.lang.analyse` on a language corpus, for
the router port.
The reference runs on CPU in float32 so the numbers are deterministic and
free of autocast noise; neuron's reduced-precision paths are compared
against them with a tolerance.
Usage (in a venv with `pip install laya==<pinned>` and CPU torch):
HF_ENDPOINT=... HF_TOKEN=... python script/laya-reference.py \
--out crates/neuron/src/harness/testdata/laya
Point HF_ENDPOINT at whichever Hugging Face-compatible registry you fetch
weights through. Python's huggingface_hub may additionally need
SSL_CERT_FILE (for a private CA) and HF_HUB_DISABLE_XET=1 (for registries
without Xet support).
"""
import argparse
import json
import os
import sys
import laya
import numpy as np
import torch
from laya import Router
from laya.agent import Agent
from laya.lang import analyse
from laya.serve import _resolve_model
LONG_TEXT = (
"Hello team, following up on the invoice discrepancy from last quarter. "
"The amounts on INV-2291 and INV-2307 do not match the purchase order, "
"and our finance department needs a corrected copy before month end. "
) * 12
CASES = [
# --- the three primitives, default budgets
{
"name": "choice_basic",
"state": "I was charged twice this month, I want my money back",
"questions": {
"queue": {"type": "choice", "instructions": "Which team?",
"criteria": {"billing": "billing and refunds", "tech": "login and app issues",
"other": "everything else"}},
},
},
{
"name": "score_basic",
"state": "This is the THIRD time I've asked. Fix it today or I'm cancelling.",
"questions": {
"urgency": {"type": "score", "instructions": "How urgent?",
"criteria": ["calm", "firm", "angry", "furious"]},
},
},
{
"name": "noul_default_criteria",
"state": "Please refund the duplicate charge on my card.",
"questions": {"refund": {"type": "noul", "instructions": "Is the customer asking for a refund?"}},
},
{
"name": "noul_custom_criteria_and_labels",
"state": "Can you reset my password? I'm locked out.",
"questions": {
"locked": {"type": "noul", "instructions": "Is the user locked out of their account?",
"criteria": {"true": "the user cannot sign in", "false": "the user can sign in"},
"labels": {"true": "locked", "false": "not locked"}},
},
},
# --- several questions in one request: the shared-state, one-forward path
{
"name": "multi_question",
"state": "I was charged twice this month, I want my money back",
"questions": {
"queue": {"type": "choice", "instructions": "Which team?",
"criteria": {"billing": "billing and refunds", "tech": "login and app issues",
"other": "everything else"}},
"urgency": {"type": "score", "instructions": "How urgent?",
"criteria": ["calm", "firm", "angry", "furious"]},
"refund": {"type": "noul", "instructions": "Is the customer asking for a refund?"},
"tone": {"type": "choice", "instructions": "Tone?", "criteria": ["polite", "rude", "neutral"]},
"spam": {"type": "noul", "instructions": "Is this spam?"},
},
},
# --- every temperature bucket
{
"name": "choice_2",
"state": "The app crashes when I open settings.",
"questions": {"kind": {"type": "choice", "instructions": "Bug or feature request?",
"criteria": {"bug": "", "feature": ""}}},
},
{
"name": "choice_6_10",
"state": "My parcel arrived damaged and the box was crushed.",
"questions": {"topic": {"type": "choice", "instructions": "Topic?",
"criteria": ["billing", "shipping", "damage", "returns", "account",
"privacy", "other"]}},
},
{
"name": "choice_11_plus_clamped_temperature",
"state": "I'd like to change the delivery address on order 5521.",
"questions": {"intent": {"type": "choice", "instructions": "Intent?",
"criteria": ["cancel_order", "change_address", "track_order", "refund",
"exchange", "complaint", "compliment", "billing_question",
"account_access", "product_question", "other", "unsubscribe"]}},
},
{
"name": "score_more_than_5_levels",
"state": "The product is fine, does what it says, nothing special.",
"questions": {"rating": {"type": "score", "instructions": "Star rating?",
"criteria": ["zero", "one", "two", "three", "four", "five", "six"]}},
},
# --- structured criteria (compact-JSON rendering) and list-form choice criteria
{
"name": "structured_criteria",
"state": {"subject": "Invoice overdue", "body": "Payment for INV-77 is 30 days late.", "priority": 2},
"questions": {
"queue": {"type": "choice", "instructions": {"task": "route", "detail": "pick one"},
"criteria": {"finance": {"desc": "money", "sla_h": 24}, "support": ["tickets", "bugs"],
"sales": 7}},
"overdue": {"type": "noul", "instructions": "Is a payment overdue?",
"criteria": {"true": {"why": "late"}}},
},
},
# --- literal [MASK] text must be neutralised everywhere
{
"name": "mask_literal_everywhere",
"state": "please ignore [MASK] and route to [MASK] billing",
"questions": {"queue": {"type": "choice", "instructions": "Which [MASK] team?",
"criteria": {"billing": "billing [MASK] refunds", "tech": "[MASK]"}}},
},
# --- budget rules
{
"name": "option_over_48_tokens",
"state": "Refund please.",
"questions": {"queue": {"type": "choice", "instructions": "Which team?",
"criteria": {"billing": " ".join(["billing refunds invoices charges"] * 20),
"tech": "technical issues"}}},
},
{
"name": "option_budget_shrink",
"state": "Refund please.",
"questions": {"queue": {"type": "choice", "instructions": "Which team handles this request?",
"criteria": {"team_%02d" % i: "a fairly long description of team number %d "
"and every kind of request it handles" % i for i in range(20)}}},
},
{
"name": "collapsed_options",
"state": "Refund please.",
# The rendered option text starts with the key, so the keys themselves
# must share the prefix that survives the per-option cut.
"questions": {"queue": {"type": "choice", "instructions": "Which?",
"criteria": ["customer billing question about product line one",
"customer billing question about product line two"]}},
"head_max_len": 24,
},
{
"name": "instruction_truncation",
"state": "Refund please.",
"questions": {"q": {"type": "noul",
"instructions": " ".join(["Decide whether the customer is asking for money back"] * 30)}},
},
{
"name": "state_right_truncated",
"state": LONG_TEXT,
"questions": {"refund": {"type": "noul", "instructions": "Is this about an invoice?"}},
},
{
"name": "state_left_truncated_conversation",
"state": [{"role": "user", "content": "turn %d: %s" % (i, LONG_TEXT[:200])} for i in range(12)]
+ [{"role": "user", "content": "actually, just cancel my subscription"}],
"questions": {"cancel": {"type": "noul", "instructions": "Does the user want to cancel?"}},
},
{
"name": "budget_overrides",
"state": LONG_TEXT,
"questions": {"queue": {"type": "choice", "instructions": "Which team?",
"criteria": ["finance", "support", "sales"]}},
"max_len": 128,
"head_max_len": 64,
},
# --- non-ASCII inside a JSON state
{
"name": "json_state_non_ascii",
"state": {"from": "Zoë", "text": "Die Rechnung ist falsch — bitte korrigieren. 请尽快处理"},
"questions": {"queue": {"type": "choice", "instructions": "Which team?",
"criteria": ["billing", "tech", "other"]}},
},
# --- routing to the multilingual checkpoint
{
"name": "ml_devanagari",
"state": "मुझे इस महीने दो बार शुल्क लिया गया, मुझे पैसे वापस चाहिए",
"questions": {"queue": {"type": "choice", "instructions": "Which team?",
"criteria": {"billing": "billing and refunds", "tech": "login and app issues",
"other": "everything else"}}},
},
{
"name": "ml_khmer",
"state": "ខ្ញុំត្រូវបានគិតប្រាក់ពីរដងក្នុងខែនេះ ខ្ញុំចង់បានប្រាក់វិញ",
"questions": {"refund": {"type": "noul", "instructions": "Is the customer asking for a refund?"}},
},
{
"name": "ml_cjk",
"state": "这个月我被扣了两次钱,我要退款",
"questions": {"urgency": {"type": "score", "instructions": "How urgent?",
"criteria": ["calm", "firm", "angry", "furious"]}},
},
{
"name": "ml_arabic",
"state": "تم خصم المبلغ مرتين هذا الشهر، أريد استرداد أموالي",
"questions": {"refund": {"type": "noul", "instructions": "Is the customer asking for a refund?"}},
},
{
"name": "ml_latin_non_english",
"state": "Me han cobrado dos veces este mes y quiero que me devuelvan el dinero",
"questions": {"queue": {"type": "choice", "instructions": "Which team?",
"criteria": ["billing", "tech", "other"]}},
},
{
"name": "ml_mixed_script",
"state": "Order #5521 — 注文をキャンセルしたいです. Please help.",
"questions": {"cancel": {"type": "noul", "instructions": "Does the user want to cancel?"}},
},
# --- explicit checkpoint selection through `model`
{
"name": "model_pinned_multilingual",
"state": "I was charged twice this month, I want my money back",
"questions": {"refund": {"type": "noul", "instructions": "Is the customer asking for a refund?"}},
"model": "multilingual",
},
{
"name": "model_pinned_typed_decisions",
"state": "I was charged twice this month, I want my money back",
"questions": {"refund": {"type": "noul", "instructions": "Is the customer asking for a refund?"}},
"model": "typed-decisions",
},
{
"name": "model_foreign_name_routes_by_default",
"state": "I was charged twice this month, I want my money back",
"questions": {"refund": {"type": "noul", "instructions": "Is the customer asking for a refund?"}},
"model": "jev-1",
},
]
LANG_CASES = [
"I was charged twice this month, I want my money back",
"Me han cobrado dos veces este mes",
"Die Rechnung ist falsch, bitte korrigieren",
"Je voudrais annuler ma commande",
"मुझे पैसे वापस चाहिए",
"ខ្ញុំចង់បានប្រាក់វិញ",
"这个月我被扣了两次钱",
"注文をキャンセルしたいです",
"تم خصم المبلغ مرتين",
"Мне дважды списали деньги",
"Order #5521 — 注文をキャンセルしたいです. Please help.",
"ok",
"",
"12345 67890",
"café résumé naïve",
{"subject": "Invoice", "body": "Bitte zahlen Sie bis Freitag"},
["hello", "bonjour", "hola"],
]
class Capture:
"""Wraps the two Agent internals the fixtures need, per call."""
def __init__(self):
self.items = None
self.logits = None
self.act = None
self.agent = None
orig_encode, orig_forward = Agent._encode_state, Agent._forward
cap = self
def encode(agent, *a, **kw):
items = orig_encode(agent, *a, **kw)
cap.items, cap.agent = items, agent
return items
def forward(agent, b):
logits, act = orig_forward(agent, b)
cap.logits, cap.act = logits, act
return logits, act
Agent._encode_state, Agent._forward = encode, forward
def reset(self):
self.items = self.logits = self.act = self.agent = None
def to_py(x):
if isinstance(x, np.ndarray):
return x.tolist()
if isinstance(x, (np.floating, np.integer)):
return x.item()
if isinstance(x, dict):
return {k: to_py(v) for k, v in x.items()}
if isinstance(x, (list, tuple)):
return [to_py(v) for v in x]
return x
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True)
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
torch.manual_seed(0)
torch.set_num_threads(max(1, (os.cpu_count() or 2) // 2))
cap = Capture()
router = Router(device="cpu", preload=True, max_loaded=3)
header = {
"laya_version": laya.__version__,
"torch_version": torch.__version__,
"device": "cpu",
"dtype": "float32",
}
cases, checkpoints = [], {}
for case in CASES:
req = {"state": case["state"], "questions": case["questions"]}
kw = {}
for key in ("model", "max_len", "head_max_len"):
if key in case:
req[key] = case[key]
if key != "model":
kw[key] = case[key]
cap.reset()
# The HTTP server, not the Router, maps a foreign model name (a Jev id)
# to "let the router choose"; go through the same function.
resp = router.predict(case["state"], case["questions"], model=_resolve_model(case.get("model")), **kw)
agent = cap.agent
rows = []
for j, (qid, it) in enumerate(zip(case["questions"], cap.items)):
k = len(it["markers"])
rows.append({
"qid": qid,
"input_ids": list(it["ids"]),
"markers": list(it["markers"]),
"qtype": it["qtype"],
"options": it.get("options"),
"logits": cap.logits[j, :k].tolist(),
"act": cap.act[j].tolist(),
})
ckpt = resp["routing"]["model"]
if ckpt not in checkpoints:
checkpoints[ckpt] = {
"repo": resp["routing"].get("repo"),
"revision": getattr(agent, "revision", None),
"cfg": {k: agent.cfg.get(k) for k in ("max_len", "head_max_len", "temperature",
"temperature_by_options")},
"special_ids": {"cls": agent.tok.cls_token_id, "sep": agent.tok.sep_token_id,
"mask": agent.tok.mask_token_id, "pad": agent.tok.pad_token_id},
}
cases.append({"name": case["name"], "request": req, "items": rows, "response": to_py(resp)})
print("%-40s -> %-16s %d question(s)" % (case["name"], ckpt, len(rows)), file=sys.stderr)
lang = [{"state": s, "analysis": to_py(analyse(s))} for s in LANG_CASES]
header["checkpoints"] = checkpoints
with open(os.path.join(args.out, "reference.json"), "w") as f:
json.dump({"header": header, "cases": cases}, f, ensure_ascii=False, indent=1)
with open(os.path.join(args.out, "lang.json"), "w") as f:
json.dump({"header": header, "cases": lang}, f, ensure_ascii=False, indent=1)
if __name__ == "__main__":
main()
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""End-to-end check of /v1/systemone decision serving (#341).
Replays every case of the recorded Laya reference corpus
(`crates/neuron/src/harness/testdata/laya/reference.json`) against a
serving endpoint -- a neuron directly, a cortex, or the public router --
and compares each response with what the reference server returned:
- `routing.model` (which checkpoint answered) and `usage` must match
exactly;
- each answer's type, and its chosen option or score level, must match,
except where the reference itself was within `--tie` of a tie (reduced
precision may break a near-tie either way);
- probabilities and confidences must be within `--tolerance`. The
reference is f32; a bf16 GPU path drifts from it by about as much as
the reference's own bf16 GPU run does.
It prints each case's latency and a summary, and exits non-zero on any
mismatch. Nothing is loaded or unloaded: point it at an endpoint that
already serves a decision model.
Usage:
script/validate-decision.py URL [--model M] [--bearer KEY]
URL http://quadbrat.hanzalova.internal:13131 (neuron)
http://hanzalova.internal:31313 (cortex)
https://router.example (public chain)
--model override every request's `model` (e.g. helexa/one); by
default each case sends what it was recorded with, which
includes checkpoint pins and a foreign Jev model name.
"""
import argparse
import json
import os
import statistics
import sys
import time
import urllib.error
import urllib.request
FIXTURE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "crates", "neuron", "src",
"harness", "testdata", "laya", "reference.json")
def post(url, body, bearer):
req = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST",
headers={"Content-Type": "application/json"})
if bearer:
req.add_header("Authorization", "Bearer " + bearer)
started = time.perf_counter()
try:
with urllib.request.urlopen(req, timeout=120) as r:
payload = json.load(r)
status = r.status
except urllib.error.HTTPError as e:
payload = json.loads(e.read() or b"{}")
status = e.code
return status, payload, (time.perf_counter() - started) * 1e3
def top_gap(probs):
p = sorted(probs, reverse=True)
return p[0] - p[1] if len(p) > 1 else 1.0
def compare(name, got, want, tol, tie):
errs = []
if got.get("routing", {}).get("model") != want["routing"]["model"]:
errs.append("routing.model %r != %r" % (got.get("routing", {}).get("model"), want["routing"]["model"]))
if got.get("usage") != want["usage"]:
errs.append("usage %r != %r" % (got.get("usage"), want["usage"]))
ga, wa = got.get("answers", {}), want["answers"]
if list(ga) != list(wa):
errs.append("answer ids %r != %r" % (list(ga), list(wa)))
return errs, 0.0
worst = 0.0
for qid, w in wa.items():
g = ga[qid]
if g.get("type") != w["type"]:
errs.append("%s: type %r != %r" % (qid, g.get("type"), w["type"]))
continue
wp = w.get("probabilities")
near_tie = wp is not None and top_gap(list(wp.values())) < tie
if w["type"] == "choice" and g.get("choice") != w["choice"] and not near_tie:
errs.append("%s: choice %r != %r" % (qid, g.get("choice"), w["choice"]))
if wp:
for opt, p in wp.items():
worst = max(worst, abs(g["probabilities"][opt] - p))
for key in ("noul", "confidence", "answer_confidence"):
if key in w:
worst = max(worst, abs(g[key] - w[key]))
if worst > tol:
errs.append("max |Δ| %.4f > %.4f" % (worst, tol))
return errs, worst
def main():
ap = argparse.ArgumentParser()
ap.add_argument("url")
ap.add_argument("--model")
ap.add_argument("--bearer")
ap.add_argument("--tolerance", type=float, default=0.1)
ap.add_argument("--tie", type=float, default=0.05)
args = ap.parse_args()
endpoint = args.url.rstrip("/") + "/v1/systemone"
cases = json.load(open(FIXTURE))["cases"]
failures, latencies, worst_all = 0, [], 0.0
for case in cases:
body = dict(case["request"])
if args.model:
body["model"] = args.model
status, got, ms = post(endpoint, body, args.bearer)
latencies.append(ms)
if status != 200:
failures += 1
print("FAIL %-40s HTTP %d %s" % (case["name"], status, json.dumps(got)[:200]))
continue
errs, worst = compare(case["name"], got, case["response"], args.tolerance, args.tie)
worst_all = max(worst_all, worst)
mark = "ok " if not errs else "FAIL"
failures += bool(errs)
print("%s %-40s %7.1f ms %-15s max|Δ| %.4f %s" % (
mark, case["name"], ms, got.get("routing", {}).get("model", "?"), worst, "; ".join(errs)))
print("\n%d/%d cases ok; latency median %.1f ms, p90 %.1f ms; worst |Δ| %.4f" % (
len(cases) - failures, len(cases), statistics.median(latencies),
sorted(latencies)[int(0.9 * (len(latencies) - 1))], worst_all))
sys.exit(1 if failures else 0)
if __name__ == "__main__":
main()
+44 -27
View File
@@ -9,17 +9,27 @@
# image modality.
#
# Usage:
# script/validate-image.sh [host] [model_id]
# script/validate-image.sh [host] [model_id] [quant]
#
# Defaults:
# host = benjy.hanzalova.internal (the v1 image placement)
# model_id = Tongyi-MAI/Z-Image-Turbo
# quant = none (BF16). A 12 GB card needs q8_0.
#
# A co-resident model evicted for the probe window is restored on every
# exit path, failures included.
set -euo pipefail
HOST="${1:-benjy.hanzalova.internal}"
MODEL_ID="${2:-Tongyi-MAI/Z-Image-Turbo}"
QUANT="${3:-}"
BASE="http://${HOST}:13131"
if [[ -n "${QUANT}" ]]; then
LOAD_JSON="{\"model_id\": \"${MODEL_ID}\", \"harness\": \"candle\", \"quant\": \"${QUANT}\"}"
else
LOAD_JSON="{\"model_id\": \"${MODEL_ID}\", \"harness\": \"candle\"}"
fi
echo "==> /version"
curl -sf -m 10 "${BASE}/version" | python3 -c 'import json,sys; b=json.load(sys.stdin); print(" ", b.get("git_sha","?")[:12], b.get("profile"), b.get("features"))'
@@ -28,26 +38,47 @@ curl -sf -m 10 "${BASE}/version" | python3 -c 'import json,sys; b=json.load(sys.
# cortex's job. On a host whose resident text model leaves too little
# VRAM for the DiT (benjy: 8B @ 16 GB + 14.6 GB DiT > 24 GB), evict
# it for the probe window and restore it afterwards.
RESTORE_JSON=""
EVICT=$(curl -sf -m 10 "${BASE}/models" | python3 -c '
EVICTED=()
# Unload the probe model and restore the evicted ones however the script
# ends: a failed generation must not leave the host without its models.
restore() {
local rc=$?
curl -s -m 60 -X POST "${BASE}/models/unload" \
-H 'Content-Type: application/json' \
-d "{\"model_id\": \"${MODEL_ID}\"}" > /dev/null || true
for id in "${EVICTED[@]}"; do
echo "==> restoring ${id}"
curl -s -m 900 -X POST "${BASE}/models/load" \
-H 'Content-Type: application/json' \
-d "{\"model_id\": \"${id}\", \"harness\": \"candle\"}" \
|| echo " RESTORE FAILED: reload ${id} by hand"
echo
done
exit "${rc}"
}
trap restore EXIT
mapfile -t RESIDENT < <(curl -sf -m 10 "${BASE}/models" | python3 -c '
import json, sys
models = json.load(sys.stdin)
loaded = [m for m in models if m["status"] == "loaded" and "image" not in m.get("capabilities", [])]
print(loaded[0]["id"] if loaded else "")
for m in json.load(sys.stdin):
if m["status"] == "loaded" and "image" not in m.get("capabilities", []):
print(m["id"])
')
if [[ -n "${EVICT}" ]]; then
echo "==> evicting co-resident ${EVICT} for the probe window"
for id in "${RESIDENT[@]}"; do
[[ -z "${id}" ]] && continue
echo "==> evicting co-resident ${id} for the probe window"
curl -sf -m 120 -X POST "${BASE}/models/unload" \
-H 'Content-Type: application/json' \
-d "{\"model_id\": \"${EVICT}\"}"
-d "{\"model_id\": \"${id}\"}"
echo
RESTORE_JSON="{\"model_id\": \"${EVICT}\", \"harness\": \"candle\"}"
fi
EVICTED+=("${id}")
done
echo "==> load ${MODEL_ID}"
curl -sf -m 900 -X POST "${BASE}/models/load" \
-H 'Content-Type: application/json' \
-d "{\"model_id\": \"${MODEL_ID}\", \"harness\": \"candle\"}"
-d "${LOAD_JSON}"
echo
echo "==> capabilities"
@@ -80,20 +111,6 @@ t = usage["helexa_timing"]
print(" 512x512 ok | units={:.3f} | encode={}ms denoise={}ms decode={}ms".format(units, t["encode_ms"], t["denoise_ms"], t["decode_ms"]))
'
echo "==> unload"
curl -sf -m 60 -X POST "${BASE}/models/unload" \
-H 'Content-Type: application/json' \
-d "{\"model_id\": \"${MODEL_ID}\"}"
echo
if [[ -n "${RESTORE_JSON}" ]]; then
echo "==> restoring ${EVICT}"
curl -sf -m 900 -X POST "${BASE}/models/load" \
-H 'Content-Type: application/json' \
-d "${RESTORE_JSON}"
echo
fi
echo "==> health"
curl -sf -m 10 "${BASE}/health" > /dev/null && echo " healthy"
echo "PASS"
echo "PASS (unload and restore follow)"