Skip to content

serve --laya: Laya classifies each conversation, a measured policy picks the device (RFC #186) - #187

Merged
bong-water-water-bong merged 3 commits into
mainfrom
laya-classify
Sep 28, 2026
Merged

bong-water-water-bong merged 3 commits into
mainfrom
laya-classify

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Implements accepted RFC https://github.com/orgs/1bit-MONSTER/discussions/186.

Asked for a device, Laya picked ZINC for every request (it knows nothing about the hardware). Now it answers a question about the request, and a measured policy picks the device.

What changes

  • Classifier (laya/route.h): long_doc by size (≥ 1,024 chars, no model call), else a three-way Laya choice among code / prose / short. typed-decisions/ checkpoint: 95.5% on 200 hand-labelled requests (code 100, prose 82, short 100, long_doc 100); ctest laya_classify fails below 90%.
  • Policy (config/route-policy.json, compiled in; --route-policy FILE): class → device with the measured reason per row; below confidence 0.16 → default. Every class → Vulkan today: no class has a measured faster device, and llama-server ignores per-request speculative.* settings (verified on our pin and upstream master), so per-class drafters would need a second server holding a second copy of the model.
  • serve --laya (checkpoint from $ONEBIT_LAYA_MODEL or ~/.local/share/1bit/laya) or --laya-model DIR: one decision per conversation (cached), X-1bit-Route: <class> <confidence> <device> on replies, each decision logged. Opt-in: --device auto without --laya is unchanged.
  • 1bit route --classify; scripts/fetch-laya.sh defaults to the path --laya reads.

Measured (Strix Halo, Qwen3.8-27B Q4_0 + DFlash2, 1bit serve --device auto)

1-token reply, first turn later turn decode code / prose / short
without --laya 498-518 ms 713-849 ms 50.5-50.7 / 26.7-26.9 / 19.0-19.2
--laya 1,008-1,048 ms 703-767 ms 50.6 / 26.7 / 18.9-19.1

Checks (strixhalo)

ctest 10/10 with -DONEBIT_LAYA_ROUTE_MODEL: smoke_serve, think_split, gguf_meta, laya_route_e2e (11/11: three classes to three fake devices, header, reuse on a later turn, missing-checkpoint error, npu-only), laya_classify (95.5% ≥ 90%), registry_gaps, registry_pins, npu_*.

🤖 Generated with Claude Code

bong-water-water-bong and others added 2 commits September 28, 2026 03:47
…e gate, and its eval (RFC #186)

Asked for a device, Laya picked zinc for every request (it knows nothing about the hardware).
It classifies the request instead: long_doc by size (>= 1024 chars, no model call), else a
three-way Laya choice. tests/laya_route_cases.{py,json}: 200 labelled requests, 50 per class.
tests/laya_classify_eval (checkpoint, cases): typed-decisions checkpoint, default wording:
191/200 = 95.5% (code 100, prose 82, short 100, long_doc 100), 0.59 s per decision; the root
checkpoint 92.0%. Laya alone, without the gate: 64-80%, long_doc 30-70%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cks the device (RFC #186)

- laya/policy.{h,cpp} + config/route-policy.json (compiled in, --route-policy FILE replaces
  it): class -> device with the measured reason per row, min_confidence 0.16 -> default.
  Every class goes to Vulkan today: no class has a measured faster device, and llama-server
  ignores per-request speculative settings, so per-class drafters would need a second server.
- serve: --laya (checkpoint from $ONEBIT_LAYA_MODEL or ~/.local/share/1bit/laya, the pinned
  repo's typed-decisions/ when present) or --laya-model DIR; one decision per conversation
  (cached by the opening messages); X-1bit-Route "<class> <confidence> <device>" on replies;
  each new decision logged. Opt-in: --device auto without --laya is unchanged.
- 1bit route: classify + policy; --classify prints "<class> <confidence> <device>".
- scripts/fetch-laya.sh: the destination defaults to where --laya looks.
- tests: laya_route_e2e.sh rewritten for the policy (three classes to three fake devices,
  header, reuse on a later turn, missing-checkpoint error); ctest laya_classify (90% floor).
- docs/laya.md: accuracy table (95.5% typed-decisions), the per-request drafter finding, cost
  (first turn +~0.5 s, later turns and decode unchanged); docs/serve.md.

Measured on Strix Halo, Qwen3.8-27B Q4_0 + DFlash2: 1-token reply 1,008-1,048 ms vs 498-518
on the first turn, 703-767 vs 713-849 later; decode 50.6 / 26.7 / 18.9-19.1 tok/s vs
50.5-50.7 / 26.7-26.9 / 19.0-19.2 (code / prose / short).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@context7

context7 Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Docs7 for 1bit-monster/engine

Result Status Action
Deployment ➖ Not used —
Content review ➖ Did not run. This site has no agent runs available this month. Wait for the monthly reset or check your Docs7 plan. —

Commit dcec90d

@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit dcec90d)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Potential Race Condition in Class Cache

The class cache (class_cache and class_order) in serve_child is protected by class_mu mutex, but there's a potential race condition during cache insertion. Specifically, when a new conversation key is added to the cache, the code first checks if the cache size is at the limit (class_cache.size() >= 4096) and removes the oldest entry if needed. However, this check and removal are not atomic with the insertion operation. If two threads attempt to insert simultaneously when the cache is at capacity, both might pass the size check and proceed to remove the oldest entry, potentially leading to one of the new entries being incorrectly evicted or causing a data race during the insertion.

if (fresh) {
    std::lock_guard<std::mutex> lock(class_mu);
    if (class_cache.size() >= 4096) {  // oldest conversation first
        class_cache.erase(class_order.front());
        class_order.pop_front();
    }
    if (class_cache.emplace(key, cls).second) class_order.push_back(key);
}
Incomplete Error Handling in Long Document Classification

The classify_request function handles long documents by directly assigning the class "long_doc" without checking if the scorer is properly initialized or if there are any internal errors. If the scorer fails to initialize or encounters an error during its operation, this could lead to incorrect classification or silent failures, especially when the scorer is expected to be valid for other parts of the system.

if (state.size() >= kLongDocChars) {
    out.label = "long_doc";
    out.confidence = 1.0f;
    out.probabilities = {{"long_doc", 1.0f}};
    return out;
}
Potential Invalid Device Selection

The RoutePolicy::pick function may return an empty string if the fallback device is not in the candidate list and the candidates list is empty. This could happen if the policy is misconfigured or if the device list is empty, leading to an invalid routing decision. While the code checks for fallback device availability, it doesn't handle the case where the fallback device is not in the candidates list and the candidates list is empty, which could lead to a crash or undefined behavior.

std::string RoutePolicy::pick(const std::string& cls, float confidence, const std::vector<std::string>& candidates) const {
    auto have = [&](const std::string& d) { return std::find(candidates.begin(), candidates.end(), d) != candidates.end(); };
    if (confidence >= min_confidence)
        if (const auto it = device.find(cls); it != device.end() && have(it->second)) return it->second;
    if (have(fallback)) return fallback;
    return candidates.empty() ? std::string() : candidates.front();
}

⚠️ Review coverage: The following files were not included in this review because of the token budget:

  • tests/laya_route_cases.json

A client sending "model": null made nlohmann value() throw inside the handler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit dcec90d

@bong-water-water-bong
bong-water-water-bong merged commit 10fa45d into main Sep 28, 2026
11 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the laya-classify branch September 28, 2026 09:44
bong-water-water-bong added a commit that referenced this pull request Sep 28, 2026
Asked for a device, Laya picked zinc for every request; asked what kind of request it is, it
classifies 95.5% of 200 labelled requests (size gate for long documents). The policy keeps
every class on Vulkan (per-request drafter settings are ignored by llama-server); cost: first
turn +~0.5 s, later turns and decode unchanged. Every number from docs/laya.md, docs/serve.md.

Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
bong-water-water-bong added a commit that referenced this pull request Sep 29, 2026
…UF on the NPU, 1BP; blog post (#214)

README gets a Direction note (HRX with Loom kernels plus the NPU, as geramyL proposed;
Vulkan stays the default until the RFC #213 gates are met) and current status lines:
six GGUF architectures answer on the NPU (0.006-0.17 tok/s), DwarfStar reads 1BP, and
Laya classifies conversations with its scorer on HRX at 15-16 ms. PORTING's NPU and
Laya rows gain #179/#181/#209 and #187/#206/#208. The blog post covers the same.

Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
bong-water-water-bong pushed a commit that referenced this pull request Nov 4, 2026
The pin bump moves third_party/llama.cpp d8bd0ff60 -> a5c32fbf8 but not the registry's
record of it, and registry/architectures.json is what ctest `registry_pins` compares the
committed pins against - so the bump as opened fails that test.

Regenerated with the documented recipe (`scripts/registry-regenerate`: `scripts/registry-regen.sh`,
which is also what the bump workflows run) from this commit in a fresh clone: it inits the three
trees the registry reads at their pins, rewrites the file, and then checks it:

    $ scripts/registry-regen.sh
    wrote registry/architectures.json: hrx 294, npu 7, architectures 294, mapped 294
    none of the 28 reviewed significant classes is mapped without a reason
    registry/architectures.json records the committed pins

One line changes - the recorded pin - because a5c32fbf8b only adds the converter's --dspark
argparse block (#187), which moves no architecture. tools/copyright.py --check is clean.
bong-water-water-bong added a commit that referenced this pull request Nov 4, 2026
…spark, engine #961) (#992)

* Bump HRX: llama.cpp a5c32fbf8b (fork tip: the converter registers --dspark, engine #961)

The pin was d8bd0ff6052, whose convert_hf_to_gguf.py reads args.dspark at :261 and
registers no --dspark argument, so every conversion died with AttributeError on an
argparse Namespace that has no such attribute (engine #961). a5c32fbf8b is the fork's
1bit/hrx-vulkan-patched tip: #187 squash-merged on d8bd0ff6052, one commit ahead, which
adds the registration at :156 and the V4/V4.1 arch guard.

tools/check_pins.py: d8bd0ff60 -> a5c32fbf8 is 'ahead' (no other submodule moves).

* registry: record the llama.cpp pin the bump moves to (engine #961)

The pin bump moves third_party/llama.cpp d8bd0ff60 -> a5c32fbf8 but not the registry's
record of it, and registry/architectures.json is what ctest `registry_pins` compares the
committed pins against - so the bump as opened fails that test.

Regenerated with the documented recipe (`scripts/registry-regenerate`: `scripts/registry-regen.sh`,
which is also what the bump workflows run) from this commit in a fresh clone: it inits the three
trees the registry reads at their pins, rewrites the file, and then checks it:

    $ scripts/registry-regen.sh
    wrote registry/architectures.json: hrx 294, npu 7, architectures 294, mapped 294
    none of the 28 reviewed significant classes is mapped without a reason
    registry/architectures.json records the committed pins

One line changes - the recorded pin - because a5c32fbf8b only adds the converter's --dspark
argparse block (#187), which moves no architecture. tools/copyright.py --check is clean.

---------

Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Co-authored-by: pi-zarch <pi-zarch@1bit.gg>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant