Compare commits

..
Author SHA1 Message Date
grenade c37d9de527 feat(reasoning): gate reasoning replay on the model's own template switch
CI / Config consistency (push) Successful in 12s
CI / Classify changes (push) Successful in 15s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 9s
CI / Clippy (push) Successful in 2m32s
CI / CUDA type-check (push) Successful in 20m18s
CI / Test (push) Successful in 9m8s
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
Makes H7's premise testable without trusting it.

H7 (`fix/replay-only-the-latest-reasoning`, unmerged) would replay only
the newest turn's reasoning. The premise is plausible — full replay is
what makes prompt growth track total completion rather than visible
output, pushing against the prefix-cache budget (#292) and the
throughput-derived context ceiling — but it was never measured, and
"withhold information from the model" is not a change to make on a
hunch.

It also turns out not to need new mechanism. `Qwen/Qwen3.8-27B`'s
template already has the switch:

    {%- if preserve_thinking is undefined or preserve_thinking is true
           or loop.index0 > ns.last_query_index %}
        ... '<think>\n' + reasoning_content + '\n</think>\n\n' + content
    {%- else %}
        ... content

`preserve_thinking = false` keeps reasoning only for turns after the
last user query — the turn in progress — which is exactly what H7 does,
expressed by the people who trained the model. And the default is
`true`: full replay is the authors' choice, not our accident.

So this exposes `preserve_thinking` as operator config rather than
changing any default. Precedence is request > operator > template
default; unset changes nothing. Flipping it is a config-only deploy
(~6 min, no binary rotation) now that the CI classifier treats
`asset/neuron/*.toml` as inert, which makes an A/B cheap enough to run
properly instead of once.

Carried on `ModelSpec` and `ModelProfile` both, so a cold-loaded model
behaves like a resident one — two config files describing one model is
how #252 happened. `check-config-consistency.py` now gates on it: a
model that replays when resident and not when cold-loaded would make
the measurement unreadable.

`main` still ships Qwen's default. Nothing changes until an operator
sets it, which is the point.
2026-08-26 14:19:31 +03:00
grenade d3341cb45c Merge branch 'fix/client-capability-misattributions'
build-prerelease / Resolve version stamps + change detection (push) Successful in 13s
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Successful in 1m49s
build-prerelease / Package helexa-bench RPM (push) Successful in 23s
build-prerelease / Build cortex binary (push) Successful in 2m33s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m48s
build-prerelease / Build neuron-ampere (push) Successful in 3m0s
build-prerelease / Build neuron-ada (push) Successful in 3m0s
build-prerelease / Build helexa-upstream binary (push) Successful in 3m2s
build-prerelease / Test (push) Successful in 7m56s
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Successful in 1m20s
build-prerelease / Build neuron-blackwell (push) Successful in 15m46s
build-prerelease / Package helexa-router RPM (push) Successful in 20s
build-prerelease / Package cortex RPM (push) Successful in 26s
build-prerelease / Package helexa-upstream RPM (push) Successful in 25s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m5s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m8s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m56s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 32s
2026-08-26 14:10:56 +03:00
grenade cf911b9967 fix(reasoning): resolve the TP path's budget into a local too
CI / Config consistency (push) Successful in 7s
CI / Classify changes (push) Successful in 13s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 9s
CI / Clippy (push) Successful in 2m33s
CI / Test (push) Successful in 10m19s
CI / CUDA type-check (push) Successful in 18m59s
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 CUDA type-check caught what a CPU build cannot see:

    candle.rs:4835  reasoning_budget,
                    ^^^^^^^^^^^^^^^^ not found in this scope

`inference_tp_stream` is `#[cfg(feature = "cuda")]`, so the local build
compiled neither of its two uses. The engine-submit block is textually
identical in `inference_stream` and `inference_tp_stream`; rewriting
both to the shorthand while defining the local in only one left the TP
path referring to a name that exists solely in its sibling.

Resolved into a local there as well, so the governor and the batched
engine are handed the same value. They must not diverge — a request
that bounds differently depending on whether it was batched is a bug
that would only appear under concurrency.

This is the path beast actually serves on (TP=2), so the broken one was
the one that matters.
2026-08-26 13:50:39 +03:00
grenade a1b4584f30 fix(responses): a crashed inference must not report success
CI / CUDA type-check (push) Blocked by required conditions
CI / Config consistency (push) Successful in 7s
CI / Classify changes (push) Successful in 16s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 9s
CI / Clippy (push) Has been cancelled
CI / Test (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 projector defaulted a missing `Finish` to `FinishReason::Stop`:

    let reason = finish.unwrap_or(FinishReason::Stop);

A producer only fails to send `Finish` when it died — the poisoned-model
path the comment two lines above names, an OOM, a dropped worker. So a
crash was announced as `response.completed` / `status: "completed"`,
which pi-ai maps to `stopReason: "stop"`. The caller then treats
whatever partial text arrived as the model's considered answer and
moves on, instead of retrying a request that never ran.

`InferenceEvent` has no `Error` variant, so the dropped channel is the
only signal available; this reads it as what it is. A stream with no
`Finish` now terminates with `response.failed`, carrying
`status: "failed"` and an `error` object — the shape pi renders as
`code: message` and throws on, and the shape OpenAI defines for a
server-side failure.

Decided before the message item is opened, so a failed stream does not
leave an output item open that nothing will ever close.

`synthesises_start_frames_when_producer_skips_start` asserted
`COMPLETED` for exactly this case — the defect written down as an
expectation, passing all along. Replaced with
`a_producer_that_dies_terminates_the_stream_as_failed`, which keeps the
original intent (the stream must still terminate, or the client waits
out its idle timeout) and fixes the verdict.
2026-08-26 13:31:03 +03:00
grenade b403a51059 fix(responses): say why a response is incomplete
CI / Test (push) Blocked by required conditions
CI / CUDA type-check (push) Blocked by required conditions
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 8s
CI / Clippy (push) Successful in 2m32s
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
A truncated `/v1/responses` reply announced `status: "incomplete"` and
then said nothing about why — we never emitted `incomplete_details` at
all — and named its terminal frame `response.completed` regardless.

That is the difference between a client recovering and a client giving
up. pi-ai's `mapStopReason`:

    case "incomplete":
      return incompleteReason === "max_output_tokens"
        ? {stopReason: "length"}
        : {stopReason: "error", errorMessage: incompleteReason
            ? `Response incomplete: ${incompleteReason}`
            : "Response incomplete without a provider reason"};

and its agent loop, on those two outcomes:

    if (message.stopReason === "error" || message.stopReason === "aborted") {
        await emit({type: "turn_end", ...});
        await emit({type: "agent_end", ...});
        return;                                    // halts
    }
    ...
    message.stopReason === "length"
      ? await failToolCallsFromTruncatedMessage(toolCalls, emit)
      : await executeToolCalls(...)                // continues

So with the reason present a truncation is survivable: truncated tool
calls fail safely instead of running with half-parsed arguments, the
failures go back into the conversation, and the model gets another turn
to recover. Without it the agent stops dead. Observed live — a session
ended after one turn on "Response incomplete without a provider
reason", with a working answer 1,735 tokens in and nowhere to go.

Two shapes fixed:

- `incomplete_details: {"reason": "max_output_tokens"}` on both the
  streaming `response` payload and the non-streaming body, emitted only
  for a `Length` finish.
- Terminal SSE frame is `response.incomplete` when we stopped short.
  It was `response.completed` carrying `status: "incomplete"` — an
  inconsistency a client keyed on the event name reads as "no terminal
  frame", leaving it to time out.

`reasoning_only_stream_still_closes_cleanly` already asserted both
halves of that contradiction and passed; it now pins the corrected
shape. It is also the live failure in miniature — reasoning-only finish
against the output cap.

This does not stop us truncating; #223's reserve does that. It makes
the truncation legible when it happens, which is a separate obligation.
2026-08-26 13:26:48 +03:00
grenade 0bf40d6c1b fix(reasoning): reserve part of the caller's budget for the answer
CI / Config consistency (push) Successful in 8s
CI / Classify changes (push) Successful in 18s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 9s
CI / CUDA type-check (push) Failing after 20m27s
CI / Clippy (push) Successful in 2m37s
CI / Test (push) Successful in 7m19s
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
#290 removed the effort→ladder mapping, which was right: effort is a
prompt instruction the model is trained to follow, and making it also
set a guillotine is what told Qwen3.8-27B to reason at `xhigh` and cut
it off at `medium`'s 12,288 tokens.

But it left the governor reachable only through an explicit
`reasoning.max_tokens`, and `reasoning_budget`'s own module doc already
says why that is not enough:

    OpenAI-shaped clients cannot send a token count ... so the numbers
    have to live on the server.

So OpenAI-shaped callers went unbounded, and the failure #223 was
opened for came straight back — the same doc records it:

    given 16,384 tokens it used 16,384; given 32,768 it used 32,768;
    both times it was still opening new topics when the cap bit, and
    both times the caller received no answer at all.

Reproduced on a real agentic session: xhigh, 32,768-token budget, ~69
minutes, no visible output, `stopReason: error`.

The bound now comes from the caller's own output budget rather than
from effort: `max_new - answer_reserve_tokens` (default 4,096), used
only when no explicit count was sent. A reserve carries no double
meaning — effort still reaches the model through its template and still
decides how long it thinks, within a budget that keeps enough room to
answer. At the 32,768 pi sends, that leaves ~28.7k for reasoning,
above the ~27.2k a full agentic design turn was measured to use, so in
practice it binds only when the alternative was returning nothing.

Escape hatches both ways: an explicit `reasoning.max_tokens` still wins
in either direction, and `answer_reserve_tokens = 0` restores unbounded
reasoning. A budget at or below the reserve stays unbounded too —
reserving from it would emit a stub.

Three call sites, one of them `#[cfg(feature = "cuda")]`
(`inference_tp_stream`) and therefore invisible to a local CPU build —
which is the path beast actually serves on. Branch CI's CUDA
type-check is the gate for that one.
2026-08-26 12:50:38 +03:00
grenade f1533724f7 docs: stop crediting pi with discovery it does not do
CI / Test (push) Blocked by required conditions
CI / CUDA type-check (push) Blocked by required conditions
CI / Config consistency (push) Successful in 9s
CI / Classify changes (push) Successful in 16s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 11s
CI / Clippy (push) Successful in 2m31s
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
Four comments justified published fields by claiming pi-ai reads them.
It does not read any of them, and the misattribution was load-bearing:
it made a hand-set client window look like our advertisement failing to
land, when nothing was reading it at all.

Checked against pi 0.82.1 (source) and 0.84.3 (installed bundle):

- `context_window` appears only inside the string
  `model_context_window_exceeded`; `context_length`, `max_model_len`
  and `max_input_tokens` appear zero times. `contextWindow` and
  `maxTokens` are required non-optional fields on pi's `Model` type,
  so its window is hand-maintained whatever we advertise.
- The `fetchModels`/`refreshModels` overlay hook exists in
  `packages/ai/src/models.ts` but no shipped provider supplies it —
  only two test files do. There is no runtime model discovery.
- `getSupportedThinkingLevels` reads `model.thinkingLevelMap`, a static
  field in the operator's `models.json`, not our published rungs.

That last one is the instructive case. pi excludes `xhigh`/`max` unless
the map names them explicitly, so a pi user saw exactly
`minimal, low, medium, high` — the invented ladder #290 removed, to the
value — for a reason unrelated to what we published. Our wrong
advertisement and pi's default produced the same symptom, which made
the advertisement look causal.

Publishing these fields stays correct: they are true, and other clients
(vLLM tooling, Hermes Agent, dsh) do probe them. What changes is the
stated reason, so the next person does not infer a feedback loop that
is not there.

No behaviour change — comments only.
2026-08-26 12:45:39 +03:00
grenade 4a72d73c67 Merge branch 'fix/291-max-in-flight-and-config-only-ci'
build-prerelease / Resolve version stamps + change detection (push) Successful in 14s
build-prerelease / Lint (fmt + clippy) (push) Has been skipped
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Test (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex 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 / Build helexa-upstream binary (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 / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Package helexa-angels RPM (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 / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
2026-08-26 12:27:25 +03:00
grenade 43e873f889 fix(beast): two slots, not eight — and stop rebuilding Rust for a TOML edit
CI / Config consistency (push) Successful in 7s
CI / Classify changes (push) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 8s
CI / Clippy (push) Successful in 2m31s
CI / Test (push) Successful in 9m28s
CI / CUDA type-check (push) Successful in 28m3s
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
## max_in_flight = 2

`max_in_flight` is both the admission bound and the batch engine's slot
count, and the KV budget is divided by it at load. At 8 that gave each
slot 18,852 tokens of the 150,816 available — against a 131,072-token
advertised context, which needs 4 GB of KV per card on its own and 32 GB
across eight slots, on a 31 GB card already carrying ~21 GB of weights.
The advertised context and the slot count were incompatible by roughly
7x, and an agentic session ran in a ~19k window while seven idle slots
held reservations for callers who were not there.

Seven days of paired request spans on beast (start/done matched by
req_id; an unpaired count drifts, because 312 starts never log a done):

    in_flight   % busy   cumulative
        1        89.6%      89.6%
        2         4.0%      93.6%
      3-7         3.0%      96.6%
        8         2.5%      99.1%
        9         0.9%     100.0%

Utilisation 4.9% — 8.3 busy hours in a week. And most of the concurrency
that exists is helexa-bench's own synthetic `concurrency:2/4/8` bursts,
so real multi-user demand here is close to nil.

Two slots cover 93.6% of observed busy time and give each ~75k tokens.
The batch engine is still worth 1.79x at two (5.24x at eight is
throughput nobody is asking this host for), and anything above two
queues on admission with Retry-After rather than being refused.

This does not address the *speed* gap — 32 tok/s single-stream against
73.6 for llama.cpp on one 5090 and 120-124 for vLLM on a 3090. That is
quantisation and speculation (#282, #79, #289), and `max_in_flight` does
not touch it. Worth measuring after this lands whether the lockstep
engine also costs a single stream when seven slots sit empty.

## Config-only changes skip the Rust gate

ci.yml's change classifier treated fleet config as Rust-relevant, so a
one-line TOML edit spent ~25 minutes on fmt, clippy, test and a CUDA
type-check proving code it had not touched.

`models.toml`, `asset/neuron/<host>.toml` and
`asset/helexa-bench/<host>.toml` are read by a *running daemon* — never
by a build script, never `include_str!`ed — so they cannot change a Rust
result. Deploying one is an rsync plus a service restart; the daemon
re-reads it and reloads the model.

The narrowing keeps the classifier's one-directional property: only
these three exact shapes are inert, a mixed changeset still takes the
full gate, `neuron.example.toml` still does (it is packaged into the
RPM), and an unrecognised `asset/` path falls through to the full gate
rather than being silently skipped. Verified against both the cases that
should skip and the cases that must not.

The `config` job stays ungated and still validates these files, so this
removes the meritless work without removing the check that matters.

build-prerelease already reached the same conclusion on main — none of
its per-package path regexes match these files, so a config-only push
there already builds no binaries. This closes the same gap on branches.

Refs #291, #53
2026-08-26 11:40:03 +03:00
grenade 05e02b8895 Merge branch 'feat/290-reasoning-rungs-from-the-model'
build-prerelease / Resolve version stamps + change detection (push) Successful in 15s
build-prerelease / Build cortex binary (push) Successful in 2m10s
build-prerelease / Build helexa-bench binary (push) Successful in 2m29s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m33s
build-prerelease / Build neuron-ada (push) Successful in 2m47s
build-prerelease / Build neuron-ampere (push) Successful in 2m54s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m42s
build-prerelease / Test (push) Successful in 11m30s
build-prerelease / Build neuron-blackwell (push) Successful in 14m56s
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Successful in 25s
build-prerelease / Package helexa-upstream RPM (push) Successful in 25s
build-prerelease / Package cortex RPM (push) Successful in 30s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m6s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m17s
build-prerelease / Build helexa-router binary (push) Successful in 1m31s
build-prerelease / Package helexa-router RPM (push) Successful in 19s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m58s
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 34s
2026-08-26 10:09:20 +03:00
grenade 2c09f87105 fix(reasoning): take the effort rungs from the model, and actually tell it which one
CI / Config consistency (push) Successful in 7s
CI / Classify changes (push) Successful in 23s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / CUDA type-check (push) Successful in 19m8s
CI / Clippy (push) Successful in 2m42s
CI / Test (push) Successful in 6m35s
CI / Format (push) Successful in 9s
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
Qwen3.8-27B accepts exactly three reasoning efforts and raises on
anything else:

    {%- set resolved_reasoning_effort = reasoning_effort|default('xhigh') %}
    {%- if resolved_reasoning_effort not in ('xhigh', 'medium', 'low') %}
        {{- raise_exception('Unexpected reasoning effort ...') }}

We advertised `minimal`/`low`/`medium`/`high` — two values that template
rejects — and omitted `xhigh`, its own default and strongest rung. Then
we never passed the caller's choice to the template at all: it was
consumed to look up a token budget and thrown away, so every request
rendered at the template's default of `xhigh` while the governor
force-injected `</think>` at whatever the caller had actually asked for.

Measured on the tower-defence run: two turns hit `reasoning_tokens =
12288` exactly — `medium` on the invented ladder — and were cut off
mid-deliberation. The model was told to think carefully, validate
assumptions and consider alternatives, then guillotined at less than
half the ~27k that ladder's own doc comment records this workload
needing. The turn that ended the session diagnosed both bugs correctly
and stopped at "Let me view and fix them:", which is what a plan looks
like when the part that says how was cut off.

Three changes:

**Rungs come from the artifact.** `reasoning_effort::probe` renders the
model's own template once per candidate effort and keeps the ones that
survive; `raise_exception` was already wired to a render error, so the
template answers directly. A hardcoded table would drift the moment a
model ships a new template — the defect #280 fixed by fetching the
template instead of assuming it. Two ambiguities are handled explicitly:
a template that ignores the kwarg renders identically for every
candidate and must advertise nothing rather than appear to support all
six, and the default is whichever candidate matches a render with no
effort supplied. A test runs the probe against the real deployed
template, checked in as a fixture, and pins low/medium/xhigh + xhigh.

**The caller's rung reaches the model.** `apply_effort_kwarg` maps
`reasoning.effort` onto the nearest rung the model has and sets it as
`chat_template_kwargs.reasoning_effort`. Mapping descends before it
ascends — the opposite of pi-ai's `clampThinkingLevel` — because we are
the ones who know what exists, and over-delivering thinking costs the
caller latency and money. An explicit `chat_template_kwargs` from the
caller still wins; `none`/`off` is untouched, being a switch rather than
a rung.

**Effort stops being a token budget.** `requested_reasoning_budget` now
honours only an explicit `reasoning.max_tokens`. #223's concern — a
runaway think block eating the output budget — is real, so the governor
stays, but as a backstop a caller asks for rather than the mechanism by
which effort is selected. Enforcing a prompt-level control by truncation
was the defect.

`ReasoningBudgetRung.tokens` becomes optional and gains `default`, so
`/models` publishes what the model accepts and which rung applies when
the caller is silent. That matters more than it sounds: clients pick
from what we publish — pi-ai offers a level only when the model declares
it — so an invented rung makes a real one unreachable, and on this model
that meant its default was unselectable through the client entirely.

Refs #290, #223, #274, #280
2026-08-26 09:14:49 +03:00
grenade 8fc0ef8fc6 Merge branch 'feat/bench-measurement-regimes'
build-prerelease / Resolve version stamps + change detection (push) Successful in 13s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m40s
build-prerelease / Build cortex 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 / 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 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 helexa-bench binary (push) Successful in 1m49s
build-prerelease / Build neuron-ampere (push) Successful in 3m1s
build-prerelease / Build neuron-ada (push) Successful in 3m0s
build-prerelease / Test (push) Successful in 8m42s
build-prerelease / Build neuron-blackwell (push) Successful in 14m53s
build-prerelease / Package helexa-bench RPM (push) Successful in 25s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m4s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m6s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m59s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 30s
2026-08-26 00:18:32 +03:00
grenade 98b43919c0 Merge branch 'fix/284-gdn-state-f32' 2026-08-26 00:18:32 +03:00
grenade 8540cc8ef9 fix(gdn): stop round-tripping the recurrent state through bf16
CI / Config consistency (push) Successful in 8s
CI / Classify changes (push) Successful in 17s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m35s
CI / Test (push) Successful in 9m59s
CI / CUDA type-check (push) Successful in 19m28s
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 Gated DeltaNet delta rule computes in f32 end to end, and
`state_init` upcasts to f32 on entry — but `forward` stored the result
at the model's activation dtype, which is BF16 on CUDA. So the entire
recurrent memory of 48 of this arch's 64 layers was being quantised to
~3 significant decimal digits at every call boundary, then upcast again
next call. Pure round-trip loss, buying only memory.

Measured on the chunked path (CPU, no GPU needed), replaying a sequence
in N pieces with the state carried exactly as `forward` carries it:

  carry   round trips   relative error
  f32     16 (prefill)  0        (bit-exact)
  bf16    16 (prefill)  1.3e-3
  f32     4096 (decode) 4.8e-7
  bf16    4096 (decode) 1.0e-2

Decode is the larger half by far: it round-trips once per *token*, and
the error saturates near 1% by token 256 — about 20,000x the f32 path.
Long-memory decay, the regime long-context retrieval depends on, is
roughly 2x worse than fast decay.

The same measurement settles a second question: carrying f32, an N-chunk
run reproduces a single call bit for bit at every chunk size. The
chunk-boundary math is exact, so "chunked prefill corrupts the recurrent
state" (#283 H1) is disproven — the dtype was the whole story.

Behind `NEURON_GDN_STATE_F32`, default **off**. Every #283 measurement
was taken against the bf16 baseline; flipping this silently would move
the ground underneath the sampling work and the decode-rate
investigations (#282, #285) at once. Turn it on deliberately, measure,
then decide the default. Costs ~2x the recurrent slab: +37.7 MB per GPU
per sequence on the deployed 27B under TP=2, so +302 MB/GPU at
max_in_flight = 8.

Adds the regression test whose absence let this live. All four existing
parity tests are single-call and all-f32, so none of them crosses the
boundary where the store-at-model-dtype happens. The new one asserts
both directions: f32 carry is bit-exact, and the bf16 carry is
measurably lossy — so if a future change makes bf16 lossless the test
fails loudly and the flag can be retired, rather than the flag quietly
outliving its reason.

Storing f32 keeps the kernel's own buffer rather than allocating afresh,
so the stored state now aliases what the CUDA decode kernel updated in
place. Sound, and commented: the pre-update value is dead by then, and
prefix snapshots deep-copy precisely so they never share storage with a
buffer a later forward overwrites.

Refs #284, #283, #282, #285, #23
2026-08-25 23:54:32 +03:00
grenade d4629f253a fix(bench): a burst must report its phase timing
CI / Config consistency (push) Successful in 8s
CI / Classify changes (push) Successful in 17s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m30s
CI / Test (push) Successful in 9m35s
CI / CUDA type-check (push) Successful in 19m52s
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
`ConcurrencyScenario` hardcoded `prefill_ms`, `decode_ms` and
`prefill_tokens` to `None`, so every concurrency cell carried no
server-side phase timing at all — confirmed in the live DB, where all
four `concurrency:*` cells on beast show zero rows with timing while
every single-request cell has it on all five.

Two consequences, both in the cells that need them most:

  - `queue_wait_ms` is defined as TTFT minus server-measured prefill
    (#85). Without prefill_ms it cannot be computed for a burst, which
    is the only place it means anything — a single request never queues.
    The number that separates "the server is slow" from "you were queued
    behind someone" was missing from every concurrent measurement.
  - the prefill-tok/s panel is blank under concurrency.

Median rather than sum: every stream prefills its own copy of the same
prompt, so the representative per-stream cost is the meaningful figure,
and it composes with `ttft_s`, which is also a median.

Found while checking whether #285 had shipped `helexa_timing` on
non-streaming replies. It had — a direct probe returns
`{prefill_ms: 93, decode_ms: 33, prefill_tokens: 19}` — but the bench
row looked empty, because the burst aggregate was discarding it. Worth
recording that the instrument, not the server, was the thing under
suspicion for the second time today.

Partly self-inflicted: the same aggregate learned to sum
`reasoning_tokens` and `cached_tokens` two commits ago while these were
left null, so the asymmetry was introduced rather than merely inherited.
2026-08-25 23:43:05 +03:00
grenade 9fa3fceabc feat(bench): mark the builds where a metric changed meaning
A trend line spans measurement-regime changes silently, and every reader
re-derives the same conclusion at their own expense. Three have already
cost real investigation time this week:

  - beast's concurrency:8 fell 168.9 -> 104.6 tok/s with ttft_p95 rising
    0.53 -> 11.77 s. It read as a 38% throughput regression for as long
    as it took to bisect to the one commit in the window, which turned
    out to be an admission *policy* change that bench, being anonymous,
    was subject to (#262/#288).
  - quadbrat's capability:debug-reason TTFT fell 88.8 -> 0.1 s, which
    reads as a latency win and is a redefinition of when the clock stops.
  - the same redefinition had put decode rates at ~250 tok/s on a 3060,
    by dividing reasoning-inclusive token counts by a
    visible-content-only window.

So the boundaries are declared rather than remembered. `regime.rs` holds
them, `GET /api/regimes` serves them, and each chart draws a dashed rule
on the panels the change actually affects — marking every panel would
train people to ignore the rules. A footnote under the charts gives the
reason for each rule drawn, because a label is a name and the name is
what sent people digging in the first place.

Declared in Rust rather than the frontend because `report` renders the
same series into benchmarks.md and needs the same caveats.

Only *semantic* changes belong. A change that made the server genuinely
faster, slower or differently behaved stays unmarked — that is the
signal these lines exist to keep readable. #280 moved quadbrat's
completion_tokens 4096 -> 1053 by ending a truncation loop, which is a
real behavioural change and deliberately carries no rule; a test asserts
it stays that way.

The identified/anonymous split is derived from `runs.principal` rather
than declared, so it cannot go stale when the principal is
reconfigured — the constant would be a second source of truth for
something the rows already know.

Three tests: every declared boundary names at least one series key and
only known keys (a typo would draw nowhere and look fine), the SHAs are
short-form so they can match a series point at all, and behavioural
changes stay unmarked.

Refs #288, #262, #117, #280
2026-08-25 23:40:43 +03:00
grenade e44e095f60 Merge branch 'feat/285-nonstreaming-through-engine'
build-prerelease / Resolve version stamps + change detection (push) Successful in 18s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m40s
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 / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (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 / Build helexa-router 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 / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Test (push) Successful in 9m53s
build-prerelease / Build neuron-ampere (push) Successful in 2m58s
build-prerelease / Build neuron-ada (push) Successful in 3m2s
build-prerelease / Build neuron-blackwell (push) Successful in 15m4s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m4s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m7s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m9s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 26s
2026-08-25 20:33:48 +03:00
grenade 45e03893f1 perf(inference): run non-streaming through the batch engine
CI / Config consistency (push) Successful in 6s
CI / Classify changes (push) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m35s
CI / Test (push) Successful in 9m3s
CI / CUDA type-check (push) Successful in 19m19s
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
Non-streaming chat completion was a second, independent inference path
that took the per-model `inference_lock` for the whole request. That
made it invisible to the batch engine (#98): concurrent non-streaming
callers serialized completely.

Measured on beast (TP=2, q6k, `ae994e3`, idle box, identical prompt and
400-token output at every level):

  concurrency   non-streaming        streaming
  1             32.1 tok/s  1.00x    32.0 tok/s  1.00x
  2             32.2 tok/s  1.00x    57.1 tok/s  1.79x
  4             32.1 tok/s  1.00x   105.9 tok/s  3.31x
  8             32.0 tok/s  1.00x   127.1 tok/s  3.98x

Wall time for the non-streaming arm scaled 24.9 -> 49.7 -> 99.7 ->
199.9 s: exactly linear, no coalescing whatever. `max_in_flight = 8`
bought those requests admission and nothing else. Worse, it was
invisible — the neuron reports `in_flight: 8`, admission accepts,
`/health` looks healthy, and throughput is single-stream. Nothing
distinguished it from a legitimately busy node.

The engine's doc-comment said vision and non-streaming "still serialize
safely against the batch", which reads as a chosen invariant. It was
worth re-examining rather than inheriting: nothing about buffering a
result requires bypassing the producer that makes it.

So `chat_completion` now runs `inference_stream` — the same producer the
streaming path uses — and buffers the events into a `ChatCompletionResponse`
via `wire::openai_chat::collect_chat_completion`. Both shapes now share
one producer, so batching, prefix-cache reuse, tool-call extraction, the
reasoning governor and server-measured timing apply identically.

Three things fall out for free:

  - `usage.prompt_tokens_details.cached_tokens` and `usage.helexa_timing`
    now populate on non-streaming. The old path hardcoded both to `None`
    with a comment that timing was "only surfaced on the streaming
    Finish event today (#85)"; the events carried them all along.
  - Non-streaming tool calls now go through the same incremental
    extractor as streaming, closing the known-unhandled non-streaming
    tool-call gap on the Qwen3.6/3.8 XML form.
  - The duplicate path is gone: -1,272 lines against +165. Deleted
    `chat_completion_tp`, `chat_completion_tp_inner`, `run_inference`,
    `split_off_reasoning`, `extract_tool_calls_from_text` and
    `debug_poison_armed`, all reachable only from the path this
    replaces. `parse_tool_call_body` — the actual parser, shared with
    the five streaming call sites — keeps its coverage; only the batch
    framing wrappers and their tests go.

Behaviour deliberately preserved: reasoning text is still dropped from
`content` (counted in `completion_tokens_details.reasoning_tokens`,
never rendered), and `finish_reason` still promotes to `"tool_calls"`
whenever a call was parsed. Both pinned by new tests, along with the
cached-tokens/timing surfacing and the case that matters most — a
stream ending without a `Finish` is an error, not an empty 200, because
that is how a dropped engine slot presents.

`use tracing::Instrument` is now `#[cfg(feature = "cuda")]`: both
remaining `.instrument()` call sites are inside cuda-gated blocks, so an
ungated import warns on the CPU build while an absent one breaks the
CUDA build that only branch CI compiles.

Not yet validated live — the sweep above must be re-run against this
build to confirm the non-streaming column now tracks the streaming one.

Refs #285, #98, #85, #269
2026-08-25 19:32:58 +03:00
grenade 7c488b976b Merge branch 'feat/283-operator-sampling-override'
build-prerelease / Resolve version stamps + change detection (push) Successful in 15s
build-prerelease / Build neuron-ampere (push) Successful in 2m40s
build-prerelease / Build neuron-ada (push) Successful in 2m29s
build-prerelease / Build helexa-bench binary (push) Successful in 2m12s
build-prerelease / Build cortex binary (push) Successful in 2m33s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m55s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m16s
build-prerelease / Build helexa-angels binary (push) Successful in 3m47s
build-prerelease / Test (push) Successful in 7m22s
build-prerelease / Package helexa-bench RPM (push) Successful in 24s
build-prerelease / Package helexa-upstream RPM (push) Successful in 22s
build-prerelease / Package cortex RPM (push) Successful in 24s
build-prerelease / Package helexa-angels RPM (push) Successful in 27s
build-prerelease / Build helexa-router binary (push) Successful in 1m26s
build-prerelease / Build helexa-tools binary (push) Successful in 1m34s
build-prerelease / Package helexa-router RPM (push) Successful in 19s
build-prerelease / Package helexa-tools RPM (push) Successful in 18s
build-prerelease / Build neuron-blackwell (push) Successful in 14m29s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m7s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m11s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m6s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 29s
2026-08-25 14:57:46 +03:00
grenade f16a6fdade Merge branch 'fix/286-clippy-all-targets' 2026-08-25 14:57:46 +03:00
grenade 46e4e3b39b Merge branch 'fix/288-bench-authenticates'
build-prerelease / Resolve version stamps + change detection (push) Successful in 13s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (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 / 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 helexa-router binary (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Successful in 1m58s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m44s
build-prerelease / Test (push) Successful in 9m35s
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Successful in 21s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 15s
2026-08-25 14:32:33 +03:00
grenade b0da2d35d5 fix(bench): one identity per stream, or fair-share caps the burst at 2
CI / Classify changes (push) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m25s
CI / Test (push) Successful in 8m34s
CI / CUDA type-check (push) Successful in 19m18s
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
Authenticating bench (#288) fixed the anonymous-yield problem and
introduced a worse one. Fair-share (#54) bounds a single principal to
`max_per_principal` requests in-flight-or-queued; it defaults to 2 and
beast does not override it. Eight streams under one key would have had
six rejected with `PrincipalCap` — so `concurrency:8` would have
measured the fair-share cap instead of the server, and reported it as
backpressure, which reads as a healthy node shedding load rather than an
instrument measuring itself.

Each stream in a burst now presents its own key (`<key_id>-<n>`).

That is also the more faithful model. A concurrency-8 cell is meant to
stand in for eight simultaneous *callers*, not one caller issuing eight
requests, and eight callers is what the router and admission would see in
production. Fair-share is still exercised — every identity is subject to
it — just not self-inflicted by the measurement.

The base identity stays a default header on the client so every scenario
is identified without having to remember; the burst overrides only the
key id per request. Single-request scenarios add no override at all,
since a per-request copy would be a second place for the value to drift
from the client's.

Two tests pin it: a burst yields distinct per-stream keys, and a
single-request scenario adds none.

Worth recording for whoever sets `[bench.principal]`: the account/key
pair needs no provisioning anywhere. `principal_key` in neuron
concatenates the two headers into an opaque fair-share key with no
validation and no allowlist, and bench talks directly to a neuron, so
cortex's entitlements never see it. What the pair *does* need is to be
distinct from any real caller's, so bench holds its own allocation
rather than eating someone else's.

Refs #288, #54, #262
2026-08-25 14:03:24 +03:00
grenade 19243d317e feat(bench): collect and chart what a decode-rate line cannot show
CI / Test (push) Blocked by required conditions
CI / CUDA type-check (push) Blocked by required conditions
CI / Classify changes (push) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 8s
CI / Clippy (push) Successful in 2m50s
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
Two metrics on the dashboard could not explain any of the last week's
behaviour, so this adds the ones that can — and charts the several that
were already being recorded and thrown away at the UI.

## The chart was hiding the regression

The TTFT panel plots `ttft_s`, which for a concurrency burst is the
*median* across streams. It sat flat at ~0.3 s straight through the
build where p95 went 0.53 s → 11.77 s (#288). With 7 of 8 seats admitted
and one parked at the class gate, the median never moves. Median and p95
now draw on one panel, which makes the divergence the point rather than
something you have to know to go looking for.

## Now collected

- `tpot_p95_ms` — p95 inter-token arrival gap. `decode_tps` is a mean
  over the decode window, so a stream that stalls for a second and
  catches up is indistinguishable from one that never stalled. This is
  what a user feels and where a batching stall shows.
- `reasoning_tokens` (#223) — the dominant cost driver on a reasoning
  model, and independent of every rate on the dashboard: a template,
  sampling (#283) or budget change can double what the model thinks
  before answering with no speed chart moving.
- `cached_tokens` (#269) — why prefill timing varies between otherwise
  identical samples. #269 exists because the saving was invisible;
  bench then discarded it.

For a burst, token counts sum like `completion_tokens`, while the
inter-token tail takes the **worst** stream rather than a median of
medians — under load the question is how bad it got for somebody, and
averaging tails is how a stall stays hidden.

## Now charted (all already in the DB)

`ttft_p95_s`, `queue_wait_ms`, `rejected`, and prefill tok/s — the last
derived in the query from `prefill_tokens / prefill_ms` rather than
stored, since both inputs are already recorded and a stored rate would
be a third thing to keep consistent.

Panels are a shared component over one x-axis so a change can be lined
up across metrics, and a panel with no data for the selection hides
itself instead of drawing an empty box.

## The series says who measured it

`SeriesPoint.principal` carries the identity a build's samples were taken
under, and the page warns when a selection mixes anonymous with
identified points — those are not comparable once #262 is in the build,
and a step change across that boundary is the instrument rather than the
engine. Better to say so on the chart than to have it rediscovered.

Refs #288, #285, #269, #223, #262, #89, #85
2026-08-25 13:55:46 +03:00
grenade 16d473227d fix(bench): authenticate, and collect what was being thrown away
CI / Test (push) Blocked by required conditions
CI / CUDA type-check (push) Blocked by required conditions
CI / Classify changes (push) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m24s
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
Three problems, one instrument.

## Bench was anonymous, so it stopped measuring capacity (#288)

helexa-bench sends no identity headers. Since #262 — "serve anonymous
callers from leftover capacity" — anonymous callers are capped below
`max_in_flight` and park at the class gate ahead of every other gate.
Bench is exactly the caller that change exists to hold back.

The day #262 landed, beast's `concurrency:8` moved from 168.9 tok/s /
0.53 s ttft_p95 to 104.6 / 11.77 s, and held there for every build
since. Same model, same max_tokens, same prompt size — so for a week the
fleet's primary performance instrument has been characterising the yield
policy rather than the capacity authenticated users actually get, and
`helexa-bench report` regenerates benchmarks.md from it.

Bench now stamps `x-helexa-account-id` / `x-helexa-key-id` from
`[bench.principal]`, set on the reqwest client rather than at each call
site so a scenario added later cannot silently measure as anonymous —
which is how this went unnoticed. A dedicated pair, not a borrowed one,
so bench holds its own fair-share allocation (#54) and cannot starve
interactive traffic: the failure #262 was fixing.

Deliberately not an admission exemption. That would restore the old
numbers by measuring something no user experiences — the same class of
error in the other direction.

`runs.principal` records the identity each sample was taken under, so
the 2026-08-18 discontinuity is data rather than folklore and a reader
can tell which rows are comparable.

## Non-streaming was unmeasurable (#285)

`"stream": true` was hardcoded in both scenario payloads, so the path
that serialized completely — 1.00x aggregate throughput at every
concurrency level against 3.98x streamed — could not be seen at all.
Adds a non-streaming burst arm under its own cell id
(`concurrency:N:nostream`), defaulting to the top level where the signal
is starkest. Separate ids on purpose: averaged together, a 1.00x and a
3.98x would have hidden both.

## Two wire fields were being discarded

`reasoning_tokens` (#223) and `cached_tokens` (#269) are emitted by
neuron on both shapes and bench parsed neither.

Reasoning tokens are the dominant cost driver on a reasoning model and
move independently of every tok/s number on the dashboard: a template,
sampling (#283) or reasoning-budget change can double what the model
thinks before answering while every speed chart stays flat. Cached
tokens are why prefill timing varies between otherwise identical
samples — and #269 exists precisely because that saving was invisible.

Both are summed across a burst like `completion_tokens`, with `None`
preserved as distinct from zero.

Refs #288, #285, #262, #269, #223, #54
2026-08-25 13:48:43 +03:00
grenade d7b407cca6 feat(deploy): make CI the only writer of fleet config
CI / Config consistency (push) Successful in 14s
CI / Classify changes (push) Successful in 20s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 10s
CI / CUDA type-check (push) Successful in 21m46s
CI / Clippy (push) Successful in 2m25s
CI / Test (push) Successful in 10m51s
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
Operator decision, 2026-08-25: the automated deployment path is the only
path, and manual config sync is a footgun to be removed rather than left
loaded.

Verifying the new sync against the live fleet proved the point before
the code even shipped. `asset/neuron/{benjy,quadbrat}.toml` were still
carrying pre-#197 configs on the hosts — the tracked assets gained
`[harness.candle.image]` when text-to-image landed and nobody re-ran the
sync script, so the repo and the fleet had disagreed for weeks. benjy's
drift was harmless (the block's values equal the defaults); quadbrat's
was not, in principle: its 768 ceiling exists because larger OOMs the
3060 and an image OOM poisons the device worker context. Latent rather
than live, since the catalogue's min_device_vram_mb = 20000 keeps the
image model off a 12 GB card — but a request sent straight to that
neuron would have found it.

So the three syncs move out of script/infra-setup.sh and into
deploy.yml, which already ships two of them after the previous commit;
this adds the bench host, gated and restart-triggering like the others.
The sudoers grants for all three rsync targets already existed and had
simply never been used.

The tracked configs gain a MANAGED BY CI banner, because the failure
mode is someone opening the deployed copy and reasonably assuming it is
editable.

What is deliberately NOT closed, and why, recorded in the script so the
next person does not have to rediscover it:

  * cortex.toml, helexa-router.toml, helexa-upstream.toml and
    helexa-angels.toml carry API keys, JWT secrets, SMTP passwords and
    database URLs. They cannot be tracked for CI to read; closing them
    needs the secrets to come from Gitea secrets and the file rendered
    at deploy time.
  * asset/searxng/* IS tracked and could move today but for sequencing:
    asset/sudoers.d/gallumbits-host.conf grants gitea_ci no rsync at
    all. Grants must reach the host before the deploy step exists, or
    every deploy in between fails.

Both are real gaps and neither is fixed here. The script now says so at
the point where someone would otherwise be tempted to add a sync back.

Refs #283, #252, #197
2026-08-25 12:47:56 +03:00
grenade 902f5460bf fix(deploy): default the catalogue-changed flag when the step emits nothing
Under `set -eu`, an empty step output makes `[ "" -eq 0 ]` an
arithmetic error and aborts the deploy on something unrelated to the
deploy. The neuron job already defaulted its equivalent; match it.
2026-08-25 12:47:56 +03:00
grenade 821ca116aa feat(sampling): let the operator correct a model's published temperature
A model's `generation_config.json` is its authors' statement of intent,
and honouring it (#272) is right by default. It is not always right for
the workload being served: `Qwen/Qwen3.8-27B` publishes
`temperature = 1.0`, which measured a 20% structural-defect rate on
~2k-token code generation against 0/60 at `<= 0.6` — Fisher exact
one-sided p = 0.0031, n = 20 per arm, truncation excluded by
`finish_reason` (#283).

Extrapolated at constant hazard that is a ~79% chance of at least one
defect in a 15k-token file, which is why the model had never once
finished a task two cloud models did in ten minutes. The failures are
structural, not semantic — an `else` with no `if`, a duplicate `export
function` — the sampling-tail signature, not a capability gap.

Adds `SamplingOverride { temperature, top_p, top_k }` to `ModelSpec`
(which doubles as the `[[default_models]]` shape in neuron.toml) and to
`ModelProfile` (models.toml), so the override travels with the *model*
rather than the request. neuron is the only place sampling is resolved,
so it has to reach there whether the load came from cortex's catalogue
or the host's own config.

Precedence is request > operator > model > fallback. The operator sits
below the request deliberately: an explicit API parameter that is
silently ignored is a contract break, and the observed failure came from
callers naming nothing, so a default-tier override is sufficient.

Mechanically the operator tier is folded into the model tier once at
load by `with_operator_override`, so the per-request path stays a
two-tier lookup and none of the five resolve sites can forget it — the
same reasoning that made #272 resolve sampling once instead of at eight
`LogitsProcessor` construction sites. Fields overlay independently, so
correcting `temperature` does not blank `top_p`/`top_k` back to "no
truncation" (the #271 failure mode).

Deliberately not a heuristic. neuron will never inspect a model and
guess a better temperature; a guesser is a footgun, a number in a config
file is not.

Two files now describe one model, which is exactly the #252 hazard, so
the gate lands with the feature rather than after the next incident:

- `script/check-config-consistency.py` fails when models.toml and
  asset/neuron/<host>.toml disagree on `quant` or `sampling` for a model
  they both name. It catches #252 retroactively.
- CI gains a `config` job, ungated by the change classifier — a
  TOML-only push is neither `rust` nor `web`, and that is precisely the
  push that introduces drift.
- deploy.yml gains a `config-check` gate ahead of both rollouts, and now
  actually *ships* the configs: models.toml to the gateway and
  asset/neuron/<host>.toml to each neuron, digest-compared, with a
  change restarting the service even when no new RPM landed. The sudoers
  grants for both rsync targets already existed and were simply unused.

models.toml is untracked no longer. It carries no secrets — cortex.toml
holds the API keys — and CI cannot gate a file it cannot see, which is
how one half of the #252 pair stayed invisible.

Refs #283, #272, #271, #252
2026-08-25 12:47:56 +03:00
grenade 03fe47e91b fix(ci): lint test code too, and revive two tests that stopped running
CI / Classify changes (push) Successful in 22s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 11s
CI / Clippy (push) Successful in 2m59s
CI / CUDA type-check (push) Successful in 20m28s
CI / Test (push) Successful in 12m58s
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
`cargo clippy --workspace -- -D warnings` lints lib and bin targets
only. Without `--all-targets` it never sees `#[cfg(test)]` modules,
`tests/`, `benches/` or `examples/` — so despite `-D warnings`, no
warning in test code has ever gated a merge.

That was not hypothetical. One bad edit in candle.rs had displaced a doc
comment and a `#[test]` attribute onto the following function, causing
three separate faults that CI could not see:

  - `kv_budget_prices_a_long_context_session` lost its `#[test]` and
    stopped running;
  - `validate_request_cap_and_fit` lost its `#[test]` and stopped
    running, and inherited a doc comment describing a different test;
  - `saturated_model_with_a_budget_is_still_servable` ended up with two
    `#[test]` attributes and was registered — and run — *twice*.

Both revived tests pass, so no product defect was hiding behind them.
They cover KV-budget pricing and request cap/fit validation, which is
the admission path from #257, so they are not tests worth losing.

Reattaching the attributes and dropping the duplicate is a net +1
registered test: +2 revived, -1 that had been counted twice.

The remaining backlog was 19 warnings, far smaller than feared. Fixed
rather than suppressed, except one: `figment::Jail::expect_with` dictates
a closure returning `figment::Error`, 208 bytes and not ours to shrink,
so `result_large_err` gets a scoped `#[allow]` with the reason written
down. The rest are three `needless_range_loop`s in the snapshot batch
tests (now `iter().enumerate()`, bounds verified equal), a doc comment
whose wrapped `+` read as a markdown bullet, and clippy's own autofixes.

`build-prerelease.yml` carried the same omission and is fixed with it —
that is the gate `main` relies on, since ci.yml deliberately skips main.
CLAUDE.md's documented triad updated to match, so the local command and
the CI command stay the same command.

Refs #286, #257
2026-08-25 12:12:31 +03:00
grenade ae994e3ca5 Merge branch 'fix/developer-role-maps-to-system'
build-prerelease / Resolve version stamps + change detection (push) Successful in 19s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m33s
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 / 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-tools binary (push) Has been skipped
build-prerelease / Build helexa-router 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-ampere (push) Successful in 2m28s
build-prerelease / Build neuron-ada (push) Successful in 2m33s
build-prerelease / Test (push) Successful in 9m18s
build-prerelease / Build neuron-blackwell (push) Successful in 21m55s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m4s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m10s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m10s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 29s
2026-08-24 22:46:16 +03:00
55 changed files with 4188 additions and 1594 deletions
+1 -1
View File
@@ -262,7 +262,7 @@ jobs:
# fast instead of triggering a slower uncached rebuild; only a real
# sccache fault drops the cache. See script/ci-cargo-escalate.sh.
- name: Clippy (sccache escalation)
run: script/ci-cargo-escalate.sh cargo clippy --workspace -- -D warnings
run: script/ci-cargo-escalate.sh cargo clippy --workspace --all-targets -- -D warnings
test:
name: Test
+51 -5
View File
@@ -101,14 +101,34 @@ jobs:
echo "changed files:"
printf '%s\n' "${files}" | sed 's/^/ /'
# Inert for Rust: the SPA, and documentation. Markdown is
# matched only at the repo root and under doc/ — a .md inside
# crates/ is left to fall through, since `include_str!` could
# make one load-bearing without this file knowing.
# Inert for Rust: the SPA, documentation, and fleet config.
# Markdown is matched only at the repo root and under doc/ — a
# .md inside crates/ is left to fall through, since
# `include_str!` could make one load-bearing without this file
# knowing.
#
# Fleet config (models.toml, asset/neuron/<host>.toml,
# asset/helexa-bench/<host>.toml) is read by a *running*
# daemon, never by a build script or an `include_str!`, so it
# cannot change a Rust result. Deploying one is a config rsync
# plus a service restart — neuron re-reads it and reloads the
# model — so spending ~25 minutes on fmt, clippy, test and a
# CUDA type-check to prove a one-line TOML edit compiles is
# pure latency (#291).
#
# The `config` job below stays ungated and still validates
# these files, so narrowing here removes the meritless work
# without removing the check that matters. build-prerelease
# already reaches the same conclusion on main: none of its
# per-package path regexes match these files, so a config-only
# push builds no binaries.
rust_relevant="$(printf '%s\n' "${files}" \
| grep -v '^helexa\.ai/' \
| grep -v '^doc/' \
| grep -vE '^[^/]+\.md$' \
| grep -vE '^asset/neuron/[^/]+\.toml$' \
| grep -vE '^asset/helexa-bench/[^/]+\.toml$' \
| grep -vE '^models\.toml$' \
|| true)"
if [ -n "${rust_relevant}" ]; then
echo "rust=true" >> "$GITHUB_OUTPUT"
@@ -137,6 +157,27 @@ jobs:
- uses: actions/checkout@v4
- run: cargo fmt --check --all
# models.toml and asset/neuron/<host>.toml both describe how a model is
# served, and until #283 nothing forced them to agree. #252 is the bill
# for that: models.toml omitted `quant`, so a cortex cold-load served
# bf16 and could not prefill, while the same model was fine when the
# host loaded it from its own config. The sampling override (#283) is a
# second field with the same hazard, so the gate lands with it.
#
# Deliberately ungated by the `changes` classifier: it is a
# sub-second Python run with no build, and the whole point is that it
# cannot be skipped on the push that introduces the drift. A config
# change is neither `rust` nor `web`, so gating it on either would let
# a pure-TOML push through unchecked — precisely the push that breaks
# this.
config:
name: Config consistency
timeout-minutes: 5
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
- run: python3 script/check-config-consistency.py
web:
name: Web (lint + typecheck + i18n + build)
timeout-minutes: 20
@@ -179,8 +220,13 @@ jobs:
# in sync with build-prerelease.yml): a signal death (rustc SIGSEGV
# / OOM-kill) keeps the cache and fails fast instead of an uncached
# rebuild; only a real sccache fault drops the cache.
# --all-targets so `#[cfg(test)]` modules, tests/, benches/ and
# examples/ are linted too (#286). Without it clippy sees only lib
# and bin targets, so `-D warnings` never gated a single line of
# test code — which is how two tests lost their `#[test]` attribute
# and stopped running unnoticed.
- name: Clippy (sccache escalation)
run: script/ci-cargo-escalate.sh cargo clippy --workspace -- -D warnings
run: script/ci-cargo-escalate.sh cargo clippy --workspace --all-targets -- -D warnings
test:
name: Test
+165 -18
View File
@@ -37,7 +37,31 @@ env:
${{ secrets.RSYNC_SSH_KEY }}
jobs:
# Gate the whole rollout on the two config files agreeing about every
# model they both describe. models.toml (cortex, cold loads) and
# asset/neuron/<host>.toml (resident models) each say how a model is
# served — quant, and since #283 the sampling override. When they
# disagree the model is served one way cold and another way resident,
# which is #252.
#
# Deliberately a *gate*, not a step inside deploy-cortex: an
# inconsistent pair must stop the neuron rollout too, and stopping it
# before anything restarts is much cheaper than after (a beast restart
# costs ~5-6 min of 27B cold-load).
config-check:
name: Config consistency
runs-on: fedora-43
if: >-
${{
github.event_name == 'workflow_dispatch'
|| github.event.workflow_run.conclusion == 'success'
}}
steps:
- uses: actions/checkout@v4
- run: python3 script/check-config-consistency.py
deploy-cortex:
needs: [config-check]
runs-on: fedora-43
# Two trigger paths: manual dispatch always runs; workflow_run
# only runs if the upstream `build-prerelease` actually succeeded.
@@ -47,6 +71,7 @@ jobs:
|| github.event.workflow_run.conclusion == 'success'
}}
steps:
- uses: actions/checkout@v4
- name: SSH init
run: |
mkdir -p ~/.ssh
@@ -65,6 +90,33 @@ jobs:
echo "inv=$(ssh gitea_ci@hanzalova.internal \
'systemctl show -P InvocationID cortex.service' || true)" >> "$GITHUB_OUTPUT"
# The catalogue is tracked (#283) precisely so it can be rolled out
# here rather than hand-copied. Sync it *before* the package step,
# so the restart that step performs is the one that picks it up —
# and report whether it changed, because a catalogue edit with no
# new RPM must still restart cortex or the change never takes
# effect.
#
# Compared by digest rather than rsync's own change detection so the
# answer is available to the shell that decides on the restart.
- name: Sync gateway catalogue (models.toml)
id: catalogue
run: |
local_sum=$(sha256sum models.toml | cut -d' ' -f1)
remote_sum=$(ssh gitea_ci@hanzalova.internal \
'sha256sum /etc/cortex/models.toml 2>/dev/null | cut -d" " -f1' || true)
if [ "${local_sum}" = "${remote_sum}" ]; then
echo "changed=0" >> "$GITHUB_OUTPUT"
echo "models.toml unchanged (${local_sum})"
else
rsync --archive --compress \
--chown root:root --chmod 0644 \
--rsync-path 'sudo rsync' \
models.toml gitea_ci@hanzalova.internal:/etc/cortex/models.toml
echo "changed=1" >> "$GITHUB_OUTPUT"
echo "models.toml updated: ${remote_sum:-<absent>} -> ${local_sum}"
fi
# Gating compares `rpm -q` against the packages.json manifest the
# publish job maintains — NOT unprivileged `dnf check-update`,
# which proved unreliable as the gitea_ci user (hung on metadata
@@ -75,6 +127,11 @@ jobs:
ssh gitea_ci@hanzalova.internal 'bash -s' <<'DEPLOY'
set -eu
pkg=cortex
# Default to 0 if the sync step somehow produced no output:
# `set -u` plus `[ "" -eq 0 ]` would abort the deploy on an
# arithmetic error rather than on anything meaningful.
config_changed="${{ steps.catalogue.outputs.changed }}"
config_changed="${config_changed:-0}"
# `rpm -q` prints "package X is not installed" on STDOUT and exits
# 1, so `|| echo not-installed` never fires — it captures the
# message text instead. Test the exit code.
@@ -90,18 +147,31 @@ jobs:
p = max(cands, key=lambda p: p.get("buildTime", 0))
print(p["version"] + "-" + p["release"])
' "${pkg}" 2>/dev/null || true)
if [ -n "${latest}" ] && [ "${latest}" = "${installed}" ]; then
echo "${pkg}-${installed} already current — leaving service untouched"
# Skip only when BOTH the package and the catalogue are
# unchanged — a models.toml edit (a new sampling override, a
# corrected quant) must restart cortex even with no new RPM,
# exactly as the neuron drop-in does below.
if [ -n "${latest}" ] && [ "${latest}" = "${installed}" ] \
&& [ "${config_changed}" -eq 0 ]; then
echo "${pkg}-${installed} already current; models.toml unchanged — leaving service untouched"
exit 0
fi
echo "installed=${installed} published=${latest:-unknown} — deploying"
pkg_changed=1
if [ -n "${latest}" ] && [ "${latest}" = "${installed}" ]; then
pkg_changed=0
fi
echo "installed=${installed} published=${latest:-unknown} pkg_changed=${pkg_changed} config_changed=${config_changed} — deploying"
if systemctl is-active --quiet cortex.service; then
sudo /usr/bin/systemctl stop cortex.service
fi
if rpm -q "${pkg}" >/dev/null 2>&1; then
sudo /usr/bin/dnf upgrade --refresh --allowerasing -y cortex
else
sudo /usr/bin/dnf install --refresh --allowerasing -y cortex
# A catalogue-only change still restarts, but must not spend a
# dnf metadata refresh proving the package is already current.
if [ "${pkg_changed}" -eq 1 ]; then
if rpm -q "${pkg}" >/dev/null 2>&1; then
sudo /usr/bin/dnf upgrade --refresh --allowerasing -y cortex
else
sudo /usr/bin/dnf install --refresh --allowerasing -y cortex
fi
fi
sudo /usr/bin/systemctl daemon-reload
# enable --now: start the service AND enable it for boot so the
@@ -142,7 +212,7 @@ jobs:
CAPTURE
deploy-neurons:
needs: [deploy-cortex]
needs: [config-check, deploy-cortex]
runs-on: fedora-43
strategy:
# One neuron failing must not cancel the others. Cortex is up
@@ -177,6 +247,7 @@ jobs:
load_timeout: 300
max_prompt_tokens: 16384
steps:
- uses: actions/checkout@v4
- name: SSH init
run: |
mkdir -p ~/.ssh
@@ -192,6 +263,42 @@ jobs:
echo "inv=$(ssh gitea_ci@${{ matrix.host }} \
'systemctl show -P InvocationID neuron.service' || true)" >> "$GITHUB_OUTPUT"
# asset/neuron/<short>.toml is this host's neuron.toml. It carries
# the resident-model specs — including the #283 sampling override,
# which is inert until the daemon reloads the model, so a change
# here has to restart the neuron the same way the context-cap
# drop-in does.
#
# Synced before the package step so the restart that step performs
# is the one that picks it up. Digest-compared rather than trusting
# rsync's own change detection, because the restart decision needs
# the answer and a needless neuron restart costs ~5-6 min of 27B
# cold-load (#1).
- name: Sync neuron config (${{ matrix.host }})
id: neuronconf
run: |
short="${{ matrix.host }}"; short="${short%%.*}"
src="asset/neuron/${short}.toml"
if [ ! -f "${src}" ]; then
echo "changed=0" >> "$GITHUB_OUTPUT"
echo "no ${src} in the repo — leaving this host's config alone"
exit 0
fi
local_sum=$(sha256sum "${src}" | cut -d' ' -f1)
remote_sum=$(ssh gitea_ci@${{ matrix.host }} \
'sha256sum /etc/neuron/neuron.toml 2>/dev/null | cut -d" " -f1' || true)
if [ "${local_sum}" = "${remote_sum}" ]; then
echo "changed=0" >> "$GITHUB_OUTPUT"
echo "${src} unchanged (${local_sum})"
else
rsync --archive --compress \
--chown root:root --chmod 0644 \
--rsync-path 'sudo rsync' \
"${src}" gitea_ci@${{ matrix.host }}:/etc/neuron/neuron.toml
echo "changed=1" >> "$GITHUB_OUTPUT"
echo "${src} updated: ${remote_sum:-<absent>} -> ${local_sum}"
fi
# See deploy-cortex for why gating uses the publish manifest and
# not unprivileged `dnf check-update`.
- name: Deploy helexa-neuron-${{ matrix.flavour }} (skips when already current)
@@ -200,6 +307,9 @@ jobs:
set -eu
pkg=helexa-neuron-${{ matrix.flavour }}
max_prompt_tokens="${{ matrix.max_prompt_tokens }}"
# A neuron.toml change counts as a config change for the
# restart decision below, exactly like the drop-in.
neuron_toml_changed="${{ steps.neuronconf.outputs.changed }}"
# ── Desired per-model systemd drop-in ─────────────────────────
# model.conf carries NEURON_MAX_PROMPT_TOKENS so the context cap
@@ -208,7 +318,7 @@ jobs:
# deploy-managed value wins over any manual local override of the
# same variable. See doc/context-limits.md.
conf=/etc/systemd/system/neuron.service.d/model.conf
config_changed=0
config_changed=${neuron_toml_changed:-0}
if [ -n "${max_prompt_tokens}" ]; then
desired=$(printf '%s\n%s\n%s\n%s' \
"# Managed by .gitea/workflows/deploy.yml - do not edit by hand." \
@@ -242,13 +352,17 @@ jobs:
# Skip only when BOTH the package and the drop-in are unchanged —
# a context-cap change must restart the neuron even with no new RPM.
if [ "${pkg_changed}" -eq 0 ] && [ "${config_changed}" -eq 0 ]; then
echo "${pkg}-${installed} current; NEURON_MAX_PROMPT_TOKENS=${max_prompt_tokens:-<unset>} unchanged — leaving service untouched"
echo "${pkg}-${installed} current; NEURON_MAX_PROMPT_TOKENS=${max_prompt_tokens:-<unset>} and neuron.toml unchanged — leaving service untouched"
exit 0
fi
echo "installed=${installed} published=${latest:-unknown} pkg_changed=${pkg_changed} config_changed=${config_changed} — deploying"
# Write the drop-in (staged in gitea_ci's dir, installed root-owned).
if [ "${config_changed}" -eq 1 ]; then
# Guarded on max_prompt_tokens as well as config_changed: since
# a neuron.toml change also sets config_changed, an entry with
# no max_prompt_tokens would otherwise reach this with `desired`
# unset.
if [ "${config_changed}" -eq 1 ] && [ -n "${max_prompt_tokens}" ]; then
printf '%s\n' "${desired}" > /var/lib/gitea_ci/model.conf
sudo /usr/bin/install -o root -g root -m 0644 -D /var/lib/gitea_ci/model.conf "${conf}"
rm -f /var/lib/gitea_ci/model.conf
@@ -534,6 +648,7 @@ jobs:
CAPTURE
deploy-bench:
needs: [config-check]
runs-on: fedora-43
if: >-
${{
@@ -541,6 +656,7 @@ jobs:
|| github.event.workflow_run.conclusion == 'success'
}}
steps:
- uses: actions/checkout@v4
- name: SSH init
run: |
mkdir -p ~/.ssh
@@ -556,6 +672,28 @@ jobs:
echo "inv=$(ssh gitea_ci@bob.hanzalova.internal \
'systemctl show -P InvocationID helexa-bench.service' || true)" >> "$GITHUB_OUTPUT"
# Same contract as the gateway and neuron configs: the repo is the
# only writer, so a config edit must reach the host and restart the
# service even when no new RPM landed.
- name: Sync bench config
id: benchconf
run: |
src="asset/helexa-bench/bob.toml"
local_sum=$(sha256sum "${src}" | cut -d' ' -f1)
remote_sum=$(ssh gitea_ci@bob.hanzalova.internal \
'sha256sum /etc/helexa-bench/helexa-bench.toml 2>/dev/null | cut -d" " -f1' || true)
if [ "${local_sum}" = "${remote_sum}" ]; then
echo "changed=0" >> "$GITHUB_OUTPUT"
echo "${src} unchanged (${local_sum})"
else
rsync --archive --compress \
--chown root:root --chmod 0644 \
--rsync-path 'sudo rsync' \
"${src}" gitea_ci@bob.hanzalova.internal:/etc/helexa-bench/helexa-bench.toml
echo "changed=1" >> "$GITHUB_OUTPUT"
echo "${src} updated: ${remote_sum:-<absent>} -> ${local_sum}"
fi
# See deploy-cortex for why gating uses the publish manifest and
# not unprivileged `dnf check-update`.
- name: Deploy helexa-bench (skips when already current)
@@ -563,6 +701,8 @@ jobs:
ssh gitea_ci@bob.hanzalova.internal 'bash -s' <<'DEPLOY'
set -eu
pkg=helexa-bench
config_changed="${{ steps.benchconf.outputs.changed }}"
config_changed="${config_changed:-0}"
# `rpm -q` prints "package X is not installed" on STDOUT and exits
# 1, so `|| echo not-installed` never fires — it captures the
# message text instead. Test the exit code.
@@ -578,18 +718,25 @@ jobs:
p = max(cands, key=lambda p: p.get("buildTime", 0))
print(p["version"] + "-" + p["release"])
' "${pkg}" 2>/dev/null || true)
if [ -n "${latest}" ] && [ "${latest}" = "${installed}" ]; then
echo "${pkg}-${installed} already current — leaving service untouched"
if [ -n "${latest}" ] && [ "${latest}" = "${installed}" ] \
&& [ "${config_changed}" -eq 0 ]; then
echo "${pkg}-${installed} already current; config unchanged — leaving service untouched"
exit 0
fi
echo "installed=${installed} published=${latest:-unknown} — deploying"
pkg_changed=1
if [ -n "${latest}" ] && [ "${latest}" = "${installed}" ]; then
pkg_changed=0
fi
echo "installed=${installed} published=${latest:-unknown} pkg_changed=${pkg_changed} config_changed=${config_changed} — deploying"
if systemctl is-active --quiet helexa-bench.service; then
sudo /usr/bin/systemctl stop helexa-bench.service
fi
if rpm -q "${pkg}" >/dev/null 2>&1; then
sudo /usr/bin/dnf upgrade --refresh --allowerasing -y helexa-bench
else
sudo /usr/bin/dnf install --refresh --allowerasing -y helexa-bench
if [ "${pkg_changed}" -eq 1 ]; then
if rpm -q "${pkg}" >/dev/null 2>&1; then
sudo /usr/bin/dnf upgrade --refresh --allowerasing -y helexa-bench
else
sudo /usr/bin/dnf install --refresh --allowerasing -y helexa-bench
fi
fi
sudo /usr/bin/systemctl daemon-reload
# enable --now: start the service AND enable it for boot so the
-1
View File
@@ -9,7 +9,6 @@ helexa.ai/.env.local
.idea/
.vscode/
cortex.toml
models.toml
doc/plan/*
/target-cuda/
.claude/
+4 -3
View File
@@ -167,7 +167,7 @@ doc-comment; touch points (the `Job` enum, the dispatch handlers, the
cargo build --release # build all crates
cargo run -p cortex-cli -- serve # run the gateway
cargo test # run all tests
cargo clippy --workspace # lint
cargo clippy --workspace --all-targets # lint (test code included)
```
## CI
@@ -177,7 +177,7 @@ pass before merging:
```sh
cargo fmt --check --all # formatting
cargo clippy --workspace -- -D warnings # lint (warnings are errors)
cargo clippy --workspace --all-targets -- -D warnings # lint (warnings are errors)
cargo test --workspace # tests
```
@@ -191,7 +191,8 @@ Work each change on its own branch; `main` stays releasable.
1. Implement on a feature branch (`fix/<issue>-…`, `feat/<issue>-…`).
2. Run the CI triad locally (`cargo fmt --check --all`,
`cargo clippy --workspace -- -D warnings`, `cargo test --workspace`).
`cargo clippy --workspace --all-targets -- -D warnings`,
`cargo test --workspace`).
Local builds are **CPU-only** — the `#[cfg(feature = "cuda")]` neuron/TP
paths do NOT compile locally. The branch CI's **CUDA type-check** job is
the only thing that validates them, so for any neuron change the push to
+25
View File
@@ -1,3 +1,10 @@
# ─────────────────────────────────────────────────────────────────────
# MANAGED BY CI — .gitea/workflows/deploy.yml is the only writer.
#
# Edits made directly to the deployed copy on the host are reverted by
# the next deploy, and until then the fleet runs something no commit
# describes. Change it here, commit, let the pipeline ship it.
# ─────────────────────────────────────────────────────────────────────
# helexa-bench config for bob.hanzalova.internal.
#
# Synced to /etc/helexa-bench/helexa-bench.toml by script/infra-setup.sh
@@ -15,6 +22,20 @@ iteration_pause_secs = 2
request_timeout_secs = 600
db_path = "/var/lib/helexa-bench/bench.sqlite"
# Identity bench presents to neurons (#288). Without it bench is
# anonymous, and since #262 anonymous callers are served only from
# leftover capacity — capped below max_in_flight and parked at the class
# gate. That moved beast's concurrency:8 from 168.9 tok/s / 0.53s
# ttft_p95 to 104.6 / 11.77s the day #262 landed and held there, so every
# number since measured the yield policy, not serving capacity.
#
# A dedicated pair, not a borrowed one: bench then holds its own
# fair-share allocation (#54) and cannot starve interactive traffic —
# the failure #262 existed to fix.
[bench.principal]
account_id = "helexa-bench"
key_id = "fleet-benchmark"
[scenarios]
prompt_sizes = [128, 4096]
max_tokens = 256
@@ -22,6 +43,10 @@ max_tokens = 256
# carries p95-under-concurrency data before the F3 A/B gate (#94) needs
# it for comparison. Levels mirror the real a0/hermes/opencode fan-out.
concurrency_levels = [2, 4, 8]
# Non-streaming bursts (#285/#288). Kept to the top level: it is where
# serialization shows most starkly and each level costs a full burst per
# sample per build.
concurrency_nonstreaming_levels = [8]
concurrency_prompt_tokens = 512
# Capability probes (#91) — the reasoning/planning axis the speed
+38 -1
View File
@@ -1,3 +1,10 @@
# ─────────────────────────────────────────────────────────────────────
# MANAGED BY CI — .gitea/workflows/deploy.yml is the only writer.
#
# Edits made directly to the deployed copy on the host are reverted by
# the next deploy, and until then the fleet runs something no commit
# describes. Change it here, commit, let the pipeline ship it.
# ─────────────────────────────────────────────────────────────────────
# neuron.toml for beast.hanzalova.internal
#
# 2x RTX 5090 (32 GB each) — TP-2 capable. Pre-warms Qwen3.6-27B with
@@ -22,7 +29,24 @@ name = "candle"
# count. NEURON_BATCHING=0 (systemd drop-in) is the kill switch;
# removing this section reverts to batch-1 serialization.
[harness.candle.admission]
max_in_flight = 8
# 2, not 8 (#291). max_in_flight is both the admission bound and the
# batch engine's slot count, and the KV budget is divided by it at load.
# At 8 that gave each slot 18,852 tokens out of 150,816 — against a
# 131,072-token advertised context, which needs 4 GB of KV per card on
# its own and 32 GB across eight slots, on a 31 GB card already holding
# ~21 GB of weights. The advertised context and the slot count were
# incompatible by roughly 7x, and an agentic session ran in a ~19k
# window while seven idle slots held reservations.
#
# Seven days of paired request spans on this host: 89.6% of busy time
# was ONE request in flight, 93.6% was two or fewer, and utilisation was
# 4.9%. Most of what little concurrency exists is helexa-bench's own
# synthetic bursts. Two slots cover 93.6% of observed load and give each
# ~75k tokens; the batch engine is still worth 1.79x there (5.24x at
# eight is throughput nobody is asking this host for). Anything above
# two queues on admission (#53) with Retry-After rather than being
# refused.
max_in_flight = 2
[[default_models]]
# Qwen3.8-27B is the resident flagship (#251). Architecturally identical
@@ -35,3 +59,16 @@ harness = "candle"
quant = "q6k"
tensor_parallel = 2
devices = [0, 1]
# Sampling override (#283) — MUST match the `[models.sampling]` block on
# this model in models.toml. Two files describe one model; only
# script/check-config-consistency.py forces them to agree, and #252 is
# what happens when nothing does.
#
# The model's own generation_config.json says temperature = 1.0, which
# measured a 20% structural-defect rate on ~2k-token code generation
# against 0/60 at <= 0.6 (Fisher exact one-sided p = 0.0031). Precedence
# is request > operator > model, so a caller naming a temperature still
# gets what it asked for.
[default_models.sampling]
temperature = 0.6
+7
View File
@@ -1,3 +1,10 @@
# ─────────────────────────────────────────────────────────────────────
# MANAGED BY CI — .gitea/workflows/deploy.yml is the only writer.
#
# Edits made directly to the deployed copy on the host are reverted by
# the next deploy, and until then the fleet runs something no commit
# describes. Change it here, commit, let the pipeline ship it.
# ─────────────────────────────────────────────────────────────────────
# neuron.toml for benjy.hanzalova.internal
#
# 1x RTX 4090 (24 GB) — largest single-GPU host on the fleet. Pre-warms
+7
View File
@@ -1,3 +1,10 @@
# ─────────────────────────────────────────────────────────────────────
# MANAGED BY CI — .gitea/workflows/deploy.yml is the only writer.
#
# Edits made directly to the deployed copy on the host are reverted by
# the next deploy, and until then the fleet runs something no commit
# describes. Change it here, commit, let the pipeline ship it.
# ─────────────────────────────────────────────────────────────────────
# neuron.toml for quadbrat.hanzalova.internal
#
# 1x RTX 3060 (12 GB) — small / quantised tier. Pre-warms Qwen3-1.7B
+8 -1
View File
@@ -1,4 +1,10 @@
import type { Dimensions, ReportRow, RunRow, SeriesPoint } from "./types";
import type {
Dimensions,
MeasurementRegime,
ReportRow,
RunRow,
SeriesPoint,
} from "./types";
// Empty default → `fetch('/api/...')` hits the dev proxy (vite.config.ts)
// or the same origin. For a separately-hosted build, set VITE_API_BASE to
@@ -15,6 +21,7 @@ async function getJson<T>(path: string): Promise<T> {
export const getDimensions = () => getJson<Dimensions>("/api/dimensions");
export const getSummary = () => getJson<ReportRow[]>("/api/summary");
export const getRegimes = () => getJson<MeasurementRegime[]>("/api/regimes");
// host is resolved server-side (each model maps to one host today), so the
// public UI selects by model + scenario alone.
+288 -86
View File
@@ -11,8 +11,8 @@ import {
XAxis,
YAxis,
} from "recharts";
import { getDimensions, getSeries } from "../api";
import type { Dimensions, SeriesPoint } from "../types";
import { getDimensions, getRegimes, getSeries } from "../api";
import type { Dimensions, MeasurementRegime, SeriesPoint } from "../types";
import { BASELINE_SOURCE, baselineFor } from "../baseline";
function Picker({
@@ -40,11 +40,95 @@ function Picker({
);
}
type SeriesDef = {
key: string;
name: string;
stroke: string;
dashed?: boolean;
};
/** One titled chart over the shared build timeline.
*
* Every panel draws the same x-axis and the same regime divider, so a
* reader can line a change up across metrics — which is the whole point
* of having more than two of them. */
/** A vertical rule at a build where the metric changed meaning. */
type Rule = { at: string; label: string; detail?: string };
function MetricChart({
title,
hint,
data,
lines,
rules,
unit,
}: {
title: string;
hint?: string;
data: Record<string, unknown>[];
lines: SeriesDef[];
rules?: Rule[];
unit?: string;
}) {
const hasAny = data.some((d) => lines.some((l) => d[l.key] != null));
if (!hasAny) return null;
return (
<>
<h5 className="mt-4">
{title}
{unit ? <span className="text-muted fw-normal"> ({unit})</span> : null}
</h5>
{hint && <p className="text-muted small mb-2">{hint}</p>}
<ResponsiveContainer width="100%" height={260}>
<LineChart data={data} margin={{ top: 8, right: 24, bottom: 8, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" />
<YAxis />
<Tooltip />
<Legend />
{(rules ?? [])
// Only draw a rule whose build is actually on this x-axis;
// recharts would otherwise place it at the origin and imply
// the boundary sits before all the data.
.filter((r) => data.some((d) => d.label === r.at))
.map((r) => (
<ReferenceLine
key={`${r.at}-${r.label}`}
x={r.at}
stroke="#bbb"
strokeDasharray="3 3"
label={{
value: r.label,
position: "top",
fill: "#999",
fontSize: 11,
}}
/>
))}
{lines.map((l) => (
<Line
key={l.key}
type="monotone"
dataKey={l.key}
name={l.name}
stroke={l.stroke}
strokeDasharray={l.dashed ? "5 5" : undefined}
connectNulls
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</>
);
}
export default function Trends() {
const [dims, setDims] = useState<Dimensions | null>(null);
const [model, setModel] = useState("");
const [scenario, setScenario] = useState("");
const [series, setSeries] = useState<SeriesPoint[]>([]);
const [regimes, setRegimes] = useState<MeasurementRegime[]>([]);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
@@ -55,6 +139,10 @@ export default function Trends() {
if (d.scenarios[0]) setScenario(d.scenarios[0]);
})
.catch((e) => setErr(String(e)));
// A missing/older API just means no rules — not a page failure.
getRegimes()
.then(setRegimes)
.catch(() => setRegimes([]));
}, []);
useEffect(() => {
@@ -68,10 +156,7 @@ export default function Trends() {
// Prepend the pre-helexa-bench baseline (dashed, separate keys) so it
// anchors the timeline without being merged into the live line. Different
// measurement regime — see baseline.ts / doc/benchmarks.md.
const base = useMemo(
() => baselineFor(model, scenario),
[model, scenario],
);
const base = useMemo(() => baselineFor(model, scenario), [model, scenario]);
const data = useMemo(
() => [
...base.map((p) => ({
@@ -85,6 +170,14 @@ export default function Trends() {
ttft: p.ttft_s_median,
decode: p.decode_tps_median,
total: p.total_s_median,
ttftP95: p.ttft_p95_s_median,
queueWait: p.queue_wait_ms_median,
rejected: p.rejected_median,
prefillTps: p.prefill_tps_median,
reasoning: p.reasoning_tokens_median,
cached: p.cached_tokens_median,
completion: p.completion_tokens_median,
tpot: p.tpot_p95_ms_median,
})),
],
[series, base],
@@ -95,6 +188,55 @@ export default function Trends() {
const firstLive = series[0]?.git_sha;
const showDivider = base.length > 0 && series.length > 0;
// The build where the measuring identity changed, derived from the
// data rather than declared — a constant would go stale the moment
// the principal is reconfigured (#288).
const identityShift = useMemo(() => {
for (let i = 1; i < series.length; i++) {
const prev = series[i - 1].principal ?? "anonymous";
const cur = series[i].principal ?? "anonymous";
if (prev !== cur) {
return {
at: series[i].git_sha,
label: `measured as ${cur === "anonymous" ? "anonymous" : "identified"}`,
detail:
"The identity bench measured under changed here. Anonymous " +
"samples are subject to the #262 yield policy and understate " +
"capacity, so points either side are not comparable (#288).",
} as Rule;
}
}
return null;
}, [series]);
/** Declared boundaries touching `metric`, plus the regime-independent
* ones that apply to every panel. */
const rulesFor = (metric: string): Rule[] => {
const out: Rule[] = [];
if (showDivider && firstLive) {
out.push({ at: firstLive, label: "bench.py → helexa-bench" });
}
for (const r of regimes) {
if (r.affects.includes(metric)) {
out.push({ at: r.first_sha, label: r.label, detail: r.detail });
}
}
if (identityShift) out.push(identityShift);
return out;
};
// Anonymous and identified samples are not comparable once #262 is in
// the build: an anonymous caller is capped below max_in_flight and
// parked at the class gate, so it characterises the yield policy
// rather than serving capacity. Say so rather than letting someone
// read a step change as an engine regression (#288).
const identities = useMemo(
() => new Set(series.map((p) => p.principal ?? "anonymous")),
[series],
);
const mixedIdentity = identities.size > 1;
const anyAnonymous = identities.has("anonymous");
if (err) return <Alert variant="danger">{err}</Alert>;
if (!dims) return <Spinner animation="border" />;
@@ -122,6 +264,25 @@ export default function Trends() {
</p>
)}
{mixedIdentity && (
<Alert variant="warning" className="py-2">
<strong>Mixed measurement identity.</strong> Some builds were
sampled anonymously and some under a principal. Since{" "}
<code>#262</code> an anonymous caller is capped below{" "}
<code>max_in_flight</code> and yields to identified traffic, so
those points measure the admission policy rather than serving
capacity. A step change across that boundary is the instrument,
not the engine — see <code>#288</code>.
</Alert>
)}
{!mixedIdentity && anyAnonymous && (
<Alert variant="secondary" className="py-2 small">
Sampled anonymously. Since <code>#262</code> anonymous callers are
served from leftover capacity, so these numbers understate what an
authenticated caller gets (<code>#288</code>).
</Alert>
)}
{data.length === 0 ? (
<Alert variant="info">No data for this selection yet.</Alert>
) : (
@@ -133,87 +294,128 @@ export default function Trends() {
see <code>doc/benchmarks.md</code>.
</p>
)}
<h5 className="mt-3">decode tok/s (higher is better)</h5>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={data} margin={{ top: 8, right: 24, bottom: 8, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" />
<YAxis />
<Tooltip />
<Legend />
{showDivider && firstLive && (
<ReferenceLine
x={firstLive}
stroke="#bbb"
strokeDasharray="3 3"
label={{
value: "bench.py → helexa-bench",
position: "top",
fill: "#999",
fontSize: 11,
}}
/>
)}
<Line
type="monotone"
dataKey="decode"
name="decode tok/s"
stroke="#0d6efd"
connectNulls
/>
{base.length > 0 && (
<Line
type="monotone"
dataKey="baseDecode"
name="baseline (bench.py · gateway)"
stroke="#888"
strokeDasharray="5 5"
connectNulls
/>
)}
</LineChart>
</ResponsiveContainer>
<h5 className="mt-4">TTFT seconds (lower is better)</h5>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={data} margin={{ top: 8, right: 24, bottom: 8, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" />
<YAxis />
<Tooltip />
<Legend />
{showDivider && firstLive && (
<ReferenceLine
x={firstLive}
stroke="#bbb"
strokeDasharray="3 3"
label={{
value: "bench.py → helexa-bench",
position: "top",
fill: "#999",
fontSize: 11,
}}
/>
)}
<Line
type="monotone"
dataKey="ttft"
name="TTFT (s)"
stroke="#dc3545"
connectNulls
/>
{base.length > 0 && (
<Line
type="monotone"
dataKey="baseTtft"
name="baseline (bench.py · gateway)"
stroke="#888"
strokeDasharray="5 5"
connectNulls
/>
)}
</LineChart>
</ResponsiveContainer>
<MetricChart
title="decode tok/s"
unit="higher is better"
data={data}
rules={rulesFor("decode")}
lines={[
{ key: "decode", name: "decode tok/s", stroke: "#0d6efd" },
...(base.length > 0
? [
{
key: "baseDecode",
name: "baseline (bench.py · gateway)",
stroke: "#888",
dashed: true,
},
]
: []),
]}
/>
<MetricChart
title="prefill tok/s"
unit="higher is better"
hint="The other half of serving speed, derived from prefill_tokens / prefill_ms. A prefix-cache hit shortens prefill_ms while the token count stays whole, so a high rate here is itself the cache-hit signal."
data={data}
rules={rulesFor("prefillTps")}
lines={[
{ key: "prefillTps", name: "prefill tok/s", stroke: "#20c997" },
]}
/>
<MetricChart
title="TTFT"
unit="seconds, lower is better"
hint="Median and p95 together on purpose. Under concurrency the median is dominated by whichever streams were admitted immediately; the p95 is the one that moves when a caller is made to wait. Charting only the median is how a 0.53 s → 11.77 s tail went unnoticed for a week."
data={data}
rules={rulesFor("ttft")}
lines={[
{ key: "ttft", name: "TTFT median (s)", stroke: "#dc3545" },
{ key: "ttftP95", name: "TTFT p95 (s)", stroke: "#fd7e14" },
...(base.length > 0
? [
{
key: "baseTtft",
name: "baseline (bench.py · gateway)",
stroke: "#888",
dashed: true,
},
]
: []),
]}
/>
<MetricChart
title="inter-token gap p95"
unit="ms, lower is better"
hint="Stream smoothness. decode tok/s is a mean over the whole window, so a stream that stalls and then catches up is indistinguishable from one that never stalled — this is the number a user feels."
data={data}
rules={rulesFor("tpot")}
lines={[
{ key: "tpot", name: "inter-token p95 (ms)", stroke: "#6f42c1" },
]}
/>
<MetricChart
title="admission"
unit="queue wait ms · requests shed"
hint="Separates “the server is slow” from “you were queued behind someone”. Queue wait is TTFT minus server-measured prefill; rejected counts honest backpressure rather than silent failures."
data={data}
rules={rulesFor("queueWait")}
lines={[
{ key: "queueWait", name: "queue wait (ms)", stroke: "#d63384" },
{ key: "rejected", name: "rejected (count)", stroke: "#adb5bd" },
]}
/>
<MetricChart
title="tokens per sample"
unit="counts"
hint="Cost, not speed. Reasoning tokens are the dominant driver on a reasoning model and move independently of every rate above — a template or sampling change can double what the model thinks before answering while the speed charts stay flat. Cached tokens are why prefill timing varies between otherwise identical samples."
data={data}
rules={rulesFor("completion")}
lines={[
{ key: "completion", name: "completion tokens", stroke: "#0dcaf0" },
{ key: "reasoning", name: "reasoning tokens", stroke: "#ffc107" },
{ key: "cached", name: "cached prompt tokens", stroke: "#198754" },
]}
/>
{(() => {
// Explain every rule actually drawn for this selection. The
// chart label is a name; without the reason, a reader still
// has to go and rediscover what it meant — which is the cost
// this whole feature exists to remove.
const shown = [
...regimes
.filter((r) => series.some((p) => p.git_sha === r.first_sha))
.map((r) => ({ at: r.first_sha, label: r.label, detail: r.detail })),
...(identityShift ? [identityShift] : []),
];
if (shown.length === 0) return null;
return (
<div className="mt-4 pt-3 border-top">
<p className="text-muted small mb-2">
<strong>Dashed rules mark measurement-regime changes</strong> —
builds where a number changed meaning rather than value. A step
across one is the instrument, not the engine.
</p>
<dl className="row small text-muted mb-0">
{shown.map((r) => (
<div key={`${r.at}-${r.label}`} className="mb-2">
<dt className="fw-semibold">
<code>{r.at}</code> — {r.label}
</dt>
<dd className="mb-0">{r.detail}</dd>
</div>
))}
</dl>
</div>
);
})()}
</>
)}
</>
+21
View File
@@ -41,6 +41,17 @@ export interface SeriesPoint {
ttft_s_median: number | null;
decode_tps_median: number | null;
total_s_median: number | null;
ttft_p95_s_median: number | null;
queue_wait_ms_median: number | null;
rejected_median: number | null;
prefill_tps_median: number | null;
reasoning_tokens_median: number | null;
cached_tokens_median: number | null;
completion_tokens_median: number | null;
tpot_p95_ms_median: number | null;
/** Identity the samples were taken under, or null if anonymous.
* Anonymous and identified points are not comparable (#288). */
principal: string | null;
samples: number;
}
@@ -67,3 +78,13 @@ export interface RunRow {
ok: boolean;
error: string | null;
}
/** A build at which a metric changed meaning rather than value (#288).
* Served from the API so the UI and `report` cite one list. */
export interface MeasurementRegime {
first_sha: string;
label: string;
detail: string;
/** Series keys this affects, as Trends.tsx names them. */
affects: string[];
}
+20 -1
View File
@@ -1,7 +1,7 @@
//! Model catalogue — profiles describing how to serve each model.
use crate::discovery::DeviceInfo;
use crate::harness::{ModelCost, ModelLimit};
use crate::harness::{ModelCost, ModelLimit, SamplingOverride};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
@@ -57,6 +57,23 @@ pub struct ModelProfile {
/// on this being explicit per model rather than implicit.
#[serde(default)]
pub source: Option<String>,
/// Operator sampling override forwarded to neuron on cold load
/// (#283). The counterpart to the same field in a host's
/// `[[default_models]]` — a model that can be both cold-loaded by
/// cortex and resident on a host must carry the same value in both,
/// which `script/check-config-consistency.py` enforces in CI.
///
/// This is the #252 shape of hazard: two config files describing one
/// model, with nothing forcing them to agree.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sampling: Option<SamplingOverride>,
/// The model's own `preserve_thinking` template control, forwarded
/// on cold load so a model this gateway loads behaves the same as
/// one the host was configured to hold resident. Two config files
/// describing one model is how #252 happened; this rides along for
/// the same reason `sampling` does.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preserve_thinking: Option<bool>,
// ── Enrichment (issue #62) ────────────────────────────────
/// Per-model token budget. When present, advertised in `/v1/models`
@@ -240,6 +257,8 @@ mod tests {
pinned_on: vec![],
residency_priority: None,
source: None,
sampling: None,
preserve_thinking: None,
limit: None,
cost: None,
capabilities: vec![],
+104 -2
View File
@@ -26,7 +26,50 @@ pub struct HarnessHealth {
pub uptime_secs: Option<u64>,
}
/// Operator-set sampling defaults for one model (#283).
///
/// A model's `generation_config.json` is the model authors' statement of
/// intent, and honouring it (#272) is right by default. It is not always
/// right for the workload the operator serves: `Qwen/Qwen3.8-27B`
/// publishes `temperature = 1.0`, which measured a 20% structural-defect
/// rate on ~2k-token code generation against 0/60 at `<= 0.6`
/// (#283, p = 0.0031).
///
/// This is the operator's explicit, visible correction to that default —
/// deliberately *not* a heuristic that inspects the model and guesses.
/// A temperature guesser is a footgun; a number in a config file the
/// operator wrote is not.
///
/// Precedence is **request > operator > model > built-in fallback**: the
/// override replaces what the model published, but a caller that names a
/// value still wins, because an explicit API parameter that is silently
/// ignored is a contract break.
///
/// Every field is optional and overlays independently — setting
/// `temperature` alone leaves the model's `top_p`/`top_k` in force.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SamplingOverride {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_k: Option<usize>,
}
impl SamplingOverride {
/// True when the operator set nothing — an empty `[sampling]` table
/// must behave exactly as no table at all.
pub fn is_empty(&self) -> bool {
self.temperature.is_none() && self.top_p.is_none() && self.top_k.is_none()
}
}
/// Specification for loading a model through a harness.
///
/// Doubles as the `[[default_models]]` entry shape in `neuron.toml`, so
/// a field added here is available to both the load API and the
/// operator's per-host config without a second type to keep in step.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelSpec {
pub model_id: String,
@@ -34,6 +77,36 @@ pub struct ModelSpec {
pub quant: Option<String>,
pub tensor_parallel: Option<u32>,
pub devices: Option<Vec<u32>>,
/// Operator sampling override (#283). Travels with the *model*, not
/// the request: neuron is the only place sampling is resolved, so
/// the override has to reach it whether the load came from cortex's
/// catalogue or from this host's own `[[default_models]]`.
///
/// `#[serde(default)]` so a cortex or neuron predating this field
/// still round-trips the spec.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sampling: Option<SamplingOverride>,
/// Whether prior assistant turns keep their reasoning when the chat
/// template re-renders the conversation.
///
/// This is the model's own `preserve_thinking` template control, not
/// an invention of ours. `Qwen/Qwen3.8-27B` defaults it to *true* —
/// the whole transcript's think blocks are replayed — and `false`
/// keeps reasoning only for turns after the last user query, i.e.
/// the turn in progress.
///
/// `None` leaves the kwarg unset so the template's own default
/// applies, which is the only safe default: the model's authors
/// chose it, and overriding that fleet-wide on a hunch is how you
/// ship a regression you cannot see.
///
/// Exposed as operator config so the premise can be A/B'd on a real
/// workload — full replay is the larger prompt and pushes against
/// the prefix-cache budget and the throughput-derived context
/// ceiling, but whether it *helps* the model is a measurement, not
/// a belief. A request's own `chat_template_kwargs` still wins.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preserve_thinking: Option<bool>,
}
/// Per-model token budget advertised by the catalogue or neuron.
@@ -89,9 +162,38 @@ fn is_zero(v: &usize) -> bool {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReasoningBudgetRung {
/// The effort level as the caller spells it on the wire.
///
/// Since #290 these are the rungs the **model's own chat template**
/// accepts, discovered by rendering it, rather than a ladder the
/// operator invented. `Qwen/Qwen3.8-27B` accepts exactly `low`,
/// `medium` and `xhigh` and raises on anything else; we previously
/// advertised `minimal`/`low`/`medium`/`high`, two of which the
/// template rejects and which omitted the model's own default.
///
/// Advertise only rungs the template accepts: an invented name is a
/// name a caller can send and the model will reject. Note that a
/// client need not read this at all — pi-ai takes its levels from a
/// static `thinkingLevelMap` in the operator's config — so this is
/// a statement of truth, not a control surface we can rely on.
pub effort: String,
/// Reasoning tokens that level is allowed to spend.
pub tokens: usize,
/// Backstop reasoning-token cap for this rung, when the deployment
/// sets one.
///
/// `None` is the normal case and means "named rung, no cap": effort
/// is expressed to the model through its template, and the number of
/// tokens it then spends is the model's business. A cap is a safety
/// net against a runaway think block (#223), not the mechanism by
/// which effort is selected — enforcing effort by truncation is what
/// #290 fixed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens: Option<usize>,
/// Whether the model applies this rung when the caller names none.
///
/// Taken from the template's own `|default(...)`. Without it a
/// caller that says nothing cannot tell what it is getting — on
/// Qwen3.8 that silently means `xhigh`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub default: bool,
}
/// Operator-set pricing, **USD per 1,000,000 tokens, as JSON numbers**
+23 -10
View File
@@ -194,10 +194,19 @@ pub struct CortexModelEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_model_len: Option<usize>,
/// The same window under the two names generic OpenAI clients look
/// for (#278). pi-ai's provider discovery, for one, reads
/// `context_window` then `context_length` and nothing else — so a
/// client probing cortex found no window at all and fell back to a
/// number its operator had to invent by hand.
/// for (#278) — a client that probes for one of these rather than
/// `max_model_len` would otherwise find no window at all and fall
/// back to a number its operator had to invent by hand.
///
/// Publishing them is cheap and harmless; do not assume any
/// *specific* client consumes them. An earlier version of this
/// comment credited pi-ai's provider discovery, which was wrong:
/// pi requires `contextWindow` as a static field on its model type
/// and reads none of these keys at runtime (checked against 0.82.1
/// and 0.84.3). The misattribution mattered — it made an operator's
/// hand-set 80k window look like our advertisement failing to land,
/// when in fact nothing was reading it and the number had to be
/// maintained by hand regardless.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -227,12 +236,16 @@ impl CortexModelEntry {
self.context_window = self.limit.as_ref().map(|l| l.context);
self.context_length = self.limit.as_ref().map(|l| l.context);
self.max_input_tokens = self.limit.as_ref().and_then(|l| l.input);
// The ceiling, not the reserve (#278): this field is what
// OpenAI-ecosystem clients read to decide what to ask for, and
// pi-ai/DSH turns it straight into a per-request cap. Advertising
// the reserve here handed a reasoning model less budget than its
// own think block needs. Falls back to the reserve for a neuron
// too old to publish a ceiling.
// The ceiling, not the reserve (#278): a client that reads this
// field turns it into the cap it sends on each request, so
// advertising the reserve handed a reasoning model less budget
// than its own think block needs. Falls back to the reserve for
// a neuron too old to publish a ceiling.
//
// Observed with dsh. pi is *not* an example: its `maxTokens` is
// a required static field in models.json and it reads nothing
// from here — an operator has to copy this number across by
// hand, and gets no error if they copy it wrong.
self.max_output_tokens = self.limit.as_ref().map(|l| {
if l.output_ceiling > 0 {
l.output_ceiling
+85
View File
@@ -215,11 +215,46 @@ pub struct ResponsesResponse {
pub status: String,
pub model: String,
pub output: Vec<ResponsesOutputItem>,
/// Why a `status: "incomplete"` response stopped short. Required in
/// practice, not optional decoration: a client cannot tell an
/// honest truncation from a protocol fault without it, and at least
/// one treats the difference as fatal.
///
/// pi-ai's `mapStopReason` maps
/// `incomplete` + `reason == "max_output_tokens"` to
/// `stopReason: "length"`, and `incomplete` with no reason to
/// `stopReason: "error"` ("Response incomplete without a provider
/// reason"). Its agent loop then either continues — failing any
/// truncated tool calls safely and letting the model recover on the
/// next turn — or halts outright. Omitting this field cost a live
/// agentic session its whole run.
///
/// `None` for `completed` and `in_progress`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub incomplete_details: Option<IncompleteDetails>,
/// Populated on completion; `None` while streaming.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<ResponsesUsage>,
}
/// Why a response is `incomplete`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IncompleteDetails {
/// OpenAI spells the output-cap case `"max_output_tokens"`; that is
/// the only reason we produce today. `content_filter` is the other
/// value the upstream API defines.
pub reason: String,
}
impl IncompleteDetails {
/// The response ran into the caller's output budget.
pub fn max_output_tokens() -> Self {
Self {
reason: "max_output_tokens".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponsesOutputItem {
@@ -327,6 +362,24 @@ pub mod events {
pub const FUNCTION_CALL_ARGUMENTS_DELTA: &str = "response.function_call_arguments.delta";
pub const FUNCTION_CALL_ARGUMENTS_DONE: &str = "response.function_call_arguments.done";
pub const COMPLETED: &str = "response.completed";
/// Terminal frame for a response that stopped short. Carries the
/// same `response` payload as [`COMPLETED`], with
/// `status: "incomplete"` and `incomplete_details.reason`.
///
/// A client keyed on the event name — rather than on
/// `response.status` — sees no terminal event at all if a truncated
/// response is announced as `response.completed`, and waits out its
/// idle timeout.
pub const INCOMPLETE: &str = "response.incomplete";
/// Terminal frame for a response that did not finish because the
/// server failed — a poisoned device, an OOM, a producer that died
/// mid-stream. Carries `status: "failed"` and an `error` object.
///
/// The alternative is worse than it looks: defaulting a missing
/// finish to a clean stop reports a crash as a complete answer, and
/// the caller believes a truncated or empty reply was the model's
/// considered response.
pub const FAILED: &str = "response.failed";
}
#[cfg(test)]
@@ -531,6 +584,7 @@ mod tests {
}],
status: "completed".into(),
}],
incomplete_details: None,
usage: Some(ResponsesUsage {
input_tokens: 5,
output_tokens: 3,
@@ -543,5 +597,36 @@ mod tests {
let parsed: ResponsesResponse = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, "resp_1");
assert_eq!(parsed.output.len(), 1);
assert!(
!json.contains("incomplete_details"),
"a completed response must not carry an incomplete reason"
);
}
/// The field pi-ai's `mapStopReason` keys on. Absent, it maps
/// `incomplete` to `stopReason: "error"` and its agent loop halts;
/// present with `max_output_tokens`, it maps to `"length"` and the
/// loop continues.
#[test]
fn an_incomplete_response_serialises_its_reason() {
let r = ResponsesResponse {
id: "resp_2".into(),
object: "response".into(),
created_at: 1700,
status: "incomplete".into(),
model: "m".into(),
output: vec![],
incomplete_details: Some(IncompleteDetails::max_output_tokens()),
usage: None,
};
let v: serde_json::Value = serde_json::to_value(&r).unwrap();
assert_eq!(v["status"], "incomplete");
assert_eq!(v["incomplete_details"]["reason"], "max_output_tokens");
let parsed: ResponsesResponse = serde_json::from_value(v).unwrap();
assert_eq!(
parsed.incomplete_details,
Some(IncompleteDetails::max_output_tokens())
);
}
}
+1 -1
View File
@@ -288,7 +288,7 @@ mod tests {
// Requested output dominates; prompt estimate is small for a tiny body.
let body = br#"{"model":"m","max_tokens":1000}"#;
let est = reservation_estimate(body, Some(8192));
assert!(est >= 1000 && est < 1100, "est was {est}");
assert!((1000..1100).contains(&est), "est was {est}");
}
#[test]
+7
View File
@@ -535,6 +535,11 @@ async fn profile_to_spec(
quant: profile.quant.clone(),
tensor_parallel,
devices: Some(devices),
// The catalogue's operator override rides along with the load
// (#283), so a cold-loaded model samples the same way as one
// this host was configured to hold resident.
sampling: profile.sampling.clone(),
preserve_thinking: profile.preserve_thinking,
}
}
@@ -642,6 +647,8 @@ mod tests {
pinned_on: vec![],
residency_priority: None,
source: source.map(String::from),
sampling: None,
preserve_thinking: None,
limit: None,
cost: None,
capabilities: vec![],
@@ -1,9 +1,9 @@
//! Integration tests for budget enforcement (#52) — the A0 seatbelt.
//!
//! A reservation over the key's hard cap is refused *before* neuron is hit,
//! with the #63 code matching the cap-window semantics (rate_limit_exceeded
//! + Retry-After for a resetting window, insufficient_quota for a hard
//! balance). Spend never exceeds the cap. No 402, ever.
//! with the #63 code matching the cap-window semantics: `rate_limit_exceeded`
//! plus `Retry-After` for a resetting window, `insufficient_quota` for a hard
//! balance. Spend never exceeds the cap. No 402, ever.
use axum::Json;
use axum::extract::Path;
+47 -25
View File
@@ -69,17 +69,20 @@ capabilities = ["text"]
let mut nodes = fleet.nodes.write().await;
let node = nodes.get_mut("mock-node").expect("node exists");
node.healthy = true;
// The ladder the neuron reported at poll time (#223) — host
// configuration, held once per node.
node.reasoning_budget = ["minimal", "low", "medium", "high"]
// The rungs the neuron reported at poll time. Since #290 these
// come from the model's own chat template rather than operator
// configuration, so the fixture uses the shape a real reasoning
// model ships: Qwen3.8-27B accepts exactly low/medium/xhigh and
// defaults to xhigh.
node.reasoning_budget = ["low", "medium", "xhigh"]
.into_iter()
.zip([1024usize, 4096, 12288, 32768])
.map(
|(effort, tokens)| cortex_core::harness::ReasoningBudgetRung {
effort: effort.into(),
tokens,
},
)
.map(|effort| cortex_core::harness::ReasoningBudgetRung {
default: effort == "xhigh",
effort: effort.into(),
// No cap: effort is expressed to the model through its
// template, not enforced by truncating its thinking.
tokens: None,
})
.collect();
node.models.insert(
"test-model".into(),
@@ -162,14 +165,16 @@ capabilities = ["text"]
// vLLM-convention probes (Hermes Agent) auto-detect the window.
assert_eq!(entry["max_model_len"], 49152);
assert_eq!(entry["max_input_tokens"], 40960);
// The ceiling, not the reserve (#278). pi-ai's discovery — and every
// client like it — turns this field into the cap it sends on each
// request; advertising the 8192 reserve told a reasoning model's
// harness to stop below the cost of a single think block.
// The ceiling, not the reserve (#278). A client that reads this
// field turns it into the cap it sends on each request; advertising
// the 8192 reserve told a reasoning model's harness to stop below
// the cost of a single think block. Observed with dsh — pi takes
// `maxTokens` from static config and reads nothing here.
assert_eq!(entry["max_output_tokens"], 32768);
// What each effort level buys (#223). A client can only send the
// rung names — it has no way to express a token count — so a ladder
// it cannot see is a ladder it has to guess at.
// The rungs the model itself accepts (#290). A caller can only send
// a rung name, so a name we invent is one the template will reject.
// Publishing the true set is correct regardless of who reads it —
// pi-ai, for one, takes its levels from static config instead.
let rungs = entry["reasoning_budget"]
.as_array()
.expect("reasoning_budget advertised for a reasoning model");
@@ -177,17 +182,34 @@ capabilities = ["text"]
.iter()
.map(|r| r["effort"].as_str().expect("effort"))
.collect();
assert_eq!(names, ["minimal", "low", "medium", "high"]);
assert_eq!(names, ["low", "medium", "xhigh"]);
// Which rung applies when the caller names none. Without this a
// silent caller cannot tell what it is getting — on Qwen3.8 that is
// xhigh, the most expensive rung on the ladder.
let default_rung: Vec<&str> = rungs
.iter()
.filter(|r| r["default"].as_bool().unwrap_or(false))
.map(|r| r["effort"].as_str().expect("effort"))
.collect();
assert_eq!(
default_rung,
["xhigh"],
"the model's own default must be visible"
);
// A named rung carries no token count: effort reaches the model
// through its template, and enforcing it by truncation is the defect
// #290 fixed.
assert!(
rungs
.iter()
.all(|r| r["tokens"].as_u64().is_some_and(|t| t > 0)),
"every advertised rung must name a token count"
rungs.iter().all(|r| r.get("tokens").is_none()),
"a named rung must not masquerade as a token budget"
);
// The window under the two names generic clients actually read.
// pi-ai looks for `context_window` then `context_length` and gives
// up if neither is present — `max_model_len` is invisible to it.
// The window under the two names a generic client may read, for one
// that probes these rather than `max_model_len`. Publish all three;
// do not assume a particular client consumes any of them. pi, in
// particular, reads none — `contextWindow` is a required static
// field on its model type (checked against 0.82.1 and 0.84.3), so
// its window is hand-maintained whatever we advertise.
assert_eq!(entry["context_window"], 49152);
assert_eq!(entry["context_length"], 49152);
+9
View File
@@ -34,6 +34,7 @@ pub fn api_routes(state: ApiState) -> Router {
Router::new()
.route("/api/health", get(health))
.route("/api/dimensions", get(dimensions))
.route("/api/regimes", get(regimes))
.route("/api/summary", get(summary))
.route("/api/scaling", get(scaling))
.route("/api/concurrency", get(concurrency))
@@ -65,6 +66,14 @@ async fn health(State(s): State<ApiState>) -> Result<Json<serde_json::Value>, Ap
Ok(Json(json!({ "status": "ok", "run_count": count })))
}
/// Declared measurement-regime boundaries (#288).
///
/// Static — no store access — but served from the API rather than
/// hardcoded in the frontend so `report` and the UI cite one list.
async fn regimes() -> Json<&'static [crate::regime::MeasurementRegime]> {
Json(crate::regime::REGIMES)
}
async fn dimensions(State(s): State<ApiState>) -> Result<Json<crate::store::Dimensions>, ApiError> {
let store = s.lock().await;
store.dimensions().map(Json).map_err(err500)
+53 -4
View File
@@ -6,6 +6,7 @@ use crate::config::{TargetConfig, TargetKind};
use anyhow::{Context, Result, anyhow};
use cortex_core::build_info::BuildInfo;
use cortex_core::discovery::{DiscoveryResponse, HealthResponse};
use cortex_core::entitlements::{HEADER_ACCOUNT_ID, HEADER_KEY_ID};
use cortex_core::harness::{ModelInfo, ModelSpec};
use cortex_core::openai::ModelsResponse;
use std::time::Duration;
@@ -19,10 +20,49 @@ pub struct TargetClient {
impl TargetClient {
pub fn new(request_timeout: Duration) -> Result<Self> {
let http = reqwest::Client::builder()
.timeout(request_timeout)
.build()
.context("building HTTP client")?;
Self::with_principal(request_timeout, None)
}
/// Build a client that stamps `principal`'s identity headers on every
/// request (#288).
///
/// Set on the client rather than at each call site so no scenario can
/// be added later that silently measures as anonymous — which is the
/// failure this fixes, and it was invisible for a week.
///
/// `None` keeps the historical anonymous behaviour, which is correct
/// for `openai` targets (the headers mean nothing to a foreign
/// engine) and for a fresh install with no principal configured.
pub fn with_principal(
request_timeout: Duration,
principal: Option<&crate::config::PrincipalSettings>,
) -> Result<Self> {
let mut builder = reqwest::Client::builder().timeout(request_timeout);
if let Some(p) = principal {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
HEADER_ACCOUNT_ID,
reqwest::header::HeaderValue::from_str(&p.account_id)
.context("principal.account_id is not a valid header value")?,
);
headers.insert(
HEADER_KEY_ID,
reqwest::header::HeaderValue::from_str(&p.key_id)
.context("principal.key_id is not a valid header value")?,
);
tracing::info!(
account_id = %p.account_id,
key_id = %p.key_id,
"bench authenticates: measurements reflect identified-caller capacity (#288)"
);
builder = builder.default_headers(headers);
} else {
tracing::warn!(
"bench has no [bench.principal]: requests are anonymous, so since #262 \
they measure the anonymous-yield policy rather than serving capacity (#288)"
);
}
let http = builder.build().context("building HTTP client")?;
Ok(TargetClient { http })
}
@@ -169,6 +209,15 @@ impl TargetClient {
quant: None,
tensor_parallel: (info.devices.len() > 1).then_some(info.devices.len() as u32),
devices: Some(info.devices.clone()),
// `GET /models` does not report the operator sampling
// override (#283), so a spec reconstructed here cannot carry
// it — same limitation as `quant` directly above. Benign
// while this is only used to re-load a model the host
// already has a `[[default_models]]` entry for, since that
// entry supplies the override; it would matter if this were
// ever used to load a model the host has no config for.
sampling: None,
preserve_thinking: None,
})
}
+94
View File
@@ -67,6 +67,44 @@ pub struct BenchSettings {
/// SQLite system-of-record path.
#[serde(default = "default_db_path")]
pub db_path: String,
/// Identity bench presents to a neuron (#288).
///
/// Without this bench is *anonymous*, and since #262 anonymous
/// callers are served only from capacity left over once identified
/// traffic is satisfied — they are capped below `max_in_flight` and
/// park at the class gate ahead of every other gate. Measured on
/// beast, that moved `concurrency:8` from 168.9 tok/s / 0.53 s
/// ttft_p95 to 104.6 / 11.77 s the day #262 landed, and held there.
///
/// So an unauthenticated bench does not measure serving capacity; it
/// measures the yield policy. Real traffic arrives through cortex
/// with a principal and is subject to none of it.
///
/// Deliberately not an admission *exemption*: bench should be
/// subject to the same rules as any caller, or it measures something
/// no user experiences — the same class of error in the other
/// direction. Give it a principal and let it queue like everyone
/// else.
#[serde(default)]
pub principal: Option<PrincipalSettings>,
}
/// The account/key pair bench stamps on inference requests (#288).
///
/// These are the headers cortex asserts after a bearer resolves (#49).
/// Bench talks to neurons **directly** over the WireGuard mesh, so it
/// stamps them itself — the same trust model the link already relies on,
/// since a neuron accepts them from cortex on exactly that basis.
///
/// Give bench its *own* pair rather than borrowing a real caller's: it
/// then holds its own fair-share allocation (#54) and cannot starve
/// interactive traffic, which is the failure #262 was fixing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrincipalSettings {
/// Stamped as `x-helexa-account-id`.
pub account_id: String,
/// Stamped as `x-helexa-key-id`.
pub key_id: String,
}
impl Default for BenchSettings {
@@ -77,6 +115,7 @@ impl Default for BenchSettings {
iteration_pause_secs: default_iter_pause(),
request_timeout_secs: default_timeout(),
db_path: default_db_path(),
principal: None,
}
}
}
@@ -110,6 +149,18 @@ pub struct ScenarioConfig {
/// deliberately, e.g. `concurrency_levels = [2, 4, 8]`.
#[serde(default)]
pub concurrency_levels: Vec<u32>,
/// Concurrency levels to run **non-streaming** (#285). Separate from
/// `concurrency_levels` because the two shapes are not
/// interchangeable: streaming multiplexes through the batch engine,
/// non-streaming did not until #285 and serialized completely — a
/// measured 1.00x aggregate throughput at every level against 3.98x
/// streamed.
///
/// Defaults to the top level only. The signal is starkest there and
/// each extra level costs a full burst per sample per build; an
/// operator who wants the whole curve can list it.
#[serde(default = "default_concurrency_nonstreaming_levels")]
pub concurrency_nonstreaming_levels: Vec<u32>,
/// Square image sizes (px per side) — one `image:<px>` scenario per
/// entry (#203), run only against models advertising the `image`
/// capability. Defaults to `[1024]`.
@@ -148,6 +199,7 @@ impl Default for ScenarioConfig {
prompt_sizes: default_prompt_sizes(),
max_tokens: default_max_tokens(),
concurrency_levels: Vec::new(),
concurrency_nonstreaming_levels: Vec::new(),
concurrency_prompt_tokens: default_concurrency_prompt_tokens(),
image_sizes: default_image_sizes(),
capability_probes: Vec::new(),
@@ -215,6 +267,10 @@ fn default_timeout() -> u64 {
fn default_db_path() -> String {
"/var/lib/helexa-bench/bench.sqlite".to_string()
}
fn default_concurrency_nonstreaming_levels() -> Vec<u32> {
vec![8]
}
fn default_api_enabled() -> bool {
true
}
@@ -243,6 +299,44 @@ fn default_capability_max_tokens() -> u64 {
// figment's, not ours, so suppress the lint here.
#[allow(clippy::result_large_err)]
mod tests {
/// #288: a config with no `[bench.principal]` must still parse — the
/// anonymous behaviour is the historical one and correct for
/// `openai` targets — but a configured one must round-trip, because
/// a silently-dropped principal reverts the measurement to the
/// anonymous-yield path without changing a visible number.
#[test]
fn principal_is_optional_and_round_trips() {
let parse = |t: &str| -> BenchConfig {
figment::Figment::new()
.merge(figment::providers::Toml::string(t))
.extract()
.expect("parse")
};
let none = parse("[bench]\nsamples_per_version = 3\n");
assert!(none.bench.principal.is_none());
let some = parse(
"[bench]\nsamples_per_version = 3\n\n\
[bench.principal]\naccount_id = \"acct\"\nkey_id = \"k1\"\n",
);
let p = some.bench.principal.expect("principal");
assert_eq!((p.account_id.as_str(), p.key_id.as_str()), ("acct", "k1"));
}
/// The non-streaming burst defaults on (#285). If it defaulted off,
/// the path that serialized completely would stay unmeasured on every
/// fleet that had not opted in — which is how it went unnoticed.
#[test]
fn nonstreaming_concurrency_defaults_to_the_top_level() {
let cfg: BenchConfig = figment::Figment::new()
.merge(figment::providers::Toml::string(
"[scenarios]\nprompt_sizes = [128]\n",
))
.extract()
.expect("parse");
assert_eq!(cfg.scenarios.concurrency_nonstreaming_levels, vec![8]);
}
use super::*;
use figment::Jail;
+1
View File
@@ -7,6 +7,7 @@
pub mod api;
pub mod client;
pub mod config;
pub mod regime;
pub mod report;
pub mod scenario;
pub mod store;
+148
View File
@@ -0,0 +1,148 @@
//! Measurement-regime boundaries — builds where a number changed
//! meaning rather than value.
//!
//! A trend line silently spans them, and every reader re-derives the
//! same thing: that a cliff was the instrument, not the engine. Three
//! have already cost real investigation time:
//!
//! - beast's `concurrency:8` fell 168.9 → 104.6 tok/s with `ttft_p95`
//! rising 0.53 → 11.77 s, and it read as a 38% throughput regression
//! until the one commit in the window turned out to be an admission
//! *policy* change that bench, being anonymous, was subject to (#288).
//! - quadbrat's `capability:debug-reason` TTFT fell 88.8 → 0.1 s, which
//! read as a latency win and was a redefinition of when the clock
//! stops.
//! - the same redefinition put decode rates at ~250 tok/s on a 3060
//! beforehand, by dividing reasoning-inclusive token counts by a
//! visible-content-only window.
//!
//! So the boundaries are declared, not remembered. Declared here rather
//! than in the frontend because `report` renders the same series into
//! `benchmarks.md` and needs the same caveats.
//!
//! ## What belongs here
//!
//! Only changes to **what a metric means**. A change that made the
//! server genuinely faster, slower, or differently behaved does *not*
//! belong — that is the signal these lines exist to keep readable. #280
//! (fetch the model's own chat template) moved quadbrat's
//! `completion_tokens` from 4096 to 1053 by ending a truncation loop;
//! that is a real behavioural change and stays unmarked.
//!
//! The identified/anonymous split is deliberately absent too: it is
//! recorded per-row in `runs.principal`, so the UI derives it from the
//! data instead of trusting a constant to stay true.
use serde::Serialize;
/// A build at which one or more metrics changed meaning.
#[derive(Debug, Clone, Serialize)]
pub struct MeasurementRegime {
/// Short SHA of the first build measured under the new semantics.
/// Matched against `SeriesPoint::git_sha`, so a boundary whose build
/// never appears in a selection simply does not draw.
pub first_sha: &'static str,
/// Terse label for the chart rule.
pub label: &'static str,
/// What changed, and why the step is not a performance result.
pub detail: &'static str,
/// Series keys the change affects, as the API/UI name them. A
/// boundary only draws on panels carrying one of these — marking
/// every panel would train people to ignore the rules.
pub affects: &'static [&'static str],
}
/// The declared boundaries, oldest first.
pub const REGIMES: &[MeasurementRegime] = &[
MeasurementRegime {
first_sha: "92dddc0",
label: "#262 anonymous callers yield",
detail: "Anonymous traffic became servable only from capacity left over \
once identified traffic is satisfied — capped below max_in_flight \
and parked at the class gate. An anonymous bench therefore \
characterises the yield policy rather than serving capacity. \
Steps here are admission policy, not the engine (#288).",
affects: &["decode", "ttft", "ttftP95", "queueWait", "rejected"],
},
MeasurementRegime {
first_sha: "0fe7aa3",
label: "#117 reasoning counts as liveness",
detail: "TTFT previously waited for the first *visible content* chunk, so \
on a thinking model it measured the whole reasoning span (88.8 s \
on quadbrat). It now stops at the first delta of any kind. The \
same change fixed decode rates computed by dividing \
reasoning-inclusive token counts by a content-only window, which \
had produced ~250 tok/s on a 3060.",
affects: &["decode", "ttft"],
},
];
/// Boundaries affecting `metric`, in declaration order.
pub fn regimes_for(metric: &str) -> Vec<&'static MeasurementRegime> {
REGIMES
.iter()
.filter(|r| r.affects.contains(&metric))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// A boundary that names no metric would draw nowhere and mislead by
/// omission; one naming an unknown key would draw nowhere and look
/// fine. Both are typos this catches.
#[test]
fn every_regime_affects_a_known_series_key() {
// The keys `Trends.tsx` maps `SeriesPoint` onto.
const KNOWN: &[&str] = &[
"decode",
"ttft",
"ttftP95",
"queueWait",
"rejected",
"prefillTps",
"reasoning",
"cached",
"completion",
"tpot",
];
for r in REGIMES {
assert!(
!r.affects.is_empty(),
"{} affects nothing, so it would never draw",
r.first_sha
);
for key in r.affects {
assert!(
KNOWN.contains(key),
"{} names unknown series key {key:?}",
r.first_sha
);
}
}
}
#[test]
fn regimes_for_selects_by_metric() {
assert!(regimes_for("ttft").iter().any(|r| r.first_sha == "0fe7aa3"));
assert!(
regimes_for("completion").is_empty(),
"#280 changed behaviour, not meaning — it must not be marked"
);
}
/// Short SHAs are matched against `SeriesPoint::git_sha`, which the
/// store records at the width `/version` reports.
#[test]
fn declared_shas_are_short_form() {
for r in REGIMES {
assert_eq!(
r.first_sha.len(),
7,
"{} is not a 7-char short sha; it would never match a series point",
r.first_sha
);
}
}
}
+403 -16
View File
@@ -9,6 +9,7 @@
use crate::config::ScenarioConfig;
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use cortex_core::entitlements::HEADER_KEY_ID;
use cortex_core::harness::ModelInfo;
use cortex_core::openai::ChatCompletionChunk;
use eventsource_stream::Eventsource;
@@ -46,6 +47,17 @@ pub struct RunCtx<'a> {
pub model_id: String,
pub max_tokens: u64,
pub timeout: Duration,
/// Base `x-helexa-key-id` bench presents (#288), when configured.
///
/// The client already sends this as a default header, so every
/// scenario is identified without having to remember. A concurrency
/// burst overrides it per stream — see
/// [`ConcurrencyScenario`] — because fair-share (#54) bounds one
/// principal to `max_per_principal` in-flight-or-queued requests,
/// which defaults to 2. Eight streams under one identity would be
/// capped at two and the rest rejected, measuring the fair-share cap
/// instead of the server.
pub principal_key_id: Option<String>,
}
/// Operator-felt metrics for a single measured request.
@@ -72,6 +84,34 @@ pub struct ScenarioMetrics {
pub decode_ms: Option<u64>,
/// Tokens submitted to prefill — the denominator for prefill tok/s.
pub prefill_tokens: Option<u64>,
/// Tokens spent inside the reasoning span, from
/// `usage.completion_tokens_details.reasoning_tokens` (#223). A
/// sub-count of `completion_tokens`.
///
/// Worth a trend of its own: on a reasoning model this is the
/// dominant cost driver and it moves independently of speed. A
/// template change, a sampling change (#283) or a reasoning-budget
/// change can double what the model thinks before answering while
/// every tok/s number stays flat — the bill doubles and no chart
/// moves.
pub reasoning_tokens: Option<u64>,
/// p95 inter-token arrival gap in milliseconds — the tail of the
/// stream's smoothness, client-observed.
///
/// `decode_tps` is a mean over the whole decode window, so a stream
/// that stalls for a second and then catches up is indistinguishable
/// from one that never stalled. This is the number a user actually
/// feels, and the one a batching stall or a mid-stream rebatch shows
/// up in. `None` for non-streaming, which has no inter-token gaps by
/// construction, and for streams too short to have a tail.
pub tpot_p95_ms: Option<f64>,
/// Prompt tokens served from neuron's prefix KV cache (#269), from
/// `usage.prompt_tokens_details.cached_tokens`.
///
/// The reason prefill timing varies between otherwise identical
/// samples. #269 exists because the saving was invisible and every
/// client reported a 0% hit rate; bench then threw the number away.
pub cached_tokens: Option<u64>,
// ── Concurrency / agentic-load fields (#89) ──────────────────────────
// Set only by the concurrency scenario, which fans out N simultaneous
// streams to characterize the real a0/hermes/opencode workload that
@@ -203,6 +243,9 @@ impl Scenario for ImageLatencyScenario {
.as_u64()
.map(|d| d + timing["decode_ms"].as_u64().unwrap_or(0)),
prefill_tokens: None,
reasoning_tokens: None,
cached_tokens: None,
tpot_p95_ms: None,
concurrency: None,
ttft_p95_s: None,
queue_wait_ms_median: None,
@@ -233,6 +276,18 @@ pub fn build_scenarios(cfg: &ScenarioConfig) -> Vec<Box<dyn Scenario>> {
id: format!("concurrency:{n}"),
concurrency: n,
approx_prompt_tokens: cfg.concurrency_prompt_tokens,
streaming: true,
}) as Box<dyn Scenario>);
}
// Non-streaming bursts (#285). A separate cell id so the two shapes
// never average together — they measured 1.00x against 3.98x before
// #285, and a combined number would have hidden both.
for &n in &cfg.concurrency_nonstreaming_levels {
scenarios.push(Box::new(ConcurrencyScenario {
id: format!("concurrency:{n}:nostream"),
concurrency: n,
approx_prompt_tokens: cfg.concurrency_prompt_tokens,
streaming: false,
}) as Box<dyn Scenario>);
}
for &side in &cfg.image_sizes {
@@ -257,7 +312,7 @@ pub fn build_scenarios(cfg: &ScenarioConfig) -> Vec<Box<dyn Scenario>> {
pub async fn cold_probe(ctx: &RunCtx<'_>) -> Result<ScenarioMetrics> {
let prompt = build_prompt(128);
let payload = chat_payload(ctx, &prompt);
tokio::time::timeout(ctx.timeout, stream_and_measure(ctx, &payload))
tokio::time::timeout(ctx.timeout, stream_and_measure(ctx, &payload, None))
.await
.map_err(|_| anyhow!("cold probe timed out after {:?}", ctx.timeout))?
}
@@ -295,7 +350,7 @@ impl Scenario for ChatLatencyScenario {
async fn run(&self, ctx: &RunCtx) -> Result<ScenarioMetrics> {
let prompt = build_prompt(self.approx_prompt_tokens);
let payload = chat_payload(ctx, &prompt);
let fut = stream_and_measure(ctx, &payload);
let fut = stream_and_measure(ctx, &payload, None);
tokio::time::timeout(ctx.timeout, fut)
.await
.map_err(|_| anyhow!("request timed out after {:?}", ctx.timeout))?
@@ -313,6 +368,16 @@ pub struct ConcurrencyScenario {
id: String,
concurrency: u32,
approx_prompt_tokens: u32,
/// Whether the burst streams (#285).
///
/// Both shapes matter and they are not interchangeable. Streaming
/// multiplexes through the batch engine; non-streaming did not until
/// #285, and serialized completely — 1.00x aggregate throughput at
/// every concurrency level against 3.98x streamed. A bench that only
/// streams cannot see that, which is why it went unmeasured: the
/// neuron reports `in_flight: 8`, admission accepts, `/health` looks
/// healthy, and throughput is single-stream.
streaming: bool,
}
#[async_trait]
@@ -327,13 +392,26 @@ impl Scenario for ConcurrencyScenario {
async fn run(&self, ctx: &RunCtx) -> Result<ScenarioMetrics> {
let prompt = build_prompt(self.approx_prompt_tokens);
let payload = chat_payload(ctx, &prompt);
let payload = if self.streaming {
chat_payload(ctx, &prompt)
} else {
nonstreaming_payload(ctx, &prompt)
};
// Fire all streams at once; each is independently timed and capped by
// the per-request timeout so one hung stream can't stall the burst.
// Fire all requests at once; each is independently timed and capped
// by the per-request timeout so one hung request can't stall the
// burst.
let burst_start = Instant::now();
let futs = (0..self.concurrency).map(|_| async {
tokio::time::timeout(ctx.timeout, stream_and_measure(ctx, &payload)).await
let streaming = self.streaming;
let payload = &payload;
let futs = (0..self.concurrency).map(move |i| async move {
// Each stream presents its own identity — see
// `with_stream_identity`.
if streaming {
tokio::time::timeout(ctx.timeout, stream_and_measure(ctx, payload, Some(i))).await
} else {
tokio::time::timeout(ctx.timeout, request_and_measure(ctx, payload, Some(i))).await
}
});
let results = futures::future::join_all(futs).await;
let burst_window = burst_start.elapsed().as_secs_f64();
@@ -351,7 +429,7 @@ impl Scenario for ConcurrencyScenario {
}
if streams.is_empty() {
return Err(anyhow!(
"all {} concurrent streams failed ({rejected} shed by admission)",
"all {} concurrent requests failed ({rejected} shed by admission)",
self.concurrency
));
}
@@ -380,9 +458,50 @@ impl Scenario for ConcurrencyScenario {
total_s: burst_window,
prompt_tokens: streams.iter().find_map(|m| m.prompt_tokens),
completion_tokens: total_tokens,
prefill_ms: None,
decode_ms: None,
prefill_tokens: None,
// Median server-side phase timing across the burst. These
// were `None`, which left the prefill-tok/s panel blank for
// exactly the cells where queue behaviour matters most, and
// made `queue_wait_ms` (TTFT minus server prefill, #85)
// uncomputable for a burst — the one number that separates
// "the server is slow" from "you were queued".
//
// Median rather than sum: every stream prefills its own copy
// of the same prompt, so the representative per-stream cost
// is the meaningful figure and it composes with `ttft_s`,
// which is also a median.
prefill_ms: median(
&streams
.iter()
.filter_map(|m| m.prefill_ms.map(|v| v as f64))
.collect::<Vec<_>>(),
)
.map(|v| v as u64),
decode_ms: median(
&streams
.iter()
.filter_map(|m| m.decode_ms.map(|v| v as f64))
.collect::<Vec<_>>(),
)
.map(|v| v as u64),
prefill_tokens: median(
&streams
.iter()
.filter_map(|m| m.prefill_tokens.map(|v| v as f64))
.collect::<Vec<_>>(),
)
.map(|v| v as u64),
// Summed across the burst, like `completion_tokens`: the cell
// describes the whole burst, so a per-stream figure here
// would not compose with it.
reasoning_tokens: sum_opt(&streams, |m| m.reasoning_tokens),
cached_tokens: sum_opt(&streams, |m| m.cached_tokens),
// The worst stream in the burst, not a median of medians:
// under load the question is how bad it got for somebody,
// and averaging tails is how a stall stays invisible.
tpot_p95_ms: streams
.iter()
.filter_map(|m| m.tpot_p95_ms)
.max_by(|a, b| a.total_cmp(b)),
concurrency: Some(self.concurrency),
ttft_p95_s: percentile(&ttfts, 95.0),
queue_wait_ms_median: median(&queue_waits),
@@ -393,6 +512,146 @@ impl Scenario for ConcurrencyScenario {
}
}
/// Sum an optional per-stream count across a burst, `None` when no
/// stream reported it — so "nobody measured it" stays distinguishable
/// from "measured as zero", the same distinction #269 exists to
/// preserve.
fn sum_opt(
streams: &[ScenarioMetrics],
f: impl Fn(&ScenarioMetrics) -> Option<u64>,
) -> Option<u64> {
let vals: Vec<u64> = streams.iter().filter_map(f).collect();
(!vals.is_empty()).then(|| vals.iter().sum())
}
/// Per-stream identity for a concurrency burst (#288/#54).
///
/// Fair-share bounds *one principal* to `max_per_principal`
/// in-flight-or-queued requests, defaulting to 2. A burst of 8 under a
/// single identity would therefore have 6 rejected with `PrincipalCap`,
/// and the scenario would measure the fair-share cap rather than the
/// server.
///
/// Giving each stream its own key is also the more faithful model: a
/// concurrency-8 burst is meant to stand in for eight simultaneous
/// *callers*, not one caller issuing eight requests. Fair-share is still
/// exercised — each identity is subject to it — just not self-inflicted.
fn with_stream_identity(
rb: reqwest::RequestBuilder,
ctx: &RunCtx<'_>,
stream: Option<u32>,
) -> reqwest::RequestBuilder {
match (ctx.principal_key_id.as_deref(), stream) {
(Some(base), Some(i)) => rb.header(HEADER_KEY_ID, format!("{base}-{i}")),
// No principal configured, or a single-request scenario: the
// client's default headers already carry the base identity.
_ => rb,
}
}
/// The non-streaming counterpart to [`chat_payload`] (#285).
///
/// `stream_options` is deliberately absent: usage is part of the body on
/// this shape, and sending the streaming-only option to a strict server
/// is a needless compatibility risk.
fn nonstreaming_payload(ctx: &RunCtx, prompt: &str) -> serde_json::Value {
json!({
"model": ctx.model_id,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": ctx.max_tokens,
"temperature": 0,
"stream": false,
})
}
/// Time one non-streaming chat completion (#285).
///
/// There is no first-chunk moment on this shape — the whole body arrives
/// at once — so `ttft_s` is the full request wall-clock rather than a
/// separate measurement. That is the honest reading: for a non-streaming
/// caller, time-to-first-token *is* time-to-everything, and it is exactly
/// what makes the serialization this scenario exists to detect so
/// expensive.
///
/// `decode_tps` is per-request; the burst aggregate is computed by the
/// caller over the whole window, the same way the streaming path does it.
async fn request_and_measure(
ctx: &RunCtx<'_>,
payload: &serde_json::Value,
stream: Option<u32>,
) -> Result<ScenarioMetrics> {
let start = Instant::now();
let resp = with_stream_identity(ctx.client.post(&ctx.chat_url), ctx, stream)
.json(payload)
.send()
.await
.context("sending non-streaming chat request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("upstream returned {status}: {}", body.trim()));
}
let v: serde_json::Value = resp
.json()
.await
.context("non-streaming chat response was not JSON")?;
let total_s = start.elapsed().as_secs_f64();
let usage = v.get("usage");
let completion_tokens = usage
.and_then(|u| u.get("completion_tokens"))
.and_then(|t| t.as_u64())
.unwrap_or(0);
let prompt_tokens = usage
.and_then(|u| u.get("prompt_tokens"))
.and_then(|t| t.as_u64());
let reasoning_tokens = usage
.and_then(|u| u.get("completion_tokens_details"))
.and_then(|d| d.get("reasoning_tokens"))
.and_then(|t| t.as_u64());
let cached_tokens = usage
.and_then(|u| u.get("prompt_tokens_details"))
.and_then(|d| d.get("cached_tokens"))
.and_then(|t| t.as_u64());
let timing = usage.and_then(|u| u.get("helexa_timing"));
let field = |name: &str| timing.and_then(|t| t.get(name)).and_then(|x| x.as_u64());
let (prefill_ms, decode_ms, prefill_tokens) = (
field("prefill_ms"),
field("decode_ms"),
field("prefill_tokens"),
);
// Prefer the server's own decode window when it reports one (#85);
// it excludes prefill, so it is comparable with the streaming path's
// decode-window rate rather than being diluted by it.
let decode_tps = match decode_ms {
Some(ms) if ms > 200 => Some(completion_tokens as f64 / (ms as f64 / 1000.0)),
_ if total_s > 0.2 => Some(completion_tokens as f64 / total_s),
_ => None,
};
Ok(ScenarioMetrics {
ttft_s: total_s,
decode_tps,
total_s,
prompt_tokens,
completion_tokens,
prefill_ms,
decode_ms,
prefill_tokens,
reasoning_tokens,
cached_tokens,
// Non-streaming has no inter-token gaps: the body arrives whole.
tpot_p95_ms: None,
concurrency: None,
ttft_p95_s: None,
queue_wait_ms_median: None,
rejected: None,
artifact: None,
image_units: None,
})
}
/// Quality probe (#91): runs a fixed prompt and stores the full generated
/// text as an artifact for later scoring (manual now, LLM-judge later). The
/// point is to compare reasoning/planning quality across models — the axis
@@ -425,7 +684,7 @@ impl Scenario for CapabilityScenario {
"stream": true,
"stream_options": {"include_usage": true},
});
let fut = stream_and_measure_inner(ctx, &payload, true);
let fut = stream_and_measure_inner(ctx, &payload, true, None);
tokio::time::timeout(ctx.timeout, fut)
.await
.map_err(|_| anyhow!("capability probe timed out after {:?}", ctx.timeout))?
@@ -468,8 +727,9 @@ fn percentile(values: &[f64], p: f64) -> Option<f64> {
async fn stream_and_measure(
ctx: &RunCtx<'_>,
payload: &serde_json::Value,
stream: Option<u32>,
) -> Result<ScenarioMetrics> {
stream_and_measure_inner(ctx, payload, false).await
stream_and_measure_inner(ctx, payload, false, stream).await
}
/// As [`stream_and_measure`] but accumulates the full visible text when
@@ -479,11 +739,10 @@ async fn stream_and_measure_inner(
ctx: &RunCtx<'_>,
payload: &serde_json::Value,
capture_text: bool,
stream_ix: Option<u32>,
) -> Result<ScenarioMetrics> {
let start = Instant::now();
let resp = ctx
.client
.post(&ctx.chat_url)
let resp = with_stream_identity(ctx.client.post(&ctx.chat_url), ctx, stream_ix)
.json(payload)
.send()
.await
@@ -503,6 +762,10 @@ async fn stream_and_measure_inner(
let mut prefill_ms: Option<u64> = None;
let mut decode_ms: Option<u64> = None;
let mut prefill_tokens: Option<u64> = None;
let mut reasoning_tokens: Option<u64> = None;
let mut cached_tokens: Option<u64> = None;
// Inter-token arrival gaps, for the p95 tail.
let mut gaps_ms: Vec<f64> = Vec::new();
let mut captured = String::new();
while let Some(event) = stream.next().await {
@@ -540,6 +803,11 @@ async fn stream_and_measure_inner(
if content.is_some() || reasoning.is_some() {
if first.is_none() {
first = Some(now);
} else if let Some(prev) = last {
// Gaps only *between* generated deltas: the wait for
// the first one is TTFT and belongs to prefill, not
// to stream smoothness.
gaps_ms.push(now.duration_since(prev).as_secs_f64() * 1000.0);
}
last = Some(now);
chunk_count += 1;
@@ -551,6 +819,14 @@ async fn stream_and_measure_inner(
if let Some(usage) = chunk.usage {
prompt_tokens = Some(usage.prompt_tokens);
completion_tokens = Some(usage.completion_tokens);
reasoning_tokens = usage
.completion_tokens_details
.as_ref()
.map(|d| d.reasoning_tokens);
cached_tokens = usage
.prompt_tokens_details
.as_ref()
.map(|d| d.cached_tokens);
if let Some(t) = usage.helexa_timing {
prefill_ms = Some(t.prefill_ms);
decode_ms = Some(t.decode_ms);
@@ -589,6 +865,9 @@ async fn stream_and_measure_inner(
prefill_ms,
decode_ms,
prefill_tokens,
reasoning_tokens,
cached_tokens,
tpot_p95_ms: percentile(&gaps_ms, 95.0),
// Concurrency fields unset on the single-request path; the
// concurrency scenario builds its own aggregate (#89).
concurrency: None,
@@ -604,6 +883,113 @@ async fn stream_and_measure_inner(
mod tests {
use super::*;
/// 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
/// blank under concurrency.
#[test]
fn a_burst_reports_median_phase_timing_not_none() {
let s = |prefill_ms, decode_ms, prefill_tokens| ScenarioMetrics {
ttft_s: 0.1,
decode_tps: Some(10.0),
total_s: 1.0,
prompt_tokens: Some(100),
completion_tokens: 10,
prefill_ms,
decode_ms,
prefill_tokens,
reasoning_tokens: None,
cached_tokens: None,
tpot_p95_ms: Some(5.0),
concurrency: None,
ttft_p95_s: None,
queue_wait_ms_median: None,
rejected: None,
artifact: None,
image_units: None,
};
let streams = [
s(Some(10), Some(100), Some(512)),
s(Some(20), Some(200), Some(512)),
s(Some(30), Some(300), Some(512)),
];
let med = |f: fn(&ScenarioMetrics) -> Option<u64>| {
median(
&streams
.iter()
.filter_map(|m| f(m).map(|v| v as f64))
.collect::<Vec<_>>(),
)
.map(|v| v as u64)
};
assert_eq!(med(|m| m.prefill_ms), Some(20));
assert_eq!(med(|m| m.decode_ms), Some(200));
// Every stream prefills the same prompt, so the median is the
// prompt size rather than a sum across the burst.
assert_eq!(med(|m| m.prefill_tokens), Some(512));
}
/// #288/#54: a burst must present one identity *per stream*.
///
/// Fair-share bounds a single principal to `max_per_principal`
/// in-flight-or-queued requests, which defaults to 2. Eight streams
/// under one key would have six rejected with `PrincipalCap`, and
/// `concurrency:8` would silently measure the fair-share cap instead
/// of the server — a worse failure than the anonymity it replaced,
/// because it looks like backpressure.
#[test]
fn a_burst_presents_one_identity_per_stream() {
let client = reqwest::Client::new();
let ctx = RunCtx {
client: &client,
chat_url: "http://x/v1/chat/completions".into(),
model_id: "m".into(),
max_tokens: 16,
timeout: Duration::from_secs(1),
principal_key_id: Some("fleet-benchmark".into()),
};
let key_of = |stream: Option<u32>| -> Option<String> {
with_stream_identity(client.post(&ctx.chat_url), &ctx, stream)
.build()
.expect("build")
.headers()
.get(HEADER_KEY_ID)
.map(|v| v.to_str().expect("utf8").to_string())
};
let keys: Vec<Option<String>> = (0..3).map(|i| key_of(Some(i))).collect();
assert_eq!(
keys,
vec![
Some("fleet-benchmark-0".to_string()),
Some("fleet-benchmark-1".to_string()),
Some("fleet-benchmark-2".to_string()),
]
);
// Distinct, or the cap bites anyway.
let uniq: std::collections::HashSet<_> = keys.iter().collect();
assert_eq!(uniq.len(), 3);
}
/// A single-request scenario adds no override: the client's default
/// headers already carry the base identity, and a per-request copy
/// would be a second place for it to drift.
#[test]
fn a_single_request_scenario_keeps_the_client_identity() {
let client = reqwest::Client::new();
let ctx = RunCtx {
client: &client,
chat_url: "http://x/v1/chat/completions".into(),
model_id: "m".into(),
max_tokens: 16,
timeout: Duration::from_secs(1),
principal_key_id: Some("fleet-benchmark".into()),
};
let req = with_stream_identity(client.post(&ctx.chat_url), &ctx, None)
.build()
.expect("build");
assert!(req.headers().get(HEADER_KEY_ID).is_none());
}
#[test]
fn prompt_grows_with_token_target() {
let small = build_prompt(128);
@@ -646,6 +1032,7 @@ mod tests {
prompt_sizes: vec![128],
max_tokens: 64,
concurrency_levels: vec![2, 8],
concurrency_nonstreaming_levels: vec![8],
concurrency_prompt_tokens: 512,
capability_probes: vec![CapabilityProbe {
name: "plan".into(),
+107 -3
View File
@@ -14,6 +14,22 @@ use std::path::Path;
#[derive(Debug, Clone)]
pub struct RunRecord {
pub ts: String, // RFC3339
/// Identity this sample was taken under (#288), `account/key`, or
/// `None` for an anonymous run. Anonymous samples measure the
/// admission yield policy rather than serving capacity once #262 is
/// in the build, so this is the column that says whether two rows
/// are comparable at all.
pub principal: Option<String>,
/// Tokens spent reasoning (#223) — the dominant cost driver on a
/// reasoning model, and one that moves independently of every tok/s
/// number on the dashboard.
pub reasoning_tokens: Option<u64>,
/// Prompt tokens served from the prefix cache (#269) — the reason
/// prefill timing varies between otherwise identical samples.
pub cached_tokens: Option<u64>,
/// p95 inter-token arrival gap (ms) — stream smoothness, which a
/// mean tok/s cannot express.
pub tpot_p95_ms: Option<f64>,
// target
pub target_name: String,
pub target_kind: String,
@@ -204,6 +220,18 @@ impl Store {
("artifact", "TEXT"),
("quality_score", "REAL"),
("scorer", "TEXT"),
// #288: which identity the sample was taken under. Rows
// predating this are NULL, which is honest — they were
// anonymous, and since #262 that measures a different
// thing. Without the column the 2026-08-18 discontinuity
// is folklore rather than data.
("principal", "TEXT"),
// #223 / #269: both are on the wire and were being discarded.
("reasoning_tokens", "INTEGER"),
("cached_tokens", "INTEGER"),
// #285: inter-token tail. `decode_tps` is a mean and hides
// a stall that later catches up.
("tpot_p95_ms", "REAL"),
],
)?;
Ok(())
@@ -264,6 +292,7 @@ impl Store {
swap_unload_ms, swap_load_ms,
artifact, quality_score, scorer,
image_units,
principal, reasoning_tokens, cached_tokens, tpot_p95_ms,
ok, error
) VALUES (
?1, ?2, ?3, ?4,
@@ -280,7 +309,8 @@ impl Store {
?42, ?43,
?44, ?45, ?46,
?47,
?48, ?49
?48, ?49, ?50, ?51,
?52, ?53
)",
params![
r.ts,
@@ -330,6 +360,10 @@ impl Store {
r.quality_score,
r.scorer,
r.image_units,
r.principal,
r.reasoning_tokens,
r.cached_tokens,
r.tpot_p95_ms,
r.ok as i64,
r.error,
],
@@ -703,7 +737,15 @@ impl Store {
}
};
let mut stmt = self.conn.prepare(
"SELECT git_sha, build_timestamp, package_version, ttft_s, decode_tps, total_s, ts
"SELECT git_sha, build_timestamp, package_version, ttft_s, decode_tps, total_s,
ttft_p95_s, queue_wait_ms, rejected,
-- Prefill rate derived here rather than stored: the
-- inputs are already recorded and a stored rate
-- would be a third thing to keep consistent.
CASE WHEN prefill_ms > 0 AND prefill_tokens IS NOT NULL
THEN (prefill_tokens * 1000.0) / prefill_ms END AS prefill_tps,
reasoning_tokens, cached_tokens, completion_tokens,
tpot_p95_ms, principal, ts
FROM runs
WHERE ok=1 AND target_name=?1 AND model_id=?2 AND scenario_id=?3
ORDER BY id",
@@ -717,7 +759,16 @@ impl Store {
ttft_s: r.get(3)?,
decode_tps: r.get(4)?,
total_s: r.get(5)?,
ts: r.get(6)?,
ttft_p95_s: r.get(6)?,
queue_wait_ms: r.get(7)?,
rejected: r.get(8)?,
prefill_tps: r.get(9)?,
reasoning_tokens: r.get(10)?,
cached_tokens: r.get(11)?,
completion_tokens: r.get(12)?,
tpot_p95_ms: r.get(13)?,
principal: r.get(14)?,
ts: r.get(15)?,
})
})?
.collect::<rusqlite::Result<_>>()?;
@@ -838,6 +889,33 @@ pub struct SeriesPoint {
pub ttft_s_median: Option<f64>,
pub decode_tps_median: Option<f64>,
pub total_s_median: Option<f64>,
// ── Added because the two above cannot show what changed ──────
/// Tail TTFT. The median above stayed flat at ~0.3 s across the
/// build where p95 went 0.53 s → 11.77 s (#288); charting only the
/// median is why that went unnoticed for a week.
pub ttft_p95_s_median: Option<f64>,
/// Admission queue wait — separates "the server is slow" from
/// "you were queued behind someone".
pub queue_wait_ms_median: Option<f64>,
/// Requests shed by admission during the burst.
pub rejected_median: Option<f64>,
/// Prefill throughput, derived from `prefill_tokens / prefill_ms`.
/// The other half of serving speed; only decode was ever charted.
pub prefill_tps_median: Option<f64>,
/// Tokens spent reasoning (#223) — the dominant cost driver on a
/// reasoning model, and independent of every speed metric here.
pub reasoning_tokens_median: Option<f64>,
/// Prompt tokens served from the prefix cache (#269).
pub cached_tokens_median: Option<f64>,
/// Total generated tokens — catches a model that starts truncating
/// or rambling, which no rate metric shows.
pub completion_tokens_median: Option<f64>,
/// p95 inter-token gap (ms) — stream smoothness.
pub tpot_p95_ms_median: Option<f64>,
/// Identity the samples were taken under (#288), or `None` if
/// anonymous. Anonymous and identified points are **not
/// comparable** once #262 is in the build.
pub principal: Option<String>,
pub samples: usize,
}
@@ -848,6 +926,15 @@ struct SeriesRaw {
ttft_s: Option<f64>,
decode_tps: Option<f64>,
total_s: Option<f64>,
ttft_p95_s: Option<f64>,
queue_wait_ms: Option<f64>,
rejected: Option<f64>,
prefill_tps: Option<f64>,
reasoning_tokens: Option<f64>,
cached_tokens: Option<f64>,
completion_tokens: Option<f64>,
tpot_p95_ms: Option<f64>,
principal: Option<String>,
ts: String,
}
@@ -880,6 +967,19 @@ fn aggregate_series(raws: Vec<SeriesRaw>) -> Vec<SeriesPoint> {
ttft_s_median: median(rows.iter().filter_map(|r| r.ttft_s)),
decode_tps_median: median(rows.iter().filter_map(|r| r.decode_tps)),
total_s_median: median(rows.iter().filter_map(|r| r.total_s)),
ttft_p95_s_median: median(rows.iter().filter_map(|r| r.ttft_p95_s)),
queue_wait_ms_median: median(rows.iter().filter_map(|r| r.queue_wait_ms)),
rejected_median: median(rows.iter().filter_map(|r| r.rejected)),
prefill_tps_median: median(rows.iter().filter_map(|r| r.prefill_tps)),
reasoning_tokens_median: median(rows.iter().filter_map(|r| r.reasoning_tokens)),
cached_tokens_median: median(rows.iter().filter_map(|r| r.cached_tokens)),
completion_tokens_median: median(rows.iter().filter_map(|r| r.completion_tokens)),
tpot_p95_ms_median: median(rows.iter().filter_map(|r| r.tpot_p95_ms)),
// Any row's principal: a build's samples are all taken
// under one identity, and a mixture would mean the
// config changed mid-build, which the chart should show
// as a discontinuity rather than average away.
principal: rows.iter().find_map(|r| r.principal.clone()),
samples: rows.len(),
};
(sort_key, point)
@@ -1281,6 +1381,10 @@ mod tests {
fn rec(target: &str, sha: &str, model: &str, scenario: &str, ok: bool) -> RunRecord {
RunRecord {
ts: "2026-06-13T00:00:00Z".into(),
principal: None,
reasoning_tokens: None,
cached_tokens: None,
tpot_p95_ms: None,
target_name: target.into(),
target_kind: "neuron".into(),
endpoint: "http://x:13131".into(),
+18 -1
View File
@@ -78,7 +78,10 @@ pub struct Sweeper {
impl Sweeper {
pub fn new(cfg: BenchConfig) -> Result<Self> {
let client = TargetClient::new(cfg.bench.request_timeout())?;
let client = TargetClient::with_principal(
cfg.bench.request_timeout(),
cfg.bench.principal.as_ref(),
)?;
let store = Store::open(&cfg.bench.db_path)?;
Ok(Sweeper { cfg, client, store })
}
@@ -169,6 +172,7 @@ impl Sweeper {
model_id: model.id.clone(),
max_tokens: self.cfg.scenarios.max_tokens,
timeout: self.cfg.bench.request_timeout(),
principal_key_id: self.cfg.bench.principal.as_ref().map(|p| p.key_id.clone()),
};
let cold = crate::scenario::cold_probe(&ctx).await;
let swap = SwapTiming { unload_ms, load_ms };
@@ -252,6 +256,7 @@ impl Sweeper {
model_id: model.id.clone(),
max_tokens: self.cfg.scenarios.max_tokens,
timeout: self.cfg.bench.request_timeout(),
principal_key_id: self.cfg.bench.principal.as_ref().map(|p| p.key_id.clone()),
};
// One unmeasured warmup when the cell is empty (matches
@@ -346,6 +351,15 @@ impl Sweeper {
RunRecord {
ts: chrono::Utc::now().to_rfc3339(),
// #288: stamp the identity this sample was taken under, so a
// later reader can tell an anonymous row from an identified
// one instead of inferring it from the date.
principal: self
.cfg
.bench
.principal
.as_ref()
.map(|p| format!("{}/{}", p.account_id, p.key_id)),
target_name: target.name.clone(),
target_kind: kind_str(target.kind).to_string(),
endpoint: target.endpoint.clone(),
@@ -383,6 +397,9 @@ impl Sweeper {
prefill_ms: m.and_then(|m| m.prefill_ms),
decode_ms: m.and_then(|m| m.decode_ms),
prefill_tokens: m.and_then(|m| m.prefill_tokens),
reasoning_tokens: m.and_then(|x| x.reasoning_tokens),
cached_tokens: m.and_then(|x| x.cached_tokens),
tpot_p95_ms: m.and_then(|x| x.tpot_p95_ms),
vram_used_mb: health.map(|h| h.vram_used_mb),
gpu_util_pct: health.map(|h| h.gpu_util_pct),
gpu_temp_c: health.map(|h| h.gpu_temp_c),
+4
View File
@@ -15,6 +15,10 @@ fn rec(
ok: bool,
) -> RunRecord {
RunRecord {
principal: None,
reasoning_tokens: None,
cached_tokens: None,
tpot_p95_ms: None,
ts: "2026-06-13T00:00:00Z".into(),
target_name: host.into(),
target_kind: "neuron".into(),
@@ -80,6 +80,7 @@ async fn spawn_mock(sha: &str) -> (String, Arc<Mutex<String>>) {
fn config_for(endpoint: String, db_path: String) -> BenchConfig {
BenchConfig {
bench: BenchSettings {
principal: None,
sweep_interval_secs: 1,
samples_per_version: 2,
iteration_pause_secs: 0,
@@ -91,6 +92,7 @@ fn config_for(endpoint: String, db_path: String) -> BenchConfig {
prompt_sizes: vec![128], // single scenario keeps assertions simple
max_tokens: 16,
concurrency_levels: Vec::new(),
concurrency_nonstreaming_levels: vec![],
concurrency_prompt_tokens: 512,
capability_probes: Vec::new(),
},
+5
View File
@@ -58,6 +58,11 @@ mod tests {
assert!(cfg.max_redirects > 0);
}
// The closure's error type is `figment::Error`, which is 208 bytes and
// not ours to shrink; `Jail::expect_with` dictates the signature. The
// usual remedy (box the Err) is not available through a third-party
// callback contract, and this is a test closure run once.
#[allow(clippy::result_large_err)]
#[test]
fn loads_toml_with_env_override() {
figment::Jail::expect_with(|jail| {
+1 -1
View File
@@ -1051,7 +1051,7 @@ mod tests {
// Fill the single queue slot with a waiter that parks on the semaphore.
let ctrl2 = Arc::clone(&ctrl);
let waiter = tokio::spawn(async move { ctrl2.enter(None).await.map(|p| drop(p)) });
let waiter = tokio::spawn(async move { ctrl2.enter(None).await.map(drop) });
// Give the waiter a moment to occupy the queue slot.
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(ctrl.queue_depth(), 1);
@@ -380,7 +380,23 @@ impl GatedDeltaNet {
self.head_v_dim,
)?;
// Stash the updated recurrent state for the next call.
self.state.recurrent_state = Some(new_state.to_dtype(dtype)?);
//
// The delta rule ran in f32 and `state_init` upcasts back to f32
// on entry, so storing at the model dtype is a round trip that
// buys only memory (#284). Decode takes it once per token.
//
// Storing f32 keeps `new_state` as-is, which on the CUDA decode
// path is the same buffer the kernel updated in place — so the
// stored state aliases it, where the `to_dtype` branch always
// allocated afresh. That is sound: the pre-update value is dead
// by this point, and prefix snapshots deep-copy
// (`snapshot_state`) precisely so they never share storage with
// a buffer a later forward will overwrite.
self.state.recurrent_state = Some(if gdn_state_f32() {
new_state
} else {
new_state.to_dtype(dtype)?
});
// core_attn_out: (B, H, L, D_v) → (B, L, H, D_v) → (B*L*H, D_v).
let core_attn_out = core_attn_out.transpose(1, 2)?.contiguous()?; // (B, L, H, D_v)
@@ -464,6 +480,33 @@ fn chunked_prefill_enabled() -> bool {
})
}
/// `NEURON_GDN_STATE_F32=1` keeps the recurrent state in f32 between
/// forwards instead of storing it at the model's activation dtype (#284).
///
/// The delta rule computes in f32 throughout, so storing the result at
/// BF16 and upcasting it again next call is a pure round-trip loss.
/// Measured on the chunked path: bit-exact carrying f32 at every chunk
/// size, against 1.3e-3 relative error after 16 prefill chunks and
/// **1.0e-2 after 4096 decode steps** — decode round-trips once per
/// token, across 48 of this arch's 64 layers.
///
/// Off by default on purpose. Every #283 measurement was taken against
/// the bf16 baseline, and flipping this silently would move the ground
/// underneath the sampling A/B and the decode-rate work (#282, #285)
/// at the same time. Turn it on deliberately, measure, then decide the
/// default.
///
/// Costs ~2× the recurrent slab: +37.7 MB per GPU per sequence on the
/// deployed 27B under TP=2, so +302 MB/GPU at `max_in_flight = 8`.
fn gdn_state_f32() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| {
std::env::var("NEURON_GDN_STATE_F32")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
})
}
/// Chunk-parallel gated delta rule — a faithful port of the HF
/// reference `torch_chunk_gated_delta_rule` (chunk_size = 64) in
/// `transformers/models/qwen3_5/modeling_qwen3_5.py`, minus the steps
@@ -1097,6 +1140,94 @@ mod tests {
assert!(d_state < 2e-4, "final state diverged: {d_state}");
}
/// #284: the state carried *between* forwards must not lose
/// precision. Every other parity test here is single-call and
/// all-f32, so none of them crosses the boundary where
/// `GatedDeltaNet::forward` stores the state at the model's
/// activation dtype — which is why the defect lived.
///
/// Two properties, one test:
///
/// 1. carrying f32, an N-chunk run equals a single call **exactly**,
/// so the chunk-boundary math itself is sound (this kills the
/// "chunked prefill corrupts state" hypothesis, #283 H1);
/// 2. carrying the model dtype loses a measurable, chunk-count-
/// dependent amount — the defect the `NEURON_GDN_STATE_F32` flag
/// exists to switch off.
///
/// Asserting (2) is deliberate: if a future change makes the bf16
/// carry lossless this test fails loudly and the flag can go, rather
/// than the flag quietly outliving its reason.
#[test]
fn state_carried_across_chunks_is_exact_in_f32_and_lossy_in_bf16() {
let (b, h, dk, dv) = (1, 2, 32, 32);
let dev = Device::Cpu;
let l = 1024;
// Long-memory decay: g near zero keeps the state alive across
// every boundary, which is both the realistic regime for
// long-context retrieval and the one that accumulates most.
let scale = 1.0 / (dk as f64).sqrt();
let q = Tensor::randn(0f32, 1.0, (b, h, l, dk), &dev).unwrap();
let q = (l2norm(&q, 1e-6).unwrap() * scale).unwrap();
let k = Tensor::randn(0f32, 1.0, (b, h, l, dk), &dev).unwrap();
let k = l2norm(&k, 1e-6).unwrap();
let v = (Tensor::randn(0f32, 1.0, (b, h, l, dv), &dev).unwrap() * 0.5).unwrap();
let g = (Tensor::rand(0f32, 1f32, (b, h, l), &dev).unwrap() * -0.02).unwrap();
let beta = Tensor::rand(0.05f32, 0.95f32, (b, h, l), &dev).unwrap();
let zeros = || Tensor::zeros((b, h, dk, dv), DType::F32, &dev).unwrap();
// Ground truth: the whole sequence in one call, f32 throughout.
let (out_ref, _) = run_chunk_gated_delta_rule(&q, &k, &v, &g, &beta, zeros()).unwrap();
// Replay it in `chunk`-sized pieces, carrying the state the way
// `forward` does.
let replay = |chunk: usize, carry: Option<DType>| {
let mut state = zeros();
let mut outs = Vec::new();
let mut pos = 0;
while pos < l {
let n = chunk.min(l - pos);
let take = |t: &Tensor| t.narrow(2, pos, n).unwrap().contiguous().unwrap();
let (o, s) = run_chunk_gated_delta_rule(
&take(&q),
&take(&k),
&take(&v),
&take(&g),
&take(&beta),
state,
)
.unwrap();
state = match carry {
Some(dt) => s.to_dtype(dt).unwrap().to_dtype(DType::F32).unwrap(),
None => s,
};
outs.push(o);
pos += n;
}
Tensor::cat(&outs, 2).unwrap()
};
for chunk in [128usize, 256, 512] {
let exact = max_abs_diff(&out_ref, &replay(chunk, None));
assert_eq!(
exact, 0.0,
"chunk={chunk}: an f32 state carry must reproduce the \
single-call result bit for bit, got {exact:e}"
);
}
// And the dtype round trip is what costs us. Compared on the
// final chunk, where the accumulated state has done the most work.
let tail = |t: &Tensor| t.narrow(2, l - 128, 128).unwrap().contiguous().unwrap();
let lossy = max_abs_diff(&tail(&out_ref), &tail(&replay(128, Some(DType::BF16))));
assert!(
lossy > 1e-5,
"storing the state at BF16 between chunks is expected to lose \
precision (#284); if this now passes losslessly the flag can \
be retired, got {lossy:e}"
);
}
/// Adversarially correlated inputs: near-identical keys with
/// beta ≈ 1 and negligible decay make the UT-transform matrix T
/// maximally coherent — raw powers of T grow combinatorially
@@ -562,9 +562,9 @@ mod tests {
.forward_batch_decode(&input, &positions, mask.as_ref())
.expect("batched step");
assert_eq!(h.dims()[0], 3);
for row in 0..3 {
for (row, exp) in expected.iter().enumerate() {
let got: Vec<f32> = h.i((row, 0, ..)).unwrap().to_vec1().unwrap();
let diff = max_abs_diff(&expected[row][t], &got);
let diff = max_abs_diff(&exp[t], &got);
assert!(diff < 1e-4, "row {row} step {t} diverged: {diff}");
}
}
@@ -610,9 +610,9 @@ mod tests {
let h = model
.forward_batch_decode(&input, &[3, 3], None)
.expect("step");
for row in 0..2 {
for (row, exp) in expected.iter().enumerate() {
let got: Vec<f32> = h.i((row, 0, ..)).unwrap().to_vec1().unwrap();
let diff = max_abs_diff(&expected[row], &got);
let diff = max_abs_diff(exp, &got);
assert!(diff < 1e-4, "row {row} diverged: {diff}");
}
}
@@ -674,9 +674,9 @@ mod tests {
let h = model
.forward_batch_decode(&input, &positions, mask.as_ref())
.expect("batched step");
for row in 0..2 {
for (row, exp) in expected.iter().enumerate() {
let got: Vec<f32> = h.i((row, 0, ..)).unwrap().to_vec1().unwrap();
let diff = max_abs_diff(&expected[row][t], &got);
let diff = max_abs_diff(&exp[t], &got);
assert!(diff < 1e-4, "batched row {row} step {t}: {diff}");
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -13,6 +13,7 @@ pub mod prefix_cache;
pub mod preflight;
pub mod preprocess;
pub mod reasoning_budget;
pub mod reasoning_effort;
pub mod sampling;
pub mod speculative;
pub mod tp;
+2
View File
@@ -401,6 +401,8 @@ mod tests {
quant: quant.map(String::from),
tensor_parallel: tp,
devices: None,
sampling: None,
preserve_thinking: None,
}
}
@@ -42,6 +42,31 @@ pub struct ReasoningBudgetLadder {
pub xhigh: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<usize>,
/// Tokens of the caller's **own** output budget held back for the
/// answer, when the caller names no explicit `reasoning.max_tokens`.
///
/// This is what an OpenAI-shaped client gets instead of the rungs
/// above, and it is deliberately not derived from effort. Effort is
/// a prompt instruction the model is trained to follow (#290);
/// making it *also* set a guillotine is what told Qwen3.8-27B to
/// reason at `xhigh` and then cut it off at `medium`'s 12,288.
///
/// A reserve has no such double meaning. The model thinks as long
/// as it likes within the budget the caller declared, minus enough
/// to answer — so the caller always gets an answer, and the size of
/// the think block stays the model's decision rather than ours.
///
/// Set to `0` to disable, restoring fully unbounded reasoning.
#[serde(default = "default_answer_reserve")]
pub answer_reserve_tokens: usize,
}
/// Enough for a substantive answer with tool calls, not so much that it
/// meaningfully shortens a think block: at the 32,768 budget pi sends,
/// this leaves ~28.7 k for reasoning — above the ~27.2 k a full agentic
/// design turn was measured to use.
fn default_answer_reserve() -> usize {
4_096
}
impl Default for ReasoningBudgetLadder {
@@ -58,6 +83,7 @@ impl Default for ReasoningBudgetLadder {
high: 32_768,
xhigh: None,
max: None,
answer_reserve_tokens: default_answer_reserve(),
}
}
}
@@ -0,0 +1,302 @@
//! Which reasoning-effort rungs a model actually supports, discovered
//! from the model itself (#290).
//!
//! ## Why this is discovered rather than configured
//!
//! `Qwen/Qwen3.8-27B` accepts exactly three efforts and hard-fails on
//! anything else:
//!
//! ```jinja
//! {%- set resolved_reasoning_effort = reasoning_effort|default('xhigh') %}
//! {%- if resolved_reasoning_effort not in ('xhigh', 'medium', 'low') %}
//! {{- raise_exception('Unexpected reasoning effort ...') }}
//! ```
//!
//! We previously advertised an invented ladder — `minimal`, `low`,
//! `medium`, `high` — of which two values the template rejects outright
//! and which omits `xhigh`, the model's own default and strongest rung.
//!
//! A caution about *why* that mattered, because the obvious story is
//! wrong. It is tempting to say clients read our advertisement and were
//! misled by it. pi-ai does not: its `getSupportedThinkingLevels` reads
//! `model.thinkingLevelMap`, a static field in the operator's
//! `models.json`, and treats `xhigh`/`max` as unavailable unless that
//! map names them explicitly. So a pi user saw exactly
//! `minimal, low, medium, high` — the invented ladder, to the value —
//! for a reason that had nothing to do with what we published.
//!
//! The coincidence is the trap: our wrong advertisement and pi's
//! default produced the same symptom, which made the advertisement look
//! causal. Publishing rungs the model actually accepts is still correct
//! — it is the truth, other clients may read it, and it is what makes
//! `apply_effort_kwarg` right — but do not assume a given client
//! consumes it. For pi the operator must also set `thinkingLevelMap`,
//! by hand, and nothing validates that they match.
//!
//! A hardcoded per-model table would drift the moment a model ships a
//! new template, which is exactly the class of defect #280 fixed by
//! fetching the template instead of assuming it. So the rungs come from
//! the artifact.
//!
//! ## How the probe works
//!
//! `raise_exception` is already wired to a render error, so the template
//! answers the question directly: render it once per candidate effort
//! and see which ones survive.
//!
//! Two ambiguities the probe has to resolve, or it would report
//! confident nonsense:
//!
//! 1. **A template that ignores `reasoning_effort` entirely** renders
//! successfully for every candidate. Supporting "all six" would be a
//! lie. So a candidate only counts when the rendered prompt actually
//! *differs* across efforts — if every render is identical the model
//! has no effort control and we advertise none.
//! 2. **The default** is whichever supported candidate renders
//! identically to a render with no effort supplied. Qwen3.8 defaults
//! to `xhigh`; without detecting that, a caller who says nothing gets
//! the strongest rung while we believe it got nothing.
use anyhow::Result;
use cortex_core::openai::{ChatMessage, MessageContent};
use serde_json::{Value, json};
/// Effort names in ascending order of thinking, as clients spell them.
///
/// Deliberately a superset of what any one model supports: this is the
/// *candidate* set the probe tries, and the conventional vocabulary
/// OpenAI-compatible clients use (pi-ai's `EXTENDED_THINKING_LEVELS`
/// minus `off`, which is not an effort but a separate switch handled by
/// `enable_thinking`).
pub const CANDIDATE_EFFORTS: &[&str] = &["minimal", "low", "medium", "high", "xhigh", "max"];
/// What one model's template accepts.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SupportedEfforts {
/// Accepted efforts, ascending, as the template spells them.
pub levels: Vec<String>,
/// The effort applied when the caller names none — the template's
/// own `|default(...)`. `None` when it cannot be determined.
pub default: Option<String>,
}
impl SupportedEfforts {
pub fn is_empty(&self) -> bool {
self.levels.is_empty()
}
pub fn supports(&self, effort: &str) -> bool {
self.levels.iter().any(|l| l == effort)
}
/// Map a caller's effort onto the nearest rung this model has.
///
/// Exact match wins. Otherwise walk **down** from the requested
/// level first, then up — a caller asking for more thinking than the
/// model offers should get the most it has, and a caller asking for
/// less should not be silently upgraded into a slower, costlier
/// response than they asked for.
///
/// Descending-first is the opposite of pi-ai's `clampThinkingLevel`,
/// which searches upward first. That is deliberate: pi is choosing
/// among levels it believes exist, whereas we are the ones who know
/// what exists, and over-delivering thinking is the failure that
/// costs the caller money and latency.
///
/// `None` when the model has no rungs at all.
pub fn nearest(&self, requested: &str) -> Option<&str> {
if self.levels.is_empty() {
return None;
}
if let Some(exact) = self.levels.iter().find(|l| *l == requested) {
return Some(exact.as_str());
}
let idx = CANDIDATE_EFFORTS.iter().position(|c| *c == requested)?;
for c in CANDIDATE_EFFORTS[..idx].iter().rev() {
if let Some(hit) = self.levels.iter().find(|l| l.as_str() == *c) {
return Some(hit.as_str());
}
}
for c in &CANDIDATE_EFFORTS[idx + 1..] {
if let Some(hit) = self.levels.iter().find(|l| l.as_str() == *c) {
return Some(hit.as_str());
}
}
None
}
}
/// Render a probe prompt at `effort`, or `None` if the template rejects
/// it.
fn probe_render(template: &str, effort: Option<&str>) -> Option<String> {
let messages = [ChatMessage {
role: "user".into(),
content: MessageContent::Text("probe".into()),
extra: Value::Null,
}];
let kwargs = match effort {
Some(e) => json!({ "reasoning_effort": e }),
None => Value::Null,
};
super::chat_template::render_chat_template(template, &messages, &Value::Null, &kwargs).ok()
}
/// Discover which efforts `template` accepts (#290).
///
/// Errors are not propagated: a template that cannot render even the
/// baseline probe yields no rungs, and the caller behaves as it did
/// before this existed.
pub fn probe(template: &str, model_id: &str) -> Result<SupportedEfforts> {
let Some(baseline) = probe_render(template, None) else {
tracing::debug!(
model = %model_id,
"reasoning effort: template does not render a baseline probe; advertising no rungs"
);
return Ok(SupportedEfforts::default());
};
let mut rendered: Vec<(&str, String)> = Vec::new();
for candidate in CANDIDATE_EFFORTS {
if let Some(out) = probe_render(template, Some(candidate)) {
rendered.push((candidate, out));
}
}
// A template that never reads `reasoning_effort` renders the same
// bytes whatever we pass. Advertising every candidate in that case
// would be the invented-ladder mistake again, in a new place.
let effort_aware = rendered.windows(2).any(|w| w[0].1 != w[1].1)
|| rendered.iter().any(|(_, out)| *out != baseline);
if !effort_aware {
tracing::debug!(
model = %model_id,
"reasoning effort: template ignores reasoning_effort; advertising no rungs"
);
return Ok(SupportedEfforts::default());
}
let default = rendered
.iter()
.find(|(_, out)| *out == baseline)
.map(|(name, _)| (*name).to_string());
let levels: Vec<String> = rendered
.iter()
.map(|(name, _)| (*name).to_string())
.collect();
tracing::info!(
model = %model_id,
levels = %levels.join(","),
default = ?default,
"reasoning effort: rungs discovered from the model's chat template (#290)"
);
Ok(SupportedEfforts { levels, default })
}
#[cfg(test)]
mod tests {
use super::*;
/// The probe against the **real** deployed Qwen3.8-27B template,
/// not a hand-written approximation of it. A synthetic template
/// proves the probe logic; only the shipped artifact proves the
/// answer, and the artifact is what #290 is about.
#[test]
fn the_real_qwen38_template_yields_low_medium_xhigh_defaulting_to_xhigh() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/data/qwen3.8-27b.chat_template.jinja"
);
let Ok(template) = std::fs::read_to_string(path) else {
// The fixture is checked in; if it is ever removed this test
// should fail loudly rather than silently pass.
panic!("missing fixture {path}");
};
let s = probe(&template, "Qwen/Qwen3.8-27B").expect("probe");
assert_eq!(
s.levels,
vec!["low", "medium", "xhigh"],
"the shipped template accepts exactly these"
);
assert_eq!(s.default.as_deref(), Some("xhigh"));
// The two rungs we used to advertise are rejected outright.
assert!(!s.supports("minimal"));
assert!(!s.supports("high"));
}
/// The shape Qwen3.8-27B actually ships: three rungs, `xhigh`
/// default, `raise_exception` on anything else.
const QWEN38: &str = r#"
{%- set resolved = reasoning_effort|default('xhigh') %}
{%- if resolved not in ('xhigh', 'medium', 'low') %}
{{- raise_exception('Unexpected reasoning effort ' ~ resolved) }}
{%- endif %}
{%- if resolved == 'xhigh' %}EFFORT-XHIGH{% elif resolved == 'low' %}EFFORT-LOW{% else %}EFFORT-MEDIUM{% endif %}
{%- for m in messages %}<|im_start|>{{ m.role }}
{{ m.content }}<|im_end|>
{%- endfor %}
"#;
/// A template with no effort control at all.
const PLAIN: &str = r#"
{%- for m in messages %}<|im_start|>{{ m.role }}
{{ m.content }}<|im_end|>
{%- endfor %}
"#;
#[test]
fn discovers_exactly_the_rungs_the_template_accepts() {
let s = probe(QWEN38, "test").expect("probe");
assert_eq!(s.levels, vec!["low", "medium", "xhigh"]);
assert!(!s.supports("minimal"), "template raises on minimal");
assert!(!s.supports("high"), "template raises on high");
}
/// Without this, a caller who names no effort silently receives the
/// strongest rung while we believe it received nothing — which is
/// how the xhigh instruction ended up on every request in #290.
#[test]
fn detects_the_templates_own_default() {
let s = probe(QWEN38, "test").expect("probe");
assert_eq!(s.default.as_deref(), Some("xhigh"));
}
/// A template that ignores the kwarg must advertise nothing rather
/// than appearing to support all six.
#[test]
fn a_template_without_effort_control_advertises_no_rungs() {
let s = probe(PLAIN, "test").expect("probe");
assert!(s.is_empty(), "got {:?}", s.levels);
assert_eq!(s.nearest("medium"), None);
}
#[test]
fn an_exact_match_is_returned_unchanged() {
let s = probe(QWEN38, "test").expect("probe");
assert_eq!(s.nearest("medium"), Some("medium"));
assert_eq!(s.nearest("xhigh"), Some("xhigh"));
}
/// `high` does not exist on this model. Descending first means it
/// resolves to `medium`, not `xhigh`: a caller is never given more
/// thinking (slower, dearer) than it asked for.
#[test]
fn an_unsupported_level_resolves_downward_not_upward() {
let s = probe(QWEN38, "test").expect("probe");
assert_eq!(s.nearest("high"), Some("medium"));
}
/// `minimal` is below every rung the model has, so there is nothing
/// to descend to and the nearest is the weakest available.
#[test]
fn a_level_below_every_rung_resolves_up_to_the_weakest() {
let s = probe(QWEN38, "test").expect("probe");
assert_eq!(s.nearest("minimal"), Some("low"));
}
#[test]
fn an_unknown_level_name_resolves_to_nothing_rather_than_a_guess() {
let s = probe(QWEN38, "test").expect("probe");
assert_eq!(s.nearest("turbo"), None);
}
}
+174 -4
View File
@@ -1,10 +1,29 @@
//! Sampling parameter resolution (#272).
//! Sampling parameter resolution (#272, #283).
//!
//! Three sources, in priority order:
//! Four sources, in priority order:
//!
//! 1. what the **request** asked for,
//! 2. what the **model** published in `generation_config.json`,
//! 3. a built-in fallback.
//! 2. what the **operator** set for this model (#283),
//! 3. what the **model** published in `generation_config.json`,
//! 4. a built-in fallback.
//!
//! Tier (2) exists because honouring the model's own file is right by
//! default and still wrong sometimes: `Qwen/Qwen3.8-27B` publishes
//! `temperature = 1.0`, which measured a 20% structural-defect rate on
//! ~2k-token code generation against 0/60 at `<= 0.6` (#283,
//! p = 0.0031). The operator's correction is explicit and lives in a
//! config file — never a heuristic that inspects the model and guesses,
//! which would be a footgun.
//!
//! The operator sits *below* the request on purpose. An explicit API
//! parameter that is silently ignored is a contract break; the observed
//! failure came from callers that named nothing, so a default-tier
//! override is sufficient to fix it.
//!
//! Mechanically, tier (2) is folded into tier (3) once at load time by
//! [`ModelGenerationDefaults::with_operator_override`], so the
//! per-request resolve path stays a two-tier lookup and every call site
//! gets the override for free — there is no site that can forget it.
//!
//! Before this, (2) did not exist and (3) was a hardcoded
//! `temperature = 0.7` with `top_p = None` — and `None` selects
@@ -98,6 +117,56 @@ impl ModelGenerationDefaults {
}
}
}
/// Overlay the operator's per-model override (#283) onto what the
/// model published, field by field.
///
/// Applied **once at load**, not per request, so the override cannot
/// be forgotten at one of the resolve sites — the same reasoning
/// that made #272 resolve sampling once rather than at eight
/// `LogitsProcessor` construction sites.
///
/// Each field overlays independently: setting only `temperature`
/// leaves the model's `top_p`/`top_k` in force, because an operator
/// correcting one number should not silently blank the other two
/// back to "no truncation".
///
/// Logged at `info` whenever it changes something — an operator
/// override that is in force but invisible is how a fleet ends up
/// with two configs that disagree and nobody noticing (#252).
pub fn with_operator_override(
mut self,
model_id: &str,
operator: Option<&cortex_core::harness::SamplingOverride>,
) -> Self {
let Some(op) = operator else {
return self;
};
if op.is_empty() {
return self;
}
let before = self.clone();
if let Some(t) = op.temperature {
self.temperature = Some(t);
}
if let Some(p) = op.top_p {
self.top_p = Some(p);
}
if let Some(k) = op.top_k {
self.top_k = Some(k);
}
tracing::info!(
model = %model_id,
model_temperature = ?before.temperature,
model_top_p = ?before.top_p,
model_top_k = ?before.top_k,
temperature = ?self.temperature,
top_p = ?self.top_p,
top_k = ?self.top_k,
"sampling: operator override applied over the model's published defaults (#283)"
);
self
}
}
/// What the caller asked for. All optional — absent means "no opinion",
@@ -176,6 +245,7 @@ impl SamplingParams {
mod tests {
use super::*;
use candle_transformers::generation::Sampling;
use cortex_core::harness::SamplingOverride;
fn qwen3_defaults() -> ModelGenerationDefaults {
ModelGenerationDefaults {
@@ -221,6 +291,106 @@ mod tests {
assert_eq!(p.top_p, Some(0.95));
}
// ── Operator override (#283) ──────────────────────────────
fn op(temperature: Option<f64>, top_p: Option<f64>, top_k: Option<usize>) -> SamplingOverride {
SamplingOverride {
temperature,
top_p,
top_k,
}
}
fn with_op(o: SamplingOverride) -> ModelGenerationDefaults {
qwen3_defaults().with_operator_override("Qwen/Qwen3.8-27B", Some(&o))
}
/// The #283 case: the model publishes `temperature = 1.0`, which
/// measured a 20% structural-defect rate on code generation. The
/// operator sets 0.6 and a silent caller must get 0.6.
#[test]
fn the_operator_override_replaces_what_the_model_published() {
let p = SamplingParams::resolve(
&RequestedSampling::default(),
&with_op(op(Some(0.6), None, None)),
42,
);
assert_eq!(p.temperature, 0.6);
}
/// Precedence is request > operator > model. An explicit API
/// parameter that is silently ignored is a contract break, so a
/// caller naming a temperature still wins over the operator.
#[test]
fn an_explicit_request_still_beats_the_operator() {
let requested = RequestedSampling {
temperature: Some(0.9),
..Default::default()
};
let p = SamplingParams::resolve(&requested, &with_op(op(Some(0.6), None, None)), 42);
assert_eq!(p.temperature, 0.9);
}
/// Overriding one field must not blank the others back to "no
/// truncation" — that was the #271 failure mode, reintroduced by a
/// wholesale replace instead of a field-by-field overlay.
#[test]
fn overriding_temperature_alone_leaves_top_p_and_top_k_intact() {
let d = with_op(op(Some(0.6), None, None));
assert_eq!(d.temperature, Some(0.6));
assert_eq!(d.top_p, Some(0.95), "the model's top_p must survive");
assert_eq!(d.top_k, Some(20), "the model's top_k must survive");
}
/// Each field overlays independently.
#[test]
fn every_field_can_be_overridden_independently() {
let d = with_op(op(None, Some(0.8), Some(40)));
assert_eq!(d.temperature, Some(1.0), "untouched, so the model's");
assert_eq!(d.top_p, Some(0.8));
assert_eq!(d.top_k, Some(40));
}
/// No override, and an override that sets nothing, must both be
/// indistinguishable from the pre-#283 behaviour.
#[test]
fn absent_or_empty_override_changes_nothing() {
let base = qwen3_defaults();
let none = qwen3_defaults().with_operator_override("m", None);
let empty =
qwen3_defaults().with_operator_override("m", Some(&SamplingOverride::default()));
for d in [none, empty] {
assert_eq!(d.temperature, base.temperature);
assert_eq!(d.top_p, base.top_p);
assert_eq!(d.top_k, base.top_k);
}
}
/// An operator override on a model that ships no
/// `generation_config.json` must still take effect, rather than
/// falling through to the built-in 0.7.
#[test]
fn the_override_applies_even_without_a_generation_config() {
let d = ModelGenerationDefaults::default()
.with_operator_override("m", Some(&op(Some(0.6), None, None)));
let p = SamplingParams::resolve(&RequestedSampling::default(), &d, 42);
assert_eq!(p.temperature, 0.6);
assert_ne!(p.temperature, FALLBACK_TEMPERATURE);
}
/// `temperature = 0` from an operator must select greedy decoding,
/// not be treated as "unset" by an `unwrap_or`-shaped bug.
#[test]
fn an_operator_temperature_of_zero_is_greedy_not_unset() {
let p = SamplingParams::resolve(
&RequestedSampling::default(),
&with_op(op(Some(0.0), None, None)),
42,
);
assert_eq!(p.temperature, 0.0);
assert_eq!(p.to_sampling(), Sampling::ArgMax);
}
/// A model with no `generation_config.json` must behave exactly as
/// it did before this landed, so nothing regresses on the models
/// that do not ship one.
+1 -1
View File
@@ -2445,7 +2445,7 @@ mod tests {
}
#[cfg(not(feature = "cuda"))]
fn vb_over(path: &std::path::Path) -> ShardedVarBuilder {
fn vb_over(path: &std::path::Path) -> ShardedVarBuilder<'_> {
// SAFETY: mmap of a file the test just wrote; nothing mutates it.
unsafe {
candle_nn::var_builder::ShardedSafeTensors::var_builder(
+275 -2
View File
@@ -27,8 +27,8 @@
//! propagate without us writing any logic.
use cortex_core::openai::{
ChatCompletionChunk, ChunkChoice, CompletionTokensDetails, HelexaTiming, PromptTokensDetails,
Usage,
ChatCompletionChoice, ChatCompletionChunk, ChatCompletionResponse, ChatMessage, ChunkChoice,
CompletionTokensDetails, HelexaTiming, MessageContent, PromptTokensDetails, Usage,
};
use serde_json::json;
use tokio::sync::mpsc;
@@ -450,10 +450,283 @@ fn usage_chunk(
}
}
/// Drain an [`InferenceEvent`] stream into a single non-streaming
/// [`ChatCompletionResponse`] (#285).
///
/// Non-streaming used to be a *separate inference path* that took the
/// per-model `inference_lock` for the whole request. That made it
/// invisible to the batch engine (#98), so concurrent non-streaming
/// callers serialized completely — measured on beast at 1.00x aggregate
/// throughput across 1/2/4/8 concurrency, against 3.98x for the same
/// work streamed. `max_in_flight` bought those requests admission and
/// nothing else.
///
/// Expressing non-streaming as "run the stream, buffer the result"
/// collapses the two paths onto one producer, so batching, prefix-cache
/// reuse, tool-call extraction, the reasoning governor and server-side
/// timing all apply identically to both. It also removes a second place
/// where the same wire object could be assembled differently — the
/// shape of defect #252 and #272 both were.
///
/// Semantics deliberately preserved from the path this replaces:
/// reasoning text is **dropped** from `content` (it is counted in
/// `usage.completion_tokens_details.reasoning_tokens` but never
/// rendered), and `finish_reason` becomes `"tool_calls"` whenever any
/// tool call was parsed.
///
/// Two fields that the old path hardcoded to `None` now populate,
/// because the events carry them and the struct already had the slots:
/// `prompt_tokens_details.cached_tokens` and `helexa_timing`.
///
/// A stream that ends without a `Finish` is an error, not an empty
/// reply: that is how a dropped engine slot or a fatal worker fault
/// presents, and returning a 200 with partial text would hide it.
pub async fn collect_chat_completion(
mut rx: mpsc::Receiver<InferenceEvent>,
id: String,
created: u64,
model_id: String,
) -> Result<ChatCompletionResponse, String> {
let mut content = String::new();
let mut tool_calls: Vec<serde_json::Value> = Vec::new();
let mut finish: Option<(FinishReason, u32, u32, u32, u32, Option<FinishTiming>)> = None;
while let Some(event) = rx.recv().await {
match event {
InferenceEvent::Start => {}
InferenceEvent::TextDelta(t) => content.push_str(&t),
// Counted, never rendered — see the doc comment.
InferenceEvent::ReasoningDelta(_) => {}
InferenceEvent::ToolCall {
index,
id: call_id,
name,
arguments,
} => tool_calls.push(json!({
"index": index,
"id": call_id,
"type": "function",
"function": { "name": name, "arguments": arguments },
})),
InferenceEvent::Finish {
reason,
prompt_tokens,
completion_tokens,
reasoning_tokens,
cached_tokens,
timing,
} => {
finish = Some((
reason,
prompt_tokens,
completion_tokens,
reasoning_tokens,
cached_tokens,
timing,
));
break;
}
}
}
let Some((reason, prompt_tokens, completion_tokens, reasoning_tokens, cached_tokens, timing)) =
finish
else {
return Err(
"inference stream ended without a finish event (engine slot dropped?)".to_string(),
);
};
// A parsed tool call outranks whatever the sampler stopped on, so a
// model that emits a call and then hits EOS still reports
// `tool_calls` and the client dispatches instead of rendering.
let finish_reason = if tool_calls.is_empty() {
reason.as_openai_str().to_string()
} else {
"tool_calls".to_string()
};
let message_extra = if tool_calls.is_empty() {
serde_json::Value::Object(Default::default())
} else {
json!({ "tool_calls": tool_calls })
};
Ok(ChatCompletionResponse {
id,
object: "chat.completion".into(),
created,
model: model_id,
choices: vec![ChatCompletionChoice {
index: 0,
message: ChatMessage {
role: "assistant".into(),
content: MessageContent::Text(content),
extra: message_extra,
},
finish_reason: Some(finish_reason),
extra: serde_json::Value::Object(Default::default()),
}],
usage: Some(Usage {
prompt_tokens: prompt_tokens as u64,
completion_tokens: completion_tokens as u64,
total_tokens: (prompt_tokens + completion_tokens) as u64,
completion_tokens_details: (reasoning_tokens > 0).then_some(CompletionTokensDetails {
reasoning_tokens: reasoning_tokens as u64,
}),
prompt_tokens_details: (cached_tokens > 0).then_some(PromptTokensDetails {
cached_tokens: cached_tokens as u64,
}),
helexa_timing: timing.map(|t| HelexaTiming {
prefill_ms: t.prefill_ms as u64,
decode_ms: t.decode_ms as u64,
prefill_tokens: t.prefill_tokens as u64,
}),
}),
extra: serde_json::Value::Object(Default::default()),
})
}
#[cfg(test)]
mod tests {
use super::*;
// ── Non-streaming collector (#285) ────────────────────────────
async fn collect_from(events: Vec<InferenceEvent>) -> Result<ChatCompletionResponse, String> {
let (tx, rx) = mpsc::channel(64);
for e in events {
tx.send(e).await.expect("send");
}
drop(tx);
collect_chat_completion(rx, "chatcmpl-test".into(), 1_700_000_000, "m".into()).await
}
fn finish(reason: FinishReason) -> InferenceEvent {
InferenceEvent::Finish {
reason,
prompt_tokens: 10,
completion_tokens: 4,
reasoning_tokens: 0,
cached_tokens: 0,
timing: None,
}
}
#[tokio::test]
async fn collector_concatenates_text_deltas() {
let r = collect_from(vec![
InferenceEvent::Start,
InferenceEvent::TextDelta("hel".into()),
InferenceEvent::TextDelta("lo".into()),
finish(FinishReason::Stop),
])
.await
.expect("collected");
let c = &r.choices[0];
assert!(matches!(&c.message.content, MessageContent::Text(t) if t == "hello"));
assert_eq!(c.finish_reason.as_deref(), Some("stop"));
assert_eq!(r.object, "chat.completion");
}
/// Reasoning is counted but never rendered — the behaviour of the
/// path this replaced. A regression here would leak scratchpad into
/// `content` for every non-streaming caller.
#[tokio::test]
async fn collector_drops_reasoning_text_but_keeps_the_count() {
let (tx, rx) = mpsc::channel(64);
tx.send(InferenceEvent::ReasoningDelta("secret".into()))
.await
.expect("send");
tx.send(InferenceEvent::TextDelta("answer".into()))
.await
.expect("send");
tx.send(InferenceEvent::Finish {
reason: FinishReason::Stop,
prompt_tokens: 10,
completion_tokens: 9,
reasoning_tokens: 5,
cached_tokens: 0,
timing: None,
})
.await
.expect("send");
drop(tx);
let r = collect_chat_completion(rx, "id".into(), 1, "m".into())
.await
.expect("collected");
let c = &r.choices[0];
assert!(matches!(&c.message.content, MessageContent::Text(t) if t == "answer"));
let u = r.usage.expect("usage");
assert_eq!(
u.completion_tokens_details
.expect("details")
.reasoning_tokens,
5
);
}
/// A parsed tool call outranks the sampler's stop reason, matching
/// the streaming projector.
#[tokio::test]
async fn collector_promotes_finish_reason_to_tool_calls() {
let r = collect_from(vec![
InferenceEvent::ToolCall {
index: 0,
id: "call_1".into(),
name: "read_file".into(),
arguments: r#"{"path":"a.txt"}"#.into(),
},
finish(FinishReason::Stop),
])
.await
.expect("collected");
let c = &r.choices[0];
assert_eq!(c.finish_reason.as_deref(), Some("tool_calls"));
let calls = c.message.extra.get("tool_calls").expect("tool_calls");
assert_eq!(calls[0]["function"]["name"], "read_file");
assert_eq!(calls[0]["type"], "function");
assert_eq!(calls[0]["id"], "call_1");
}
/// cached_tokens and helexa_timing were hardcoded to `None` on the
/// old non-streaming path; the events carried them all along.
#[tokio::test]
async fn collector_surfaces_cached_tokens_and_timing() {
let (tx, rx) = mpsc::channel(8);
tx.send(InferenceEvent::Finish {
reason: FinishReason::Stop,
prompt_tokens: 100,
completion_tokens: 5,
reasoning_tokens: 0,
cached_tokens: 64,
timing: Some(FinishTiming {
prefill_ms: 12,
decode_ms: 34,
prefill_tokens: 100,
}),
})
.await
.expect("send");
drop(tx);
let r = collect_chat_completion(rx, "id".into(), 1, "m".into())
.await
.expect("collected");
let u = r.usage.expect("usage");
assert_eq!(u.prompt_tokens_details.expect("details").cached_tokens, 64);
let t = u.helexa_timing.expect("timing");
assert_eq!((t.prefill_ms, t.decode_ms, t.prefill_tokens), (12, 34, 100));
}
/// A dropped engine slot ends the channel with no Finish. Returning
/// a 200 with partial text would hide a fatal worker fault.
#[tokio::test]
async fn collector_errors_when_the_stream_ends_without_finish() {
let err = collect_from(vec![InferenceEvent::TextDelta("partial".into())])
.await
.expect_err("must not succeed");
assert!(err.contains("without a finish"), "unexpected: {err}");
}
/// Drain the projection's output into a Vec for assertion.
async fn collect(mut rx: mpsc::Receiver<ChatCompletionChunk>) -> Vec<ChatCompletionChunk> {
let mut out = Vec::new();
+149 -14
View File
@@ -747,6 +747,31 @@ async fn run_projection(
return;
}
// No `Finish` means the producer died rather than finished — the
// poisoned-model path named above, an OOM, a dropped worker. This
// used to default to `FinishReason::Stop`, which told the caller a
// crash was a complete answer: pi-ai maps `status: "completed"` to
// `stopReason: "stop"` and treats whatever partial text arrived as
// the model's considered reply.
//
// Decided here, before the message item is opened, so a failed
// stream does not leave an item open that nothing will ever close.
let Some(reason) = finish else {
let _ = tx
.send(ResponseStreamFrame {
event_name: events::FAILED,
data: json!({
"response": failed_response_shell(
&meta,
"server_error",
"inference ended before the model finished",
)
}),
})
.await;
return;
};
// A stream can end while still thinking — a reasoning model that
// exhausts max_output_tokens mid-block never emits visible text.
// Close the item so the client sees a finished response rather than
@@ -761,7 +786,6 @@ async fn run_projection(
return;
}
let reason = finish.unwrap_or(FinishReason::Stop);
let _ = emit_finish_frames(
&tx,
&meta,
@@ -777,6 +801,23 @@ async fn run_projection(
.await;
}
/// The `response` payload for a [`events::FAILED`] frame.
///
/// pi-ai renders this as `` `${error.code}: ${error.message}` `` and
/// throws, which is the correct outcome — the request genuinely failed
/// — and is strictly better than a silent success.
fn failed_response_shell(meta: &ResponseMeta, code: &str, message: &str) -> Value {
json!({
"id": meta.response_id,
"object": "response",
"created_at": meta.created_at,
"status": "failed",
"model": meta.model_id,
"output": [],
"error": { "code": code, "message": message },
})
}
/// Streaming state for a reasoning model's think block (#267).
///
/// The block is its own output item, ahead of the message, so the
@@ -1043,7 +1084,14 @@ async fn emit_finish_frames(
}),
},
ResponseStreamFrame {
event_name: events::COMPLETED,
// `response.incomplete` when we stopped short, so a client
// keyed on the event name rather than on `response.status`
// still sees a terminal frame instead of timing out.
event_name: if status == "incomplete" {
events::INCOMPLETE
} else {
events::COMPLETED
},
data: json!({
"response": response_shell(meta, status, &output_items, usage)
}),
@@ -1068,6 +1116,15 @@ fn response_shell(
obj.insert("object".into(), Value::String("response".into()));
obj.insert("created_at".into(), json!(meta.created_at));
obj.insert("status".into(), Value::String(status.into()));
// Why we stopped short. A client cannot distinguish an honest
// truncation from a protocol fault without this, and pi-ai treats
// the difference as continue-vs-halt.
if status == "incomplete" {
obj.insert(
"incomplete_details".into(),
json!({ "reason": "max_output_tokens" }),
);
}
obj.insert("model".into(), Value::String(meta.model_id.clone()));
obj.insert("output".into(), Value::Array(output.to_vec()));
if let Some(u) = usage {
@@ -1138,6 +1195,10 @@ pub fn build_response(
}],
status,
}],
// Same contract as the streaming shell: a truncated response
// says so, or the caller cannot tell it from a fault.
incomplete_details: matches!(reason, FinishReason::Length)
.then(cortex_core::responses::IncompleteDetails::max_output_tokens),
usage,
}
}
@@ -2003,9 +2064,19 @@ mod tests {
names.contains(&events::CONTENT_PART_ADDED),
"message item must still be opened so the finish frames refer to something"
);
assert_eq!(names.last(), Some(&events::COMPLETED));
// This is the live failure in miniature: the model spent its
// whole budget thinking and produced almost no visible text.
// The stream must still close, and must say *why* it stopped —
// this frame used to be named `response.completed` while
// carrying `status: "incomplete"`, an inconsistency that cost a
// real agentic session its run.
assert_eq!(names.last(), Some(&events::INCOMPLETE));
let last = frames.last().unwrap();
assert_eq!(last.data["response"]["status"], "incomplete");
assert_eq!(
last.data["response"]["incomplete_details"]["reason"],
"max_output_tokens"
);
}
/// Tool calls sit after the message, which sits after any reasoning
@@ -2299,29 +2370,93 @@ mod tests {
.unwrap();
drop(tx);
let frames = collect(out).await;
// The terminal frame is `response.incomplete`, not
// `response.completed` — a client keyed on the event name must
// still see an end to the stream.
assert!(
!frames.iter().any(|f| f.event_name == events::COMPLETED),
"a truncated response must not announce itself as completed"
);
let terminal = frames
.iter()
.find(|f| f.event_name == events::INCOMPLETE)
.expect("a terminal frame is required");
assert_eq!(terminal.data["response"]["status"], "incomplete");
// The field that decides whether a client recovers or halts.
assert_eq!(
terminal.data["response"]["incomplete_details"]["reason"], "max_output_tokens",
"pi-ai maps `incomplete` with no reason to stopReason:error \
and ends the agent loop; with max_output_tokens it maps to \
stopReason:length, fails truncated tool calls safely, and \
takes another turn"
);
}
/// The converse: a normal finish must stay `response.completed`
/// and carry no reason, or every client sees phantom truncation.
#[tokio::test]
async fn a_normal_finish_carries_no_incomplete_reason() {
let (tx, rx) = mpsc::channel::<InferenceEvent>(8);
let out = project_responses_stream(rx, meta());
tx.send(InferenceEvent::Start).await.unwrap();
tx.send(InferenceEvent::Finish {
reason: FinishReason::Stop,
prompt_tokens: 0,
completion_tokens: 0,
reasoning_tokens: 0,
cached_tokens: 0,
timing: None,
})
.await
.unwrap();
drop(tx);
let frames = collect(out).await;
let completed = frames
.iter()
.find(|f| f.event_name == events::COMPLETED)
.unwrap();
assert_eq!(completed.data["response"]["status"], "incomplete");
.expect("a normal finish stays `response.completed`");
assert_eq!(completed.data["response"]["status"], "completed");
assert!(
completed.data["response"]
.get("incomplete_details")
.is_none(),
"a completed response must not carry an incomplete reason"
);
}
/// A producer that drops without ever sending `Finish` did not
/// finish — it died (poisoned model, OOM, dropped worker). The
/// stream must still terminate, and must terminate as a *failure*.
///
/// This previously asserted `COMPLETED`, encoding the defect: a
/// crashed inference was announced as a complete response, so a
/// client took whatever partial text had arrived to be the model's
/// considered answer instead of retrying.
#[tokio::test]
async fn synthesises_start_frames_when_producer_skips_start() {
// A producer that drops without sending Start (poisoned
// model, immediate disconnect, …) should still produce a
// coherent stream — the projector synthesises the
// mandatory header frames before COMPLETED so the
// consumer never sees an output_text.done without a
// matching content_part.added.
async fn a_producer_that_dies_terminates_the_stream_as_failed() {
let (tx, rx) = mpsc::channel::<InferenceEvent>(8);
let out = project_responses_stream(rx, meta());
drop(tx);
let frames = collect(out).await;
let names: Vec<&str> = frames.iter().map(|f| f.event_name).collect();
// The shell still opens, so the failure refers to a response.
assert!(names.contains(&events::CREATED));
assert!(names.contains(&events::COMPLETED));
assert!(names.contains(&events::OUTPUT_TEXT_DONE));
assert!(
!names.contains(&events::COMPLETED),
"a crash must never be announced as a completed response"
);
let failed = frames
.iter()
.find(|f| f.event_name == events::FAILED)
.expect("the stream must terminate, or the client waits out its idle timeout");
assert_eq!(failed.data["response"]["status"], "failed");
assert_eq!(failed.data["response"]["error"]["code"], "server_error");
assert!(
failed.data["response"]["error"]["message"]
.as_str()
.is_some_and(|m| !m.is_empty()),
"pi-ai renders `code: message`, so an empty message helps nobody"
);
}
#[tokio::test]
+8
View File
@@ -29,6 +29,8 @@ async fn test_load_default_models_skips_unknown_harness() {
quant: None,
tensor_parallel: None,
devices: None,
sampling: None,
preserve_thinking: None,
},
ModelSpec {
model_id: "model-b".into(),
@@ -36,6 +38,8 @@ async fn test_load_default_models_skips_unknown_harness() {
quant: None,
tensor_parallel: None,
devices: None,
sampling: None,
preserve_thinking: None,
},
];
@@ -95,6 +99,8 @@ async fn test_load_default_models_skipped_on_driver_mismatch() {
quant: Some("q6k".into()),
tensor_parallel: Some(2),
devices: None,
sampling: None,
preserve_thinking: None,
}];
let activation = ActivationTracker::new(&specs);
let reason = "host NVIDIA driver/library mismatch (userspace NVML 580.159 vs loaded \
@@ -196,6 +202,8 @@ fn qwen_spec() -> ModelSpec {
quant: None,
tensor_parallel: None,
devices: None,
sampling: None,
preserve_thinking: None,
}
}
+2
View File
@@ -53,6 +53,8 @@ async fn test_candle_qwen3_load_unload_lifecycle() {
quant: Some(quant),
tensor_parallel: None,
devices: Some(vec![0]),
sampling: None,
preserve_thinking: None,
};
registry
@@ -0,0 +1,170 @@
{%- set image_count = namespace(value=0) %}
{%- set video_count = namespace(value=0) %}
{%- macro render_content(content, do_vision_count, is_system_content=false) %}
{%- if content is string %}
{{- content }}
{%- elif content is iterable and content is not mapping %}
{%- for item in content %}
{%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
{%- if is_system_content %}
{{- raise_exception('System message cannot contain images.') }}
{%- endif %}
{%- if do_vision_count %}
{%- set image_count.value = image_count.value + 1 %}
{%- endif %}
{%- if add_vision_id %}
{{- 'Picture ' ~ image_count.value ~ ': ' }}
{%- endif %}
{{- '<|vision_start|><|image_pad|><|vision_end|>' }}
{%- elif 'video' in item or item.type == 'video' %}
{%- if is_system_content %}
{{- raise_exception('System message cannot contain videos.') }}
{%- endif %}
{%- if do_vision_count %}
{%- set video_count.value = video_count.value + 1 %}
{%- endif %}
{%- if add_vision_id %}
{{- 'Video ' ~ video_count.value ~ ': ' }}
{%- endif %}
{{- '<|vision_start|><|video_pad|><|vision_end|>' }}
{%- elif 'text' in item %}
{{- item.text }}
{%- else %}
{{- raise_exception('Unexpected item type in content.') }}
{%- endif %}
{%- endfor %}
{%- elif content is none or content is undefined %}
{{- '' }}
{%- else %}
{{- raise_exception('Unexpected content type.') }}
{%- endif %}
{%- endmacro %}
{%- if not messages %}
{{- raise_exception('No messages provided.') }}
{%- endif %}
{%- set reasoning_instructions = '' %}
{%- if enable_thinking is undefined or enable_thinking is true %}
{%- set resolved_reasoning_effort = reasoning_effort|default('xhigh') %}
{%- if resolved_reasoning_effort not in ('xhigh', 'medium', 'low') %}
{{- raise_exception('Unexpected reasoning effort ' ~ reasoning_effort ~ '. Supported types are xhigh (default), medium, and low.') }}
{%- endif %}
{%- if resolved_reasoning_effort == 'xhigh' %}
{%- set reasoning_instructions = 'Reasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.' %}
{%- elif resolved_reasoning_effort == 'low' %}
{%- set reasoning_instructions = 'Reasoning effort is set to low. Keep your thinking brief and focused, moving directly to the conclusion without unnecessary elaboration.' %}
{%- endif %}
{%- endif %}
{%- if tools and tools is iterable and tools is not mapping %}
{{- '<|im_start|>system\n' }}
{%- if reasoning_instructions %}
{{- reasoning_instructions + '\n\n' }}
{%- endif %}
{{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
{%- for tool in tools %}
{{- "\n" }}
{{- tool | tojson }}
{%- endfor %}
{{- "\n</tools>" }}
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
{%- if messages[0].role == 'system' %}
{%- set content = render_content(messages[0].content, false, true)|trim %}
{%- if content %}
{{- '\n\n' + content }}
{%- endif %}
{%- endif %}
{{- '<|im_end|>\n' }}
{%- else %}
{%- if messages[0].role == 'system' %}
{%- set content = render_content(messages[0].content, false, true)|trim %}
{%- if content %}
{{- '<|im_start|>system\n' + (reasoning_instructions + '\n\n' if reasoning_instructions else '') + content + '<|im_end|>\n' }}
{%- elif reasoning_instructions %}
{{- '<|im_start|>system\n' + reasoning_instructions + '<|im_end|>\n' }}
{%- endif %}
{%- elif reasoning_instructions %}
{{- '<|im_start|>system\n' + reasoning_instructions + '<|im_end|>\n' }}
{%- endif %}
{%- endif %}
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
{%- for message in messages[::-1] %}
{%- set index = (messages|length - 1) - loop.index0 %}
{%- if ns.multi_step_tool and message.role == "user" %}
{%- set content = render_content(message.content, false)|trim %}
{%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
{%- set ns.multi_step_tool = false %}
{%- set ns.last_query_index = index %}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- if ns.multi_step_tool %}
{{- raise_exception('No user query found in messages.') }}
{%- endif %}
{%- for message in messages %}
{%- set content = render_content(message.content, true)|trim %}
{%- if message.role == "system" %}
{%- if not loop.first %}
{{- raise_exception('System message must be at the beginning.') }}
{%- endif %}
{%- elif message.role == "user" %}
{{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
{%- elif message.role == "assistant" %}
{%- set reasoning_content = '' %}
{%- if message.reasoning_content is string %}
{%- set reasoning_content = message.reasoning_content %}
{%- endif %}
{%- set reasoning_content = reasoning_content|trim %}
{%- if preserve_thinking is undefined or preserve_thinking is true or loop.index0 > ns.last_query_index %}
{{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
{%- else %}
{{- '<|im_start|>' + message.role + '\n' + content }}
{%- endif %}
{%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
{%- for tool_call in message.tool_calls %}
{%- if tool_call.function is defined %}
{%- set tool_call = tool_call.function %}
{%- endif %}
{%- if loop.first %}
{%- if content|trim %}
{{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
{%- else %}
{{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
{%- endif %}
{%- else %}
{{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
{%- endif %}
{%- if tool_call.arguments is defined and tool_call.arguments != '' %}
{%- for args_name, args_value in tool_call.arguments|items %}
{{- '<parameter=' + args_name + '>\n' }}
{%- set args_value = args_value | string if args_value is string else args_value | tojson | safe %}
{{- args_value }}
{{- '\n</parameter>\n' }}
{%- endfor %}
{%- endif %}
{{- '</function>\n</tool_call>' }}
{%- endfor %}
{%- endif %}
{{- '<|im_end|>\n' }}
{%- elif message.role == "tool" %}
{%- if loop.previtem and loop.previtem.role != "tool" %}
{{- '<|im_start|>user' }}
{%- endif %}
{{- '\n<tool_response>\n' }}
{{- content }}
{{- '\n</tool_response>' }}
{%- if not loop.last and loop.nextitem.role != "tool" %}
{{- '<|im_end|>\n' }}
{%- elif loop.last %}
{{- '<|im_end|>\n' }}
{%- endif %}
{%- else %}
{{- raise_exception('Unexpected message role.') }}
{%- endif %}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|im_start|>assistant\n' }}
{%- if enable_thinking is defined and enable_thinking is false %}
{{- '<think>\n\n</think>\n\n' }}
{%- else %}
{{- '<think>\n' }}
{%- endif %}
{%- endif %}
+3 -1
View File
@@ -60,7 +60,9 @@ fn read_f32(path: &Path) -> Vec<f32> {
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read {path:?}: {e}"));
assert!(bytes.len().is_multiple_of(4), "truncated f32 file {path:?}");
bytes
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
.collect()
}
+2
View File
@@ -106,6 +106,8 @@ fn spec(model_id: &str, tp: Option<u32>, quant: Option<&str>) -> ModelSpec {
quant: quant.map(String::from),
tensor_parallel: tp,
devices: None,
sampling: None,
preserve_thinking: None,
}
}
+3 -1
View File
@@ -37,7 +37,9 @@ fn fixture_dir() -> PathBuf {
fn read_f32(path: &Path) -> Vec<f32> {
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read {path:?}: {e}"));
bytes
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect()
}
+21
View File
@@ -29,6 +29,27 @@
#
# Omit only when full precision is genuinely what
# you want served.
# sampling - operator sampling override (#283). Optional
# sub-table `[models.sampling]` with any of
# `temperature`, `top_p`, `top_k`.
#
# Precedence is request > operator > model's
# generation_config.json > built-in fallback: this
# replaces what the model published, but a caller
# that names a value still wins.
#
# Use when a model's own published sampling is
# wrong for the workload you serve. Qwen3.8-27B
# publishes temperature = 1.0, which measured a
# 20% structural-defect rate on ~2k-token code
# generation against 0/60 at <= 0.6 (Fisher exact
# one-sided p = 0.0031, #283).
#
# Like `quant`, this must MATCH the same model's
# `[default_models.sampling]` in the host's
# neuron.toml — the same two-paths-to-one-model
# hazard that made #252 possible.
# `script/check-config-consistency.py` gates it.
# vram_mb - rough estimate; advisory only, not enforced.
# min_devices - GPU count this profile needs. TP profiles use
# the same value as the tensor-parallel size.
+219
View File
@@ -0,0 +1,219 @@
# models.toml — helexa fleet catalogue
#
# Operator-owned and **tracked in git** (#283). It carries no secrets —
# cortex.toml holds the API keys — and CI needs to see it: this file and
# `asset/neuron/<host>.toml` both describe how a model is served, and
# nothing forced them to agree until `script/check-config-consistency.py`
# began gating on it. That gap is what #252 was.
#
# Deployed to the gateway by `.gitea/workflows/deploy.yml`, which is the
# ONLY writer (operator decision 2026-08-25). Editing
# /etc/cortex/models.toml on the host by hand is pointless — the next
# deploy overwrites it from this file — and dangerous, because until it
# does, the fleet is running something no commit describes.
#
# Fleet shape (sourced from asset/manifest.yml):
# beast = 2x RTX 5090 (32 GB each) → tp-2 capable
# benjy = 1x RTX 4090 (24 GB) → large single-GPU
# quadbrat = 1x RTX 3060 (12 GB) → small / quantised
# Qwen3-Next single-GPU validation target (Stage 8c). Same architecture
# as Qwen3.6-27B (`model_type = "qwen3_5"`, same layer_types pattern,
# same Gated DeltaNet hyperparams) but small enough to fit on any
# fleet GPU in bf16 (~1.6 GB). Stepping stone — if this answers the
# Tbilisi probe correctly, the qwen3_5 code path is right and TP-2 on
# Qwen3.6-27B is purely a sharding/collective problem.
[[models]]
id = "Qwen/Qwen3.5-0.8B"
harness = "candle"
vram_mb = 2000
min_devices = 1
min_device_vram_mb = 4000
# Illustrative pricing (USD per 1M tokens). helexa has no paying customers
# yet; these differ per model purely to validate spend surfaces in clients.
cost.input = 0.02
cost.output = 0.08
# Frontier MoE target (epic #84, F2 #93): 80B total / 3B active,
# 512 experts / 10 active + shared expert, same Gated DeltaNet hybrid
# as the 27B. Weights ~45 GB @ Q4_K_M ISQ under TP-2 (~22.5 GB/GPU) —
# beast only, and NOT co-resident with the 27B (cold-swap per F4e #99).
# The serving quant belongs HERE as well as on any load request (#252):
# a cortex cold-load builds its spec from this file, and without it the
# model loads unquantised — ~149 GB, which cannot fit beast at all.
# tensor_parallel is derived from min_devices, so only quant is needed.
[[models]]
id = "Qwen/Qwen3-Coder-Next"
harness = "candle"
quant = "q4k"
vram_mb = 46000
min_devices = 2
min_device_vram_mb = 24000
# Shares the big-node class with the flagship: either may cold-swap the
# other off beast (#99), in both directions, so a comparison between
# them can move traffic each way on demand.
residency_priority = 300
# Illustrative pricing (USD per 1M tokens) — frontier tier.
cost.input = 0.40
cost.output = 1.60
# Thinking sibling of Coder-Next (same 80B-A3B arch) — on beast for the
# F3 A/B evaluation (#94); one of the two frontier variants competes for
# residency, the other stays cold-loadable.
[[models]]
id = "Qwen/Qwen3-Next-80B-A3B-Thinking"
harness = "candle"
quant = "q4k"
vram_mb = 46000
min_devices = 2
min_device_vram_mb = 24000
# Shares the big-node class with the flagship: either may cold-swap the
# other off beast (#99), in both directions, so a comparison between
# them can move traffic each way on demand.
residency_priority = 300
# Illustrative pricing (USD per 1M tokens) — frontier tier.
cost.input = 0.40
cost.output = 1.60
# Tensor-parallel target. Beast is the only neuron with two big GPUs.
[[models]]
id = "Qwen/Qwen3.6-27B"
harness = "candle"
# REQUIRED, not cosmetic (#252). Without it a cortex cold-load builds an
# unquantised request: the model loads bf16, leaves ~2 GB free per card
# against a ~3.9 GB prefill requirement, reports `loaded`, and then fails
# every request. Activation from neuron's default_models has always said
# q6k; this is the same fact on the path cortex uses.
quant = "q6k"
# Measured q6k residency across the pair (17.9 + 22.9 GB), not the 51.7 GB
# bf16 checkpoint size. The old 54000 described a model we do not serve.
vram_mb = 42000
min_devices = 2
min_device_vram_mb = 24000
pinned_on = ["beast"]
# The big-node class. Above the everyday class, so image generation can
# never take beast however idle the flagship is; equal to the frontier
# tier, so those two swap freely. `pinned_on` here is affinity only --
# it no longer implies immunity, so this number is what protects it.
residency_priority = 300
# Illustrative pricing (USD per 1M tokens) — top tier, the 27B flagship.
cost.input = 0.30
cost.output = 1.20
# DELIBERATE, TEMPORARY operator override (issue #67): unblocks the opencode
# helexa-provider implementation by giving /v1/models a limit to consume on
# beast. Mirrors the enforced NEURON_MAX_PROMPT_TOKENS=131072 there.
# To be REMOVED once neuron self-derives these — its removal is the
# acceptance test that the computed values replace this hand-set one.
#limit.context = 131072
#limit.input = 122880
#limit.output = 8192
# Qwen3.8-27B (#251). Architecturally identical to the 3.6-27B — same
# model_type, same layer counts, same vision tower; the two configs
# differ only in `transformers_version` — so it serves through the same
# qwen3_5 TP path with the same KV arithmetic.
#
# Same residency class as the 3.6-27B *deliberately*: equal rank is what
# lets the two displace each other in BOTH directions, so whichever is
# asked for takes beast and the other can take it back. A higher number
# here would let this one claim the node permanently and make the
# comparison between them impossible.
[[models]]
id = "Qwen/Qwen3.8-27B"
harness = "candle"
quant = "q6k"
vram_mb = 42000
min_devices = 2
min_device_vram_mb = 24000
pinned_on = ["beast"]
residency_priority = 300
cost.input = 0.30
cost.output = 1.20
# Sampling override (#283). The model publishes `temperature = 1.0` in
# its generation_config.json, and honouring that (#272) is correct by
# default — but measured on this stack it produced a 20% structural-
# defect rate on ~2k-token code generation (4/20 samples failed
# `node --check` or dropped required exports) against 0/60 at
# temperature <= 0.6. Fisher exact one-sided p = 0.0031.
#
# Extrapolated at constant hazard that is a ~79% chance of at least one
# defect in a 15k-token file, which is why this model had never once
# completed the tower-defence task two cloud models did in ten minutes.
#
# 0.6 is Qwen's own published *inference* guidance for thinking mode —
# a number that lives in a model card no machine can read, which is
# exactly why it has to be set here by hand.
#
# Precedence is request > operator > model, so a caller that names a
# temperature still gets it. MUST match asset/neuron/beast.toml;
# script/check-config-consistency.py fails CI if it drifts.
[models.sampling]
temperature = 0.6
# Mid-size dense bf16 — fits on benjy (24 GB) or beast (32 GB ea.).
[[models]]
id = "Qwen/Qwen3-8B"
harness = "candle"
vram_mb = 18000
min_devices = 1
min_device_vram_mb = 16000
# Everyday class, shared with Z-Image — they take turns on benjy.
residency_priority = 200
# Illustrative pricing (USD per 1M tokens) — mid tier (helexa/balanced).
cost.input = 0.10
cost.output = 0.40
# Single-GPU dense bf16 — fits anywhere with ≥4 GB.
[[models]]
id = "Qwen/Qwen3-1.7B"
harness = "candle"
vram_mb = 4000
min_devices = 1
min_device_vram_mb = 4000
# Illustrative pricing (USD per 1M tokens) — small tier (helexa/small).
cost.input = 0.04
cost.output = 0.16
# Small GGUF quantised — runs on quadbrat (12 GB) without breaking a sweat.
[[models]]
id = "unsloth/Qwen3-0.6B-GGUF"
harness = "candle"
quant = "Q4_K_M"
vram_mb = 500
min_devices = 1
min_device_vram_mb = 4000
# Illustrative pricing (USD per 1M tokens) — cheapest, the tiny GGUF.
cost.input = 0.015
cost.output = 0.06
# -- 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
# latency/quality tier without hardcoding specific model ids — operators
# can swap the targets here without touching client config.
[aliases]
"helexa/small" = "Qwen/Qwen3-1.7B"
"helexa/balanced" = "Qwen/Qwen3-8B"
"helexa/large" = "Qwen/Qwen3.8-27B"
"helexa/image" = "Tongyi-MAI/Z-Image-Turbo"
# 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
# neuron.toml) + VAE. BF16 DiT+VAE resident ≈ 14.6 GB; fits benjy's
# 4090 via cold-swap with Qwen3-8B (operator decision 2026-07-29,
# #197). Not default-activated anywhere — cortex cold-loads it on
# first images request and the evictor swaps it back out under text
# pressure. Metered in megapixel-steps (#202), not tokens; the token
# cost fields deliberately stay unset.
[[models]]
id = "Tongyi-MAI/Z-Image-Turbo"
harness = "candle"
vram_mb = 15000
min_devices = 1
min_device_vram_mb = 20000
# Everyday class, shared with Qwen3-8B: an image request cold-swaps the
# 8B off benjy and the next text request swaps it straight back. Below
# the big-node class, so it can never take beast -- its device
# constraints alone would otherwise let it land there.
residency_priority = 200
+27
View File
@@ -110,6 +110,33 @@ name = "candle"
# harness = "candle"
# quant = "Q4_K_M"
# devices = [0]
#
# Operator sampling override (#283) — optional, per model.
#
# By default neuron honours what the model published in its
# `generation_config.json`; that is the model authors' stated intent and
# is right most of the time. It is not always right for the workload you
# serve: Qwen3.8-27B publishes `temperature = 1.0`, which measured a 20%
# structural-defect rate on ~2k-token code generation against 0/60 at
# <= 0.6 (Fisher exact one-sided p = 0.0031).
#
# Precedence is request > operator > model > built-in fallback, so a
# caller that names a temperature still gets what it asked for; this only
# changes what a caller who names nothing receives. Each field overlays
# independently — setting `temperature` alone leaves the model's `top_p`
# and `top_k` in force.
#
# Deliberately explicit. neuron will never inspect a model and guess a
# "better" temperature: a guesser is a footgun, a number you wrote is not.
#
# If cortex can also cold-load this model, the same values must appear in
# its `models.toml` entry — `script/check-config-consistency.py` gates on
# that, because two files describing one model is how #252 happened.
#
# [default_models.sampling]
# temperature = 0.6
# top_p = 0.95
# top_k = 20
# Text-to-image serving (#198). Applies to diffusers-format image
# models (Z-Image family). All fields optional.
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Fail when the two files describing one model disagree.
`models.toml` (cortex's catalogue, used for cold loads) and
`asset/neuron/<host>.toml` (`[[default_models]]`, used for models a host
holds resident) both describe *how a model is served*. Nothing forced
them to agree, and #252 is what that costs: models.toml omitted `quant`,
so a cortex cold-load served bf16 and could not prefill, while the same
model was fine when the host loaded it from its own config.
#283 adds a second such field — the operator sampling override — so this
check exists before the second instance of the bug rather than after it.
Checked, per model id present in both files:
* ``quant`` — the #252 field
* ``sampling`` — the #283 field, compared field-by-field
* ``preserve_thinking`` — the H7 A/B gate: a model that replays
prior reasoning when resident and not when cold-loaded would
make the A/B unreadable, which is #252's shape again
A model in only one file is not an error: plenty of catalogue entries are
never resident anywhere, and a host may hold a model the catalogue does
not list. Only *disagreement* fails.
Usage::
check-config-consistency.py # repo defaults
check-config-consistency.py --models /etc/cortex/models.toml \\
--neuron asset/neuron/beast.toml
Exit status is 0 when consistent, 1 on any disagreement, 2 on bad input.
"""
from __future__ import annotations
import argparse
import glob
import os
import sys
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
print("error: needs Python 3.11+ for tomllib", file=sys.stderr)
raise SystemExit(2)
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Fields that must agree wherever both files mention the same model.
# Extend this list whenever a serving-critical field lands in both.
SCALAR_FIELDS = ["quant", "preserve_thinking"]
TABLE_FIELDS = ["sampling"]
def load(path: str) -> dict:
try:
with open(path, "rb") as fh:
return tomllib.load(fh)
except FileNotFoundError:
print(f"error: {path} not found", file=sys.stderr)
raise SystemExit(2)
except tomllib.TOMLDecodeError as exc:
print(f"error: {path} is not valid TOML: {exc}", file=sys.stderr)
raise SystemExit(2)
def catalogue_entries(path: str) -> dict[str, dict]:
"""model id -> profile, from models.toml's `[[models]]`."""
return {m["id"]: m for m in load(path).get("models", []) if "id" in m}
def resident_entries(path: str) -> dict[str, dict]:
"""model id -> spec, from a neuron config's `[[default_models]]`."""
return {
m["model_id"]: m
for m in load(path).get("default_models", [])
if "model_id" in m
}
def label(path: str) -> str:
"""Repo-relative when the file is in the repo, basename otherwise.
Deploy runs pass paths outside the tree (a models.toml fetched off
the gateway), and `../../../..`-prefixed relpaths make the diff
unreadable in a CI log.
"""
rel = os.path.relpath(path, REPO)
return rel if not rel.startswith("..") else os.path.basename(path)
def compare(model_id: str, cat: dict, res: dict, catalogue_path: str, neuron_path: str):
"""Yield one human-readable line per disagreement."""
cat_name = label(catalogue_path)
neu_name = label(neuron_path)
for field in SCALAR_FIELDS:
a, b = cat.get(field), res.get(field)
# Absent on one side is "unspecified", not "different" -- but for
# quant specifically that IS the #252 bug, so say so loudly.
if a != b:
if a is None or b is None:
detail = (
" (one side leaves it unset -- this is exactly #252: "
"an unset quant makes a cold load serve bf16)"
if field == "quant"
else " (one side leaves it unset)"
)
else:
detail = ""
yield (
f"{model_id}: {field} differs\n"
f" {cat_name}: {a!r}\n"
f" {neu_name}: {b!r}{detail}"
)
for field in TABLE_FIELDS:
a = cat.get(field) or {}
b = res.get(field) or {}
if a == b:
continue
keys = sorted(set(a) | set(b))
rows = "\n".join(
f" {k}: {cat_name}={a.get(k)!r} {neu_name}={b.get(k)!r}"
for k in keys
if a.get(k) != b.get(k)
)
yield (
f"{model_id}: {field} differs\n"
f" a model must sample the same way whether cortex cold-loads it\n"
f" or the host holds it resident:\n{rows}"
)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--models", default=os.path.join(REPO, "models.toml"),
help="path to models.toml (default: repo root)")
ap.add_argument("--neuron", nargs="*", default=None,
help="neuron config(s) (default: asset/neuron/*.toml)")
args = ap.parse_args()
neuron_paths = args.neuron
if not neuron_paths:
neuron_paths = sorted(glob.glob(os.path.join(REPO, "asset", "neuron", "*.toml")))
if not neuron_paths:
print("error: no neuron configs found", file=sys.stderr)
return 2
catalogue = catalogue_entries(args.models)
problems: list[str] = []
compared = 0
for npath in neuron_paths:
for model_id, spec in resident_entries(npath).items():
profile = catalogue.get(model_id)
if profile is None:
# A host may hold a model the catalogue does not list.
# Not an error, but worth seeing -- cortex cannot route
# to what it has no profile for.
print(f"note: {model_id} is resident in {label(npath)} "
f"but absent from {label(args.models)}")
continue
compared += 1
problems.extend(compare(model_id, profile, spec, args.models, npath))
if problems:
print(f"\nconfig inconsistency: {len(problems)} disagreement(s)\n",
file=sys.stderr)
for p in problems:
print(f" {p}\n", file=sys.stderr)
print("Two config files describe one model. Make them agree, or the\n"
"model is served one way on a cold load and another when\n"
"resident -- see #252 and #283.", file=sys.stderr)
return 1
print(f"config consistency OK: {compared} model/host pairing(s) agree "
f"on {', '.join(SCALAR_FIELDS + TABLE_FIELDS)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Type-check neuron's `cuda` feature locally, in CI's own image.
#
# Why this exists: `#[cfg(feature = "cuda")]` code is invisible to a
# default-feature build, so `cargo build`, `cargo clippy --all-targets`
# and the whole test suite can pass on code that does not compile for
# the target the fleet actually serves on. The only gate that catches it
# is CI's `cuda-check` job — a ~20 minute round trip, and the failure
# arrives after the push rather than before it.
#
# This runs the same command, with the same env, in the same image.
#
# Do NOT try to install the CUDA toolkit on a Fedora 44 host instead:
# CUDA 13.0's nvcc rejects host compilers newer than gcc 15 and F44
# ships gcc 16 with no compat package. The image is Fedora 43 for
# exactly that reason, and additionally patches a glibc 2.41+ /
# CUDA-header `noexcept` mismatch that nvcc hard-errors on. See
# gongfoo's images/runner-cuda-13.0/Containerfile.
#
# No GPU is required — this is a borrow-check, not a run.
#
# First run pulls the image (~GBs) and compiles the candle/flash-attn
# kernels, so it costs about what CI costs. Afterwards the cargo target
# volume makes an incremental check a fraction of that, which is the
# whole point.
set -euo pipefail
IMAGE="${CUDA_CHECK_IMAGE:-git.lair.cafe/gongfoo/runner-cuda-13.0:latest}"
ROOT="$(git rev-parse --show-toplevel)"
# A dedicated target volume. Sharing the host's `target/` would make the
# default-feature and cuda-feature builds evict each other's artifacts on
# every alternation, which would cost more than it saves.
TARGET_VOL="${CUDA_CHECK_TARGET_VOL:-helexa-cuda-check-target}"
# Mounted at the registry rather than over CARGO_HOME: the toolchain
# binaries live in /usr/local/cargo/bin and must stay visible.
REGISTRY_VOL="${CUDA_CHECK_REGISTRY_VOL:-helexa-cuda-check-registry}"
if [ "${1:-}" = "--pull" ]; then
shift
podman pull "${IMAGE}"
fi
# Default to the exact command ci.yml's cuda-check job runs. Extra
# arguments replace it, for narrowing to one crate while iterating.
if [ "$#" -gt 0 ]; then
CARGO_CMD=("$@")
else
CARGO_CMD=(cargo check -p neuron --features cuda,flash-attn --all-targets)
fi
# Allocate a TTY only when there is one to allocate. `-it` against a
# pipe or a CI log wedges the container before cargo ever starts, which
# looks exactly like a very slow compile.
TTY_ARGS=()
if [ -t 0 ] && [ -t 1 ]; then
TTY_ARGS=(-it)
fi
exec podman run --rm "${TTY_ARGS[@]}" \
-v "${ROOT}:/work:Z" \
-v "${TARGET_VOL}:/target" \
-v "${REGISTRY_VOL}:/usr/local/cargo/registry" \
-w /work \
-e CARGO_TARGET_DIR=/target \
-e CARGO_TERM_COLOR=always \
`# candle-kernels' build script falls back to nvidia-smi for` \
`# compute-cap detection when this is unset, and there is no GPU` \
`# here. Any valid cap borrow-checks; the real per-flavour caps` \
`# live in build-prerelease.yml's matrix.` \
-e CUDA_COMPUTE_CAP=86 \
`# The image ships sccache, but the local run has no shared cache` \
`# to talk to; an unset wrapper avoids a hard cargo failure.` \
-e RUSTC_WRAPPER= \
"${IMAGE}" \
bash -lc '
set -euo pipefail
# CUDA_HOME/PATH/LD_LIBRARY_PATH come from the image. LIBRARY_PATH
# does not, and cudarc needs it at link time — ci.yml exports all
# three explicitly, so keep this in step with that job.
export LIBRARY_PATH="${CUDA_HOME}/targets/x86_64-linux/lib:${CUDA_HOME}/lib64:${LIBRARY_PATH:-}"
exec "$@"
' _ "${CARGO_CMD[@]}"
+47 -23
View File
@@ -138,14 +138,28 @@ if ! ssh "${bench_host}" '
echo " failed to prepare ${bench_host} for helexa-bench"
fi
# Push application config to the fleet. The deploy workflow is
# scoped to package install + service restart; config changes ride
# along with this script instead, since:
# - cortex.toml and models.toml are gitignored (operator-owned, may
# include secrets), so CI never sees them
# - asset/neuron/<short>.toml is tracked but iterating locally is
# faster than pushing a commit and waiting for build-prerelease
# to roll over
# Push the one config CI cannot own.
#
# Operator decision 2026-08-25 (#283): the automated deployment path is
# the ONLY path. `.gitea/workflows/deploy.yml` now ships models.toml to
# the gateway, asset/neuron/<short>.toml to each neuron, and
# asset/helexa-bench/<host>.toml to the bench host — digest-compared,
# restarting the service when the file changes.
#
# Those three syncs USED to live here, and that is exactly how the fleet
# drifted: benjy and quadbrat were still running pre-#197 configs months
# after the tracked assets had moved on, because deploying them meant
# remembering to re-run this script. A hand-sync that exists "for local
# iteration" is a footgun that silently overwrites, or silently fails to
# overwrite, whatever CI just placed. Do not add one back.
#
# What remains is cortex.toml, which carries the inference API keys
# ([[entitlements.keys]]) and therefore cannot be tracked in git for CI
# to read. It is the last hand-synced config on the fleet and the last
# place this drift can still happen; closing it needs the secrets to
# come from Gitea secrets at deploy time rather than from a working
# copy.
#
# Missing source files are skipped silently — re-run after editing.
# Optional 4th/5th args set the destination group and mode. Configs that
# carry secrets (API keys, JWT signing keys, SMTP/DB passwords) must land
@@ -174,23 +188,15 @@ sync_config() {
fi
}
echo "==> ${cortex_host}: syncing gateway configs"
# cortex.toml holds the inference API keys ([[entitlements.keys]]).
echo "==> ${cortex_host}: syncing gateway secrets config"
# cortex.toml holds the inference API keys ([[entitlements.keys]]), so it
# is gitignored and CI cannot see it. The only config still synced by
# hand — everything else is deploy.yml's.
sync_config "${cortex_host}" "${repo_path}/cortex.toml" /etc/cortex/cortex.toml cortex 0640
sync_config "${cortex_host}" "${repo_path}/models.toml" /etc/cortex/models.toml
for neuron_host in "${neuron_hosts[@]}"; do
short="${neuron_host%%.*}"
echo "==> ${neuron_host}: syncing per-host neuron config"
sync_config "${neuron_host}" \
"${repo_path}/asset/neuron/${short}.toml" \
/etc/neuron/neuron.toml
done
echo "==> ${bench_host}: syncing bench config"
sync_config "${bench_host}" \
"${repo_path}/asset/helexa-bench/${bench_host%%.*}.toml" \
/etc/helexa-bench/helexa-bench.toml
# models.toml, asset/neuron/<short>.toml and asset/helexa-bench/<host>.toml
# are deliberately NOT synced here. deploy.yml owns them; see the note
# above sync_config().
# ── bench UI: public nginx vhost on the gateway (bench.helexa.ai) ──────
# The built SPA is rsynced to the webroot by deploy.yml; nginx serves it
@@ -600,6 +606,24 @@ if ! ssh "${svc_host}" '
echo " failed to prepare ${svc_host}"
fi
# ── STILL HAND-SYNCED, and tracked as such ────────────────────────────
# Everything below this line is a config CI does not yet own, which by
# the 2026-08-25 decision (#283) makes it a footgun still loaded. Two
# distinct reasons, needing two distinct fixes:
#
# * helexa-router.toml / helexa-upstream.toml / helexa-angels.toml are
# gitignored because they carry JWT secrets, SMTP passwords and
# database URLs. Same class as cortex.toml: CI cannot read them until
# the secrets come from Gitea secrets and the file is rendered at
# deploy time.
# * asset/searxng/{settings.yml,searxng.container,helexa-searxng.xml}
# ARE tracked and could move to deploy.yml today but for one thing:
# asset/sudoers.d/gallumbits-host.conf grants gitea_ci no rsync at
# all, so the deploy would fail until the grants land on the host.
# Grants first, deploy step second — wiring it the other way round
# breaks every deploy in between.
#
# See the tracking issue; do not close the gap by adding syncs here.
echo "==> ${svc_host}: syncing service configs"
sync_config "${svc_host}" "${repo_path}/helexa-router.toml" /etc/helexa-router/helexa-router.toml
# helexa-upstream.toml holds the session JWT secret, the SMTP submission