]*\bid="release-badge-title"[^>]*>([^<]*)<\/title>/g;
+ const titles = [...svg.matchAll(title)];
+ if (titles.length !== 1 || titles[0][1].split(previous).length !== 2) {
+ throw new Error("Release badge title must contain the marked version exactly once");
+ }
+
+ svg = svg
+ .replace(/data-release-version="[^"]+"/, `data-release-version="${version}"`)
+ .replace(versionText, `$1${version}$2`)
+ .replace(title, (match) => match.replace(previous, version));
+
+ const readmePath = "README.md";
+ let readme = fs.readFileSync(readmePath, "utf8");
+ const previousLabel = `Release ${previous}`;
+ const occurrences = readme.split(previousLabel).length - 1;
+ if (occurrences !== 1) {
+ throw new Error(`README must contain "${previousLabel}" exactly once`);
+ }
+ readme = readme.replace(previousLabel, `Release ${version}`);
+
+ fs.writeFileSync(asset, svg);
+ fs.writeFileSync(readmePath, readme);
+ fs.appendFileSync(process.env.GITHUB_OUTPUT, `asset=${asset}\nversion=${version}\n`);
+ NODE
+
+ - name: Commit badge update
+ shell: bash
+ env:
+ BADGE_ASSET: ${{ steps.badge.outputs.asset }}
+ NORMALIZED_VERSION: ${{ steps.badge.outputs.version }}
+ run: |
+ git add -- README.md "$BADGE_ASSET"
+ if git diff --cached --quiet; then
+ echo "Release badge is already set to $NORMALIZED_VERSION"
+ exit 0
+ fi
+
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git commit -m "docs: prepare $NORMALIZED_VERSION release badge"
+ git push origin "HEAD:refs/heads/$TARGET_BRANCH"
+
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 7e8ae36..cb57366 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -144,6 +144,23 @@ jobs:
VERSION: ${{ steps.meta.outputs.version }}
run: make release-tarball
+ - name: Verify license files in release tarball
+ shell: bash
+ run: |
+ set -euo pipefail
+ archive=$(find dist -maxdepth 1 -name '*.tar.gz' -print -quit)
+ test -n "${archive}"
+ tar -tzf "${archive}" > "${archive}.contents"
+ for path in \
+ LICENSE \
+ NOTICE \
+ THIRD_PARTY_LICENSES/README.md \
+ THIRD_PARTY_LICENSES/llama.cpp.LICENSE \
+ THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE
+ do
+ grep -Fxq "${path}" "${archive}.contents"
+ done
+
- uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.goos }}-${{ matrix.goarch }}
diff --git a/.gitignore b/.gitignore
index 9cdc293..f894508 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,7 @@
-scripts/.venv/
*.gguf
/bin/
/dist/
.DS_Store
*.onnx
/models/
-__pycache__/
/third_party/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/Makefile b/Makefile
index ffba173..fc1458e 100644
--- a/Makefile
+++ b/Makefile
@@ -84,12 +84,14 @@ test:
clean:
rm -rf bin/ gen/ dist/
-# Packages the Go binaries plus llama-server into a single tarball.
+# Packages the Go binaries, llama-server, and their license texts into a single tarball.
# Assumes `make build` and `make llama-server` have already populated bin/.
release-tarball:
mkdir -p dist
TARNAME=runed-$(VERSION)-$(OS_LABEL)-$(GOARCH).tar.gz; \
- tar -czf dist/$$TARNAME -C bin runed rundemo llama-server; \
+ tar -czf dist/$$TARNAME \
+ -C bin runed rundemo llama-server \
+ -C $(CURDIR) LICENSE NOTICE THIRD_PARTY_LICENSES; \
cd dist && ( \
(command -v shasum >/dev/null 2>&1 && shasum -a 256 $$TARNAME > $$TARNAME.sha256) \
|| (command -v sha256sum >/dev/null 2>&1 && sha256sum $$TARNAME > $$TARNAME.sha256) \
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..6704a63
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,4 @@
+runed
+Copyright 2026 CryptoLab, Inc.
+
+This product includes software developed at CryptoLab, Inc.
diff --git a/README.md b/README.md
index 6796a75..8f97f07 100644
--- a/README.md
+++ b/README.md
@@ -1,171 +1,168 @@
-# runed — Shared embedding daemon
+
+
+
+
+
-`runed` is a Go daemon that wraps [llama.cpp](https://github.com/ggml-org/llama.cpp)
-`llama-server` to serve Qwen3-Embedding-0.6B embeddings via gRPC over a UNIX
-domain socket. It is designed as a **shared singleton process per machine** so
-that multiple client sessions do not each load their own ~400 MB embedding
-model.
+
+
+
-## Installation
+
+ rune.team ·
+ Documentation ·
+ Releases
+
-`runed` is normally installed via the [`rune` CLI](https://github.com/CryptoLabInc/rune):
+`runed` is RUNE's shared local embedding daemon. It keeps one Qwen3 embedding model per machine and serves every agent session over a Unix domain socket, avoiding a separate model copy for every `rune-mcp` process.
-```
-rune install
+The daemon starts on demand, verifies every downloaded artifact by SHA-256, suspends the heavyweight `llama-server` child when idle, and brings it back transparently on the next embedding request.
+
+## Why a shared daemon
+
+```text
+agent session A ── rune-mcp A ─┐
+agent session B ── rune-mcp B ─┼── Unix socket ── runed ── llama-server
+agent session C ── rune-mcp C ─┘ └─ one Qwen3 model
```
-This places the binary at `~/.runed/bin/runed` with a manifest URL baked
-in at build time (via `-ldflags`). End users don't need to set any
-environment variable — the first launch downloads the `llama-server`
-release tarball and the embedding GGUF described by the manifest into
-`~/.runed/{bin/llama-cpp,models}/`. Subsequent launches reuse the
-installed artifacts as long as the manifest SHA-256s still match.
+- **One model per machine** instead of one model per agent session.
+- **Local text processing** so passages do not need to leave the user's device for embedding.
+- **Automatic startup** serialized across concurrent clients.
+- **Idle suspension** that releases model memory without removing the daemon socket.
+- **Verified bootstrap** for the `llama-server` archive and GGUF model.
+- **Single and batch RPCs** with L2-normalized vectors.
-For standalone testing of the daemon without `rune`, see
-[`scripts/dev_standalone.sh`](scripts/dev_standalone.sh).
+## Installation
-## Usage
+End users normally receive `runed` through the [RUNE](https://github.com/CryptoLabInc/rune) installer. The bootstrap layer places the binary under `~/.runed/bin/`, then `rune-mcp` starts it automatically when an embedding is first needed.
-`runed` is a passive gRPC daemon. It is normally spawned on demand by a
-client (e.g. `rune-mcp`, `rundemo`, or any program that imports the
-`client/` package) the first time an embedding request arrives:
+For standalone development:
-```
-client.Connect()
- socket dial fails
- → spawn.EnsureDaemon execs ~/.runed/bin/runed (detached)
- → paths.Resolve / EnsureDirs
- → socket probe (exit 0 if another daemon already listening)
- → self-bootstrap (manifest fetch → llama-server tarball + GGUF
- download on first boot, SHA cache check on subsequent boots)
- → backend.Start (llama-server child, port 0)
- → listen on ~/.runed/embedding.sock
- client reconnects → embed RPC → result
+```bash
+git clone https://github.com/CryptoLabInc/runed.git
+cd runed
+make build
+./scripts/dev_standalone.sh
```
-Direct foreground launch is supported for development and debugging:
+Linux and macOS are supported. The current client deliberately rejects Windows because the Plan A transport uses a Unix domain socket.
-```
-./bin/runed
-```
+## Go client
-After `RUNED_IDLE_TIMEOUT` (default 10m) of no RPC activity, the daemon
-stops its `llama-server` child to release the ~470MB+ of model weights
-from memory — but `runed` itself stays up with the gRPC socket still
-listening. The next `Embed`/`EmbedBatch` RPC transparently restarts the
-backend (cold-start latency paid only on that single request). The
-daemon process exits only on SIGINT/SIGTERM/SIGHUP or a Shutdown RPC.
+Programs can use the public [`client`](client/) package directly:
-## Configuration
+```go
+package main
-### Environment
+import (
+ "context"
+ "fmt"
+ "log"
-| Variable | Purpose | Default |
-|---|---|---|
-| `RUNED_HOME` | Data directory | `~/.runed` |
-| `RUNED_MANIFEST` | Manifest URL for self-bootstrap | `DefaultManifestURL` (build-time ldflags) |
-| `RUNED_LLAMA_SERVER` | Skip self-bootstrap; use this binary | (unset) |
-| `RUNED_MODEL` | Skip self-bootstrap; use this GGUF | (unset) |
-| `RUNED_MODEL_VARIANT` | Pick a non-default model from `manifest.models` | `manifest.default_model` |
-| `RUNED_CTX_SIZE` | `llama-server --ctx-size` (max input length in tokens) | 2048 |
-| `RUNED_IDLE_TIMEOUT` | After this much idle, stop the llama-server child to free model memory. `runed` itself stays up; the next Embed RPC resurrects the backend. `"0"` disables suspend | 10m |
-
-`RUNED_MANIFEST` should be **HTTPS** in production. HTTP is permitted
-(for private networks) but emits a warning at startup — a MITM that
-rewrites the manifest can also rewrite the per-artifact SHA256s, so
-artifact integrity collapses to "trust the manifest channel" alone.
-
-The `DefaultManifestURL` referenced in the table above is injected at
-build time via `-ldflags`; see [`CONTRIBUTING.md`](CONTRIBUTING.md#build-time-options)
-for the relevant `make build` flags.
-
-### Manifest format
-
-`runed` reads only a subset of the manifest; extra keys (used by the
-companion `rune` installer) are ignored:
-
-```json
-{
- "version": 1,
- "platforms": {
- "darwin-arm64": {
- "llama_server": {
- "url": "https://.../llama-mac-arm64.tar.gz",
- "sha256": "...",
- "size": 22000000,
- "extract": "tar.gz",
- "exec": "llama-server"
- }
- }
- },
- "models": {
- "qwen3-embedding-0.6b.q6_K": {
- "url": "https://huggingface.co/.../qwen3-embedding-0.6b-q6_k.gguf",
- "sha256": "...",
- "size": 472000000
- }
- },
- "default_model": "qwen3-embedding-0.6b.q6_K"
+ "github.com/CryptoLabInc/runed/client"
+)
+
+func main() {
+ ctx := context.Background()
+
+ c, err := client.Connect(ctx)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer c.Close()
+
+ vector, err := c.Embed(ctx, "Why did we cap retry backoff at five minutes?")
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Println(len(vector))
}
```
-- `extract`: `""` (raw binary placed at `LlamaDir/`) or `"tar.gz"`
- (extracted into `LlamaDir`, `exec` path resolved inside).
-- A sidecar marker `~/.runed/bin/llama-cpp/.llama_server.sha256` tracks
- the last-installed tarball hash so repeat boots don't re-extract.
-- Models are verified by hashing the on-disk file against
- `models[].sha256` — no marker file.
-
-### config.json (`~/.runed/config.json`, optional)
-
-This file is read by **two** components with overlapping schemas; leave
-any field you don't need unset. Unknown fields are ignored.
-
-| Field | Reader | Purpose |
-|---|---|---|
-| `version` | both | Schema version (currently `1`) |
-| `llama_server` | spawn | If set, skip daemon self-bootstrap and use this binary |
-| `model` | spawn | If set, skip daemon self-bootstrap and use this GGUF |
-| `runed_binary` | spawn | Path to the daemon binary (fallback to PATH / `DefaultRunedBinary`) |
-| `idle_timeout` | spawn | Propagated to the spawned daemon as `RUNED_IDLE_TIMEOUT` |
-| `model_variant` | bootstrap | Pick a manifest model variant (overrides `manifest.default_model`) |
-
-Example — minimal config telling the spawn layer to use a custom
-`runed_binary` but leaving artifact resolution to self-bootstrap:
-
-```json
-{
- "version": 1,
- "runed_binary": "/opt/runed/bin/runed",
- "idle_timeout": "1m"
-}
+`client.Connect` first probes the default socket. If no healthy daemon is present, it serializes startup, launches `runed`, waits for health, and reconnects. `client.WithNoSpawn()` is available for tests and explicitly managed deployments.
+
+## Runtime lifecycle
+
+On first boot, `runed` performs the following sequence:
+
+1. Resolve `RUNED_HOME` and the daemon socket.
+2. Fetch the release manifest over HTTPS.
+3. Download and verify the platform-specific `llama-server` archive.
+4. Download and verify the selected Qwen3 GGUF model.
+5. Launch `llama-server` on an ephemeral loopback port.
+6. Serve gRPC over `~/.runed/embedding.sock`.
+
+Subsequent boots reuse artifacts whose SHA-256 hashes still match the manifest.
+
+After `RUNED_IDLE_TIMEOUT` without an embedding RPC, only the `llama-server` child stops. `runed` stays reachable and restarts the backend for the next `Embed` or `EmbedBatch` call. Set the timeout to `0` to disable idle suspension.
+
+## Configuration
+
+| Variable | Purpose | Default |
+| --- | --- | --- |
+| `RUNED_HOME` | Runtime, model, log, and socket directory. | `~/.runed` |
+| `RUNED_MANIFEST` | Self-bootstrap manifest URL. | Build-time release URL |
+| `RUNED_LLAMA_SERVER` | Use an explicit `llama-server` binary and skip that download. | Unset |
+| `RUNED_MODEL` | Use an explicit GGUF model and skip that download. | Unset |
+| `RUNED_MODEL_VARIANT` | Select a named model from the manifest. | `default_model` |
+| `RUNED_CTX_SIZE` | Maximum input context passed to `llama-server`. | `2048` |
+| `RUNED_IDLE_TIMEOUT` | Suspend the model backend after this idle duration. | `10m` |
+
+`RUNED_MANIFEST` should use HTTPS in production. Artifact hashes protect downloads only after the manifest itself has been trusted.
+
+An optional `~/.runed/config.json` can specify `runed_binary`, `llama_server`, `model`, `model_variant`, and `idle_timeout`. Environment variables remain useful for one-off development overrides.
+
+## Release model
+
+The release workflow currently pins **Qwen3-Embedding-0.6B Q8_0**, approximately 610 MB, as the production manifest's default model. The f16 model remains the parity reference; smaller quantizations trade retrieval fidelity for disk and memory savings.
+
+| Variant | Approximate size | Mean cosine vs. f16 | Intended use |
+| --- | ---: | ---: | --- |
+| f16 | 1.1 GB | ≈ 0.99999 | Parity reference |
+| **Q8_0** | **610 MB** | **≈ 0.9993** | **Current release default** |
+| Q6_K | 472 MB | 0.994 | Smaller high-quality alternative |
+| Q5_K_M | 424 MB | 0.990 | Size-sensitive environments |
+| Q4_K_M | 378 MB | 0.971 | Development or secondary reranking |
+
+The selected manifest controls the actual filename, hash, and default. Treat this table as guidance, not a substitute for inspecting a custom manifest.
+
+## Development
+
+Go 1.26.2 or newer is required.
+
+```bash
+make build
+make test
+
+# Full integration coverage with local artifacts
+RUNED_TEST_LLAMA_SERVER=/path/to/llama-server \
+RUNED_TEST_GGUF=/path/to/Qwen3-Embedding-0.6B-Q8_0.gguf \
+go test -race ./...
```
-## Model variants
+See [CONTRIBUTING.md](CONTRIBUTING.md) for protobuf generation, build-time manifest injection, and model parity requirements.
-The f16 GGUF is the parity reference — cosine similarity ≥ 0.9999 against
-sentence-transformers on all 8 fixture texts. Quantized variants trade parity
-for size/latency.
+## Repository map
-| Variant | Size | Mean cosine | Verdict |
-|---|---|---|---|
-| f16 | 1.1 GB | ≈ 0.99999 | parity reference |
-| q8_0 | 610 MB | ≈ 0.9993 | high-fidelity alternative |
-| q6_K | 472 MB | 0.994 | **production default** |
-| q5_K_M | 424 MB | 0.990 | borderline; natural-language only |
-| q4_K_M | 378 MB | 0.971 | rerank-only or dev staging; not sole retrieval backbone |
+| Path | Responsibility |
+| --- | --- |
+| [`cmd/runed/`](cmd/runed/) | Daemon entry point and process lifecycle. |
+| [`client/`](client/) | Auto-spawning Go client. |
+| [`internal/bootstrap/`](internal/bootstrap/) | Manifest, download, verification, extraction, and license installation. |
+| [`internal/backend/`](internal/backend/) | `llama-server` child process and embedding HTTP bridge. |
+| [`internal/ipc/`](internal/ipc/) | Local socket listener and path handling. |
+| [`internal/server/`](internal/server/) | gRPC health, embedding, centroid, and shutdown services. |
+| [`proto/runed/v1/`](proto/runed/v1/) | Public protobuf contract. |
-**Production default is q6_K** (`models/qwen3-embedding-0.6b.q6_K.gguf`) —
-23% smaller than q8_0 with mean cosine 0.994 against the f16 reference.
-f16 is reserved for parity verification against sentence-transformers.
-Set the daemon's `RUNED_MODEL` env var to the desired GGUF path.
+## License and third-party software
-## License
+- `runed`: [Apache License 2.0](LICENSE) with [NOTICE](NOTICE).
+- `llama.cpp` / `llama-server`: MIT; see [`THIRD_PARTY_LICENSES/llama.cpp.LICENSE`](THIRD_PARTY_LICENSES/llama.cpp.LICENSE).
+- Qwen3-Embedding-0.6B: Apache License 2.0; see [`THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE`](THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE).
-- `runed` Go code: MIT (LICENSE file forthcoming).
-- Qwen3-Embedding-0.6B: Apache 2.0.
-- llama.cpp: MIT.
+The bootstrap installs the applicable texts under `$RUNED_HOME/licenses/` beside the downloaded third-party artifacts. See [`THIRD_PARTY_LICENSES/README.md`](THIRD_PARTY_LICENSES/README.md) for the attribution index.
-Redistribution of the bundled `llama-server` binary and GGUF model files is
-permitted under the respective upstream licenses; CryptoLab makes no
-independent claims on those artifacts.
+
+ Part of RUNE · Built by CryptoLab
+
diff --git a/THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE b/THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/THIRD_PARTY_LICENSES/README.md b/THIRD_PARTY_LICENSES/README.md
new file mode 100644
index 0000000..076aef4
--- /dev/null
+++ b/THIRD_PARTY_LICENSES/README.md
@@ -0,0 +1,25 @@
+# Third-party licenses
+
+`runed` downloads and runs the following third-party components at first
+launch (self-bootstrap; see the manifest documentation in the top-level
+README). Their license texts are reproduced verbatim in this directory and
+are also installed to `$RUNED_HOME/licenses/` alongside the artifacts they
+cover, so every machine that receives the binaries receives the licenses.
+
+| Component | Role | License | Upstream |
+|---|---|---|---|
+| llama.cpp (`llama-server`) | Local inference server child process | MIT — [`llama.cpp.LICENSE`](llama.cpp.LICENSE) | |
+| Qwen3-Embedding-0.6B (GGUF) | Embedding model weights | Apache-2.0 — [`Qwen3-Embedding.Apache-2.0.LICENSE`](Qwen3-Embedding.Apache-2.0.LICENSE) | |
+
+Notes:
+
+- The llama.cpp license text is the upstream `LICENSE` file, unmodified
+ (`Copyright (c) 2023-2026 The ggml authors`).
+- The Qwen3-Embedding-0.6B repositories on Hugging Face declare
+ `license: apache-2.0` and ship no LICENSE or NOTICE file of their own, so
+ the canonical Apache License 2.0 text is reproduced here unmodified. No
+ NOTICE contents exist upstream to reproduce (Apache-2.0 §4(d) applies only
+ when the work includes a NOTICE file).
+- `runed` itself is licensed under the Apache License 2.0 — see the top-level
+ [`LICENSE`](../LICENSE) and [`NOTICE`](../NOTICE) (Apache-2.0 §4(d):
+ redistributions must include the NOTICE file).
diff --git a/THIRD_PARTY_LICENSES/llama.cpp.LICENSE b/THIRD_PARTY_LICENSES/llama.cpp.LICENSE
new file mode 100644
index 0000000..e7dca55
--- /dev/null
+++ b/THIRD_PARTY_LICENSES/llama.cpp.LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023-2026 The ggml authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/client/client.go b/client/client.go
index 5652c8e..76f1754 100644
--- a/client/client.go
+++ b/client/client.go
@@ -24,6 +24,7 @@ import (
"sync"
runedv1 "github.com/CryptoLabInc/runed/gen/runed/v1"
+ "github.com/CryptoLabInc/runed/internal/ipc"
"github.com/CryptoLabInc/runed/internal/spawn"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
@@ -40,10 +41,14 @@ type Client struct {
// happy path (non-Unavailable RPCs) is unlocked.
mu sync.Mutex
- conn *grpc.ClientConn
- grpc runedv1.RunedServiceClient
- socketPath string // captured at Connect time for retry/respawn (T9)
- noSpawn bool // captured to avoid retrying when caller opted out (T9)
+ conn *grpc.ClientConn
+ grpc runedv1.RunedServiceClient
+ // socketPath is the canonical path captured at Connect time for
+ // retry and respawn. Dial sites resolve it via ipc.ResolveSocketPath;
+ // spawn.EnsureDaemon needs the canonical form because it
+ // derives the daemon's RUNED_HOME from the path's parent directory.
+ socketPath string
+ noSpawn bool // captured to avoid retrying when the caller opted out
}
// Option configures Connect behavior.
@@ -115,9 +120,14 @@ func Connect(ctx context.Context, opts ...Option) (*Client, error) {
// dialer to receive the full "unix://..." URI as its addr, which then
// becomes "unix:///path" after the net.Dialer prepends no scheme — the
// socket file "unix:///path" of course does not exist.
+//
+// The canonical socketPath is resolved just-in-time for the dial:
+// when it exceeds the sun_path limit the daemon binds a short deterministic
+// alias, and connect() on the canonical path would fail with EINVAL. The
+// Client keeps the canonical form so respawn derives the right RUNED_HOME.
func dialAndProbe(ctx context.Context, socketPath string) (*Client, error) {
conn, err := grpc.NewClient(
- "unix://"+socketPath,
+ "unix://"+ipc.ResolveSocketPath(socketPath),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
@@ -143,7 +153,7 @@ func dialAndProbe(ctx context.Context, socketPath string) (*Client, error) {
func (c *Client) reconnectLocked() error {
c.conn.Close()
conn, err := grpc.NewClient(
- "unix://"+c.socketPath,
+ "unix://"+ipc.ResolveSocketPath(c.socketPath),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
diff --git a/client/client_test.go b/client/client_test.go
index 204d2dd..bdff181 100644
--- a/client/client_test.go
+++ b/client/client_test.go
@@ -4,10 +4,23 @@ import (
"context"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
+
+ runedv1 "github.com/CryptoLabInc/runed/gen/runed/v1"
+ "github.com/CryptoLabInc/runed/internal/ipc"
+ "google.golang.org/grpc"
)
+type longPathHealthServer struct {
+ runedv1.UnimplementedRunedServiceServer
+}
+
+func (longPathHealthServer) Health(context.Context, *runedv1.HealthRequest) (*runedv1.HealthResponse, error) {
+ return &runedv1.HealthResponse{Status: runedv1.HealthResponse_STATUS_OK}, nil
+}
+
// shortTempDir returns a per-test temp dir under /tmp. macOS's $TMPDIR and
// Go's t.TempDir() produce paths that can exceed the 104-byte sockaddr_un
// limit, causing bind EINVAL for unrelated reasons. /tmp keeps paths short.
@@ -80,3 +93,29 @@ func TestConnect_WithNoSpawn_FailsFastWhenNoDaemon(t *testing.T) {
t.Fatal("expected error with WithNoSpawn and no daemon, got nil")
}
}
+
+// TestConnect_LongSocketPath verifies that Listen and Connect independently
+// resolve the same over-limit canonical path and exchange a real Health RPC.
+func TestConnect_LongSocketPath(t *testing.T) {
+ home := filepath.Join(shortTempDir(t), strings.Repeat("d", 60), strings.Repeat("e", 60))
+ canonical := filepath.Join(home, "embedding.sock")
+ lis, err := ipc.Listen(canonical)
+ if err != nil {
+ t.Fatalf("listen long path: %v", err)
+ }
+ gs := grpc.NewServer()
+ runedv1.RegisterRunedServiceServer(gs, longPathHealthServer{})
+ go func() { _ = gs.Serve(lis) }()
+ t.Cleanup(func() {
+ gs.Stop()
+ _ = lis.Close()
+ })
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ c, err := Connect(ctx, WithSocketPath(canonical), WithNoSpawn())
+ if err != nil {
+ t.Fatalf("Connect on long canonical path: %v", err)
+ }
+ _ = c.Close()
+}
diff --git a/cmd/runed/main.go b/cmd/runed/main.go
index 4012cbe..db456e4 100644
--- a/cmd/runed/main.go
+++ b/cmd/runed/main.go
@@ -53,6 +53,7 @@ import (
"github.com/CryptoLabInc/runed/internal/backend"
"github.com/CryptoLabInc/runed/internal/bootstrap"
"github.com/CryptoLabInc/runed/internal/ipc"
+ "github.com/CryptoLabInc/runed/internal/route"
"github.com/CryptoLabInc/runed/internal/server"
"google.golang.org/grpc"
)
@@ -91,7 +92,12 @@ func run() error {
}
logger.Info("paths resolved", "home", paths.Home)
- sockPath := filepath.Join(paths.Home, "embedding.sock")
+ // When $RUNED_HOME is deep, the canonical path can exceed the
+ // platform's sun_path limit (104 bytes on macOS, 108 on Linux) and cannot
+ // be bound or dialed. Resolve it up front so the reachability probe, the
+ // stale-socket cleanup, the ownership watchdog, and the actual bind all
+ // operate on the same (possibly short-aliased) path the client dials.
+ sockPath := ipc.ResolveSocketPath(filepath.Join(paths.Home, "embedding.sock"))
// Early bail-out: if another daemon is already accepting connections on
// our socket, we'd just waste time on self-bootstrap before failing at
@@ -126,6 +132,17 @@ func run() error {
// the multi-minute install window. SetBackend below flips Health
// to STATUS_OK once llama-server is up.
srv := server.New(daemonVersion)
+ srv.SetCentroidCacheDir(paths.Cache)
+ // Restore the IVF centroid set from the disk cache so a restarted daemon
+ // serves with_route immediately; a missing/corrupt cache just means
+ // routing waits for the next SetCentroids push from rune-mcp.
+ if cs, err := route.Load(paths.Cache); err == nil {
+ if err := srv.LoadCentroids(cs); err != nil {
+ logger.Warn("centroid cache rejected", "err", err)
+ } else {
+ logger.Info("centroid cache restored", "version", cs.Version, "nlist", len(cs.Vectors))
+ }
+ }
lis, err := ipc.Listen(sockPath)
if err != nil {
return fmt.Errorf("ipc listen: %w", err)
diff --git a/gen/runed/v1/runed.pb.go b/gen/runed/v1/runed.pb.go
index 7743c39..d928737 100644
--- a/gen/runed/v1/runed.pb.go
+++ b/gen/runed/v1/runed.pb.go
@@ -136,7 +136,11 @@ type EmbedRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Input text. Maximum length is InfoResponse.max_text_length characters.
// Exceeding the limit returns INVALID_ARGUMENT.
- Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"`
+ Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"`
+ // When true the response also carries the vector's IVF cluster assignment
+ // (cluster_id + centroid_set_version). Requires a centroid set loaded via
+ // SetCentroids; otherwise the call fails with FAILED_PRECONDITION.
+ WithRoute bool `protobuf:"varint,2,opt,name=with_route,json=withRoute,proto3" json:"with_route,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -178,13 +182,27 @@ func (x *EmbedRequest) GetText() string {
return ""
}
+func (x *EmbedRequest) GetWithRoute() bool {
+ if x != nil {
+ return x.WithRoute
+ }
+ return false
+}
+
type EmbedResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// L2-normalized embedding. The server always normalizes output
// (llama.cpp default); no opt-out. Length equals InfoResponse.vector_dim.
- Vector []float32 `protobuf:"fixed32,1,rep,packed,name=vector,proto3" json:"vector,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ Vector []float32 `protobuf:"fixed32,1,rep,packed,name=vector,proto3" json:"vector,omitempty"`
+ // IVF hard single assignment (0..nlist-1). Only set when the request asked
+ // for routing (with_route); the vector is already normalized so the
+ // assignment metric is max inner product, matching runespace insert routing.
+ ClusterId uint32 `protobuf:"varint,2,opt,name=cluster_id,json=clusterId,proto3" json:"cluster_id,omitempty"`
+ // Version (content hash) of the centroid set the assignment was routed
+ // against. Callers echo this to the index engine so a stale set is rejected.
+ CentroidSetVersion string `protobuf:"bytes,3,opt,name=centroid_set_version,json=centroidSetVersion,proto3" json:"centroid_set_version,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *EmbedResponse) Reset() {
@@ -224,11 +242,27 @@ func (x *EmbedResponse) GetVector() []float32 {
return nil
}
+func (x *EmbedResponse) GetClusterId() uint32 {
+ if x != nil {
+ return x.ClusterId
+ }
+ return 0
+}
+
+func (x *EmbedResponse) GetCentroidSetVersion() string {
+ if x != nil {
+ return x.CentroidSetVersion
+ }
+ return ""
+}
+
type EmbedBatchRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Input texts. Batch size must not exceed InfoResponse.max_batch_size.
// Each text individually bound by InfoResponse.max_text_length.
- Texts []string `protobuf:"bytes,1,rep,name=texts,proto3" json:"texts,omitempty"`
+ Texts []string `protobuf:"bytes,1,rep,name=texts,proto3" json:"texts,omitempty"`
+ // Same as EmbedRequest.with_route, applied to every embedding in the batch.
+ WithRoute bool `protobuf:"varint,2,opt,name=with_route,json=withRoute,proto3" json:"with_route,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -270,6 +304,13 @@ func (x *EmbedBatchRequest) GetTexts() []string {
return nil
}
+func (x *EmbedBatchRequest) GetWithRoute() bool {
+ if x != nil {
+ return x.WithRoute
+ }
+ return false
+}
+
type EmbedBatchResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// One EmbedResponse per input text, same order as the request.
@@ -364,9 +405,12 @@ type InfoResponse struct {
// INVALID_ARGUMENT.
MaxTextLength int32 `protobuf:"varint,4,opt,name=max_text_length,json=maxTextLength,proto3" json:"max_text_length,omitempty"`
// Maximum batch size accepted by EmbedBatch.
- MaxBatchSize int32 `protobuf:"varint,5,opt,name=max_batch_size,json=maxBatchSize,proto3" json:"max_batch_size,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ MaxBatchSize int32 `protobuf:"varint,5,opt,name=max_batch_size,json=maxBatchSize,proto3" json:"max_batch_size,omitempty"`
+ // Version of the currently loaded IVF centroid set; empty when none is
+ // loaded (with_route requests fail until SetCentroids delivers one).
+ CentroidSetVersion string `protobuf:"bytes,6,opt,name=centroid_set_version,json=centroidSetVersion,proto3" json:"centroid_set_version,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *InfoResponse) Reset() {
@@ -434,6 +478,13 @@ func (x *InfoResponse) GetMaxBatchSize() int32 {
return 0
}
+func (x *InfoResponse) GetCentroidSetVersion() string {
+ if x != nil {
+ return x.CentroidSetVersion
+ }
+ return ""
+}
+
type HealthRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -658,29 +709,341 @@ func (x *ShutdownResponse) GetAccepted() bool {
return false
}
+type SetCentroidsRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Types that are valid to be assigned to Payload:
+ //
+ // *SetCentroidsRequest_Header
+ // *SetCentroidsRequest_Batch
+ Payload isSetCentroidsRequest_Payload `protobuf_oneof:"payload"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SetCentroidsRequest) Reset() {
+ *x = SetCentroidsRequest{}
+ mi := &file_runed_v1_runed_proto_msgTypes[10]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SetCentroidsRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SetCentroidsRequest) ProtoMessage() {}
+
+func (x *SetCentroidsRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_runed_v1_runed_proto_msgTypes[10]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SetCentroidsRequest.ProtoReflect.Descriptor instead.
+func (*SetCentroidsRequest) Descriptor() ([]byte, []int) {
+ return file_runed_v1_runed_proto_rawDescGZIP(), []int{10}
+}
+
+func (x *SetCentroidsRequest) GetPayload() isSetCentroidsRequest_Payload {
+ if x != nil {
+ return x.Payload
+ }
+ return nil
+}
+
+func (x *SetCentroidsRequest) GetHeader() *CentroidSetHeader {
+ if x != nil {
+ if x, ok := x.Payload.(*SetCentroidsRequest_Header); ok {
+ return x.Header
+ }
+ }
+ return nil
+}
+
+func (x *SetCentroidsRequest) GetBatch() *CentroidBatch {
+ if x != nil {
+ if x, ok := x.Payload.(*SetCentroidsRequest_Batch); ok {
+ return x.Batch
+ }
+ }
+ return nil
+}
+
+type isSetCentroidsRequest_Payload interface {
+ isSetCentroidsRequest_Payload()
+}
+
+type SetCentroidsRequest_Header struct {
+ Header *CentroidSetHeader `protobuf:"bytes,1,opt,name=header,proto3,oneof"`
+}
+
+type SetCentroidsRequest_Batch struct {
+ Batch *CentroidBatch `protobuf:"bytes,2,opt,name=batch,proto3,oneof"`
+}
+
+func (*SetCentroidsRequest_Header) isSetCentroidsRequest_Payload() {}
+
+func (*SetCentroidsRequest_Batch) isSetCentroidsRequest_Payload() {}
+
+// CentroidSetHeader opens the stream: identity and shape of the set.
+type CentroidSetHeader struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` // content hash (sha256); must be non-empty
+ Dim uint32 `protobuf:"varint,2,opt,name=dim,proto3" json:"dim,omitempty"` // must equal InfoResponse.vector_dim
+ Nlist uint32 `protobuf:"varint,3,opt,name=nlist,proto3" json:"nlist,omitempty"` // total centroid count that will follow
+ // evi preset the set was trained for (e.g. "IP1") — a version-hash
+ // ingredient. When present, runed recomputes the content hash over the
+ // received vectors and rejects the push on mismatch; empty (legacy
+ // senders) skips that verification.
+ Preset string `protobuf:"bytes,4,opt,name=preset,proto3" json:"preset,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *CentroidSetHeader) Reset() {
+ *x = CentroidSetHeader{}
+ mi := &file_runed_v1_runed_proto_msgTypes[11]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *CentroidSetHeader) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CentroidSetHeader) ProtoMessage() {}
+
+func (x *CentroidSetHeader) ProtoReflect() protoreflect.Message {
+ mi := &file_runed_v1_runed_proto_msgTypes[11]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use CentroidSetHeader.ProtoReflect.Descriptor instead.
+func (*CentroidSetHeader) Descriptor() ([]byte, []int) {
+ return file_runed_v1_runed_proto_rawDescGZIP(), []int{11}
+}
+
+func (x *CentroidSetHeader) GetVersion() string {
+ if x != nil {
+ return x.Version
+ }
+ return ""
+}
+
+func (x *CentroidSetHeader) GetDim() uint32 {
+ if x != nil {
+ return x.Dim
+ }
+ return 0
+}
+
+func (x *CentroidSetHeader) GetNlist() uint32 {
+ if x != nil {
+ return x.Nlist
+ }
+ return 0
+}
+
+func (x *CentroidSetHeader) GetPreset() string {
+ if x != nil {
+ return x.Preset
+ }
+ return ""
+}
+
+// CentroidBatch carries centroids in id order (append order == cluster id).
+type CentroidBatch struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Centroids []*Centroid `protobuf:"bytes,1,rep,name=centroids,proto3" json:"centroids,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *CentroidBatch) Reset() {
+ *x = CentroidBatch{}
+ mi := &file_runed_v1_runed_proto_msgTypes[12]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *CentroidBatch) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CentroidBatch) ProtoMessage() {}
+
+func (x *CentroidBatch) ProtoReflect() protoreflect.Message {
+ mi := &file_runed_v1_runed_proto_msgTypes[12]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use CentroidBatch.ProtoReflect.Descriptor instead.
+func (*CentroidBatch) Descriptor() ([]byte, []int) {
+ return file_runed_v1_runed_proto_rawDescGZIP(), []int{12}
+}
+
+func (x *CentroidBatch) GetCentroids() []*Centroid {
+ if x != nil {
+ return x.Centroids
+ }
+ return nil
+}
+
+type Centroid struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
+ Vec []float32 `protobuf:"fixed32,2,rep,packed,name=vec,proto3" json:"vec,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *Centroid) Reset() {
+ *x = Centroid{}
+ mi := &file_runed_v1_runed_proto_msgTypes[13]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *Centroid) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Centroid) ProtoMessage() {}
+
+func (x *Centroid) ProtoReflect() protoreflect.Message {
+ mi := &file_runed_v1_runed_proto_msgTypes[13]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use Centroid.ProtoReflect.Descriptor instead.
+func (*Centroid) Descriptor() ([]byte, []int) {
+ return file_runed_v1_runed_proto_rawDescGZIP(), []int{13}
+}
+
+func (x *Centroid) GetId() uint32 {
+ if x != nil {
+ return x.Id
+ }
+ return 0
+}
+
+func (x *Centroid) GetVec() []float32 {
+ if x != nil {
+ return x.Vec
+ }
+ return nil
+}
+
+type SetCentroidsResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` // version now active
+ Nlist uint32 `protobuf:"varint,2,opt,name=nlist,proto3" json:"nlist,omitempty"` // centroids loaded
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SetCentroidsResponse) Reset() {
+ *x = SetCentroidsResponse{}
+ mi := &file_runed_v1_runed_proto_msgTypes[14]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SetCentroidsResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SetCentroidsResponse) ProtoMessage() {}
+
+func (x *SetCentroidsResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_runed_v1_runed_proto_msgTypes[14]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SetCentroidsResponse.ProtoReflect.Descriptor instead.
+func (*SetCentroidsResponse) Descriptor() ([]byte, []int) {
+ return file_runed_v1_runed_proto_rawDescGZIP(), []int{14}
+}
+
+func (x *SetCentroidsResponse) GetVersion() string {
+ if x != nil {
+ return x.Version
+ }
+ return ""
+}
+
+func (x *SetCentroidsResponse) GetNlist() uint32 {
+ if x != nil {
+ return x.Nlist
+ }
+ return 0
+}
+
var File_runed_v1_runed_proto protoreflect.FileDescriptor
const file_runed_v1_runed_proto_rawDesc = "" +
"\n" +
- "\x14runed/v1/runed.proto\x12\bruned.v1\"\"\n" +
+ "\x14runed/v1/runed.proto\x12\bruned.v1\"A\n" +
"\fEmbedRequest\x12\x12\n" +
- "\x04text\x18\x01 \x01(\tR\x04text\"+\n" +
+ "\x04text\x18\x01 \x01(\tR\x04text\x12\x1d\n" +
+ "\n" +
+ "with_route\x18\x02 \x01(\bR\twithRoute\"|\n" +
"\rEmbedResponse\x12\x1a\n" +
- "\x06vector\x18\x01 \x03(\x02B\x02\x10\x01R\x06vector\")\n" +
+ "\x06vector\x18\x01 \x03(\x02B\x02\x10\x01R\x06vector\x12\x1d\n" +
+ "\n" +
+ "cluster_id\x18\x02 \x01(\rR\tclusterId\x120\n" +
+ "\x14centroid_set_version\x18\x03 \x01(\tR\x12centroidSetVersion\"H\n" +
"\x11EmbedBatchRequest\x12\x14\n" +
- "\x05texts\x18\x01 \x03(\tR\x05texts\"M\n" +
+ "\x05texts\x18\x01 \x03(\tR\x05texts\x12\x1d\n" +
+ "\n" +
+ "with_route\x18\x02 \x01(\bR\twithRoute\"M\n" +
"\x12EmbedBatchResponse\x127\n" +
"\n" +
"embeddings\x18\x01 \x03(\v2\x17.runed.v1.EmbedResponseR\n" +
"embeddings\"\r\n" +
- "\vInfoRequest\"\xc9\x01\n" +
+ "\vInfoRequest\"\xfb\x01\n" +
"\fInfoResponse\x12%\n" +
"\x0edaemon_version\x18\x01 \x01(\tR\rdaemonVersion\x12%\n" +
"\x0emodel_identity\x18\x02 \x01(\tR\rmodelIdentity\x12\x1d\n" +
"\n" +
"vector_dim\x18\x03 \x01(\x05R\tvectorDim\x12&\n" +
"\x0fmax_text_length\x18\x04 \x01(\x05R\rmaxTextLength\x12$\n" +
- "\x0emax_batch_size\x18\x05 \x01(\x05R\fmaxBatchSize\"\x0f\n" +
+ "\x0emax_batch_size\x18\x05 \x01(\x05R\fmaxBatchSize\x120\n" +
+ "\x14centroid_set_version\x18\x06 \x01(\tR\x12centroidSetVersion\"\x0f\n" +
"\rHealthRequest\"\xa9\x04\n" +
"\x0eHealthResponse\x127\n" +
"\x06status\x18\x01 \x01(\x0e2\x1f.runed.v1.HealthResponse.StatusR\x06status\x12%\n" +
@@ -707,14 +1070,32 @@ const file_runed_v1_runed_proto_rawDesc = "" +
"\x0fShutdownRequest\x12#\n" +
"\rgrace_seconds\x18\x01 \x01(\x05R\fgraceSeconds\".\n" +
"\x10ShutdownResponse\x12\x1a\n" +
- "\baccepted\x18\x01 \x01(\bR\baccepted2\xc8\x02\n" +
+ "\baccepted\x18\x01 \x01(\bR\baccepted\"\x88\x01\n" +
+ "\x13SetCentroidsRequest\x125\n" +
+ "\x06header\x18\x01 \x01(\v2\x1b.runed.v1.CentroidSetHeaderH\x00R\x06header\x12/\n" +
+ "\x05batch\x18\x02 \x01(\v2\x17.runed.v1.CentroidBatchH\x00R\x05batchB\t\n" +
+ "\apayload\"m\n" +
+ "\x11CentroidSetHeader\x12\x18\n" +
+ "\aversion\x18\x01 \x01(\tR\aversion\x12\x10\n" +
+ "\x03dim\x18\x02 \x01(\rR\x03dim\x12\x14\n" +
+ "\x05nlist\x18\x03 \x01(\rR\x05nlist\x12\x16\n" +
+ "\x06preset\x18\x04 \x01(\tR\x06preset\"A\n" +
+ "\rCentroidBatch\x120\n" +
+ "\tcentroids\x18\x01 \x03(\v2\x12.runed.v1.CentroidR\tcentroids\"0\n" +
+ "\bCentroid\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\rR\x02id\x12\x14\n" +
+ "\x03vec\x18\x02 \x03(\x02B\x02\x10\x01R\x03vec\"F\n" +
+ "\x14SetCentroidsResponse\x12\x18\n" +
+ "\aversion\x18\x01 \x01(\tR\aversion\x12\x14\n" +
+ "\x05nlist\x18\x02 \x01(\rR\x05nlist2\x99\x03\n" +
"\fRunedService\x128\n" +
"\x05Embed\x12\x16.runed.v1.EmbedRequest\x1a\x17.runed.v1.EmbedResponse\x12G\n" +
"\n" +
"EmbedBatch\x12\x1b.runed.v1.EmbedBatchRequest\x1a\x1c.runed.v1.EmbedBatchResponse\x125\n" +
"\x04Info\x12\x15.runed.v1.InfoRequest\x1a\x16.runed.v1.InfoResponse\x12;\n" +
"\x06Health\x12\x17.runed.v1.HealthRequest\x1a\x18.runed.v1.HealthResponse\x12A\n" +
- "\bShutdown\x12\x19.runed.v1.ShutdownRequest\x1a\x1a.runed.v1.ShutdownResponseB\x8f\x01\n" +
+ "\bShutdown\x12\x19.runed.v1.ShutdownRequest\x1a\x1a.runed.v1.ShutdownResponse\x12O\n" +
+ "\fSetCentroids\x12\x1d.runed.v1.SetCentroidsRequest\x1a\x1e.runed.v1.SetCentroidsResponse(\x01B\x8f\x01\n" +
"\fcom.runed.v1B\n" +
"RunedProtoP\x01Z2github.com/CryptoLabInc/runed/gen/runed/v1;runedv1\xa2\x02\x03RXX\xaa\x02\bRuned.V1\xca\x02\bRuned\\V1\xe2\x02\x14Runed\\V1\\GPBMetadata\xea\x02\tRuned::V1b\x06proto3"
@@ -731,40 +1112,50 @@ func file_runed_v1_runed_proto_rawDescGZIP() []byte {
}
var file_runed_v1_runed_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
-var file_runed_v1_runed_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
+var file_runed_v1_runed_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
var file_runed_v1_runed_proto_goTypes = []any{
- (HealthResponse_Status)(0), // 0: runed.v1.HealthResponse.Status
- (HealthResponse_Phase)(0), // 1: runed.v1.HealthResponse.Phase
- (*EmbedRequest)(nil), // 2: runed.v1.EmbedRequest
- (*EmbedResponse)(nil), // 3: runed.v1.EmbedResponse
- (*EmbedBatchRequest)(nil), // 4: runed.v1.EmbedBatchRequest
- (*EmbedBatchResponse)(nil), // 5: runed.v1.EmbedBatchResponse
- (*InfoRequest)(nil), // 6: runed.v1.InfoRequest
- (*InfoResponse)(nil), // 7: runed.v1.InfoResponse
- (*HealthRequest)(nil), // 8: runed.v1.HealthRequest
- (*HealthResponse)(nil), // 9: runed.v1.HealthResponse
- (*ShutdownRequest)(nil), // 10: runed.v1.ShutdownRequest
- (*ShutdownResponse)(nil), // 11: runed.v1.ShutdownResponse
+ (HealthResponse_Status)(0), // 0: runed.v1.HealthResponse.Status
+ (HealthResponse_Phase)(0), // 1: runed.v1.HealthResponse.Phase
+ (*EmbedRequest)(nil), // 2: runed.v1.EmbedRequest
+ (*EmbedResponse)(nil), // 3: runed.v1.EmbedResponse
+ (*EmbedBatchRequest)(nil), // 4: runed.v1.EmbedBatchRequest
+ (*EmbedBatchResponse)(nil), // 5: runed.v1.EmbedBatchResponse
+ (*InfoRequest)(nil), // 6: runed.v1.InfoRequest
+ (*InfoResponse)(nil), // 7: runed.v1.InfoResponse
+ (*HealthRequest)(nil), // 8: runed.v1.HealthRequest
+ (*HealthResponse)(nil), // 9: runed.v1.HealthResponse
+ (*ShutdownRequest)(nil), // 10: runed.v1.ShutdownRequest
+ (*ShutdownResponse)(nil), // 11: runed.v1.ShutdownResponse
+ (*SetCentroidsRequest)(nil), // 12: runed.v1.SetCentroidsRequest
+ (*CentroidSetHeader)(nil), // 13: runed.v1.CentroidSetHeader
+ (*CentroidBatch)(nil), // 14: runed.v1.CentroidBatch
+ (*Centroid)(nil), // 15: runed.v1.Centroid
+ (*SetCentroidsResponse)(nil), // 16: runed.v1.SetCentroidsResponse
}
var file_runed_v1_runed_proto_depIdxs = []int32{
3, // 0: runed.v1.EmbedBatchResponse.embeddings:type_name -> runed.v1.EmbedResponse
0, // 1: runed.v1.HealthResponse.status:type_name -> runed.v1.HealthResponse.Status
1, // 2: runed.v1.HealthResponse.phase:type_name -> runed.v1.HealthResponse.Phase
- 2, // 3: runed.v1.RunedService.Embed:input_type -> runed.v1.EmbedRequest
- 4, // 4: runed.v1.RunedService.EmbedBatch:input_type -> runed.v1.EmbedBatchRequest
- 6, // 5: runed.v1.RunedService.Info:input_type -> runed.v1.InfoRequest
- 8, // 6: runed.v1.RunedService.Health:input_type -> runed.v1.HealthRequest
- 10, // 7: runed.v1.RunedService.Shutdown:input_type -> runed.v1.ShutdownRequest
- 3, // 8: runed.v1.RunedService.Embed:output_type -> runed.v1.EmbedResponse
- 5, // 9: runed.v1.RunedService.EmbedBatch:output_type -> runed.v1.EmbedBatchResponse
- 7, // 10: runed.v1.RunedService.Info:output_type -> runed.v1.InfoResponse
- 9, // 11: runed.v1.RunedService.Health:output_type -> runed.v1.HealthResponse
- 11, // 12: runed.v1.RunedService.Shutdown:output_type -> runed.v1.ShutdownResponse
- 8, // [8:13] is the sub-list for method output_type
- 3, // [3:8] is the sub-list for method input_type
- 3, // [3:3] is the sub-list for extension type_name
- 3, // [3:3] is the sub-list for extension extendee
- 0, // [0:3] is the sub-list for field type_name
+ 13, // 3: runed.v1.SetCentroidsRequest.header:type_name -> runed.v1.CentroidSetHeader
+ 14, // 4: runed.v1.SetCentroidsRequest.batch:type_name -> runed.v1.CentroidBatch
+ 15, // 5: runed.v1.CentroidBatch.centroids:type_name -> runed.v1.Centroid
+ 2, // 6: runed.v1.RunedService.Embed:input_type -> runed.v1.EmbedRequest
+ 4, // 7: runed.v1.RunedService.EmbedBatch:input_type -> runed.v1.EmbedBatchRequest
+ 6, // 8: runed.v1.RunedService.Info:input_type -> runed.v1.InfoRequest
+ 8, // 9: runed.v1.RunedService.Health:input_type -> runed.v1.HealthRequest
+ 10, // 10: runed.v1.RunedService.Shutdown:input_type -> runed.v1.ShutdownRequest
+ 12, // 11: runed.v1.RunedService.SetCentroids:input_type -> runed.v1.SetCentroidsRequest
+ 3, // 12: runed.v1.RunedService.Embed:output_type -> runed.v1.EmbedResponse
+ 5, // 13: runed.v1.RunedService.EmbedBatch:output_type -> runed.v1.EmbedBatchResponse
+ 7, // 14: runed.v1.RunedService.Info:output_type -> runed.v1.InfoResponse
+ 9, // 15: runed.v1.RunedService.Health:output_type -> runed.v1.HealthResponse
+ 11, // 16: runed.v1.RunedService.Shutdown:output_type -> runed.v1.ShutdownResponse
+ 16, // 17: runed.v1.RunedService.SetCentroids:output_type -> runed.v1.SetCentroidsResponse
+ 12, // [12:18] is the sub-list for method output_type
+ 6, // [6:12] is the sub-list for method input_type
+ 6, // [6:6] is the sub-list for extension type_name
+ 6, // [6:6] is the sub-list for extension extendee
+ 0, // [0:6] is the sub-list for field type_name
}
func init() { file_runed_v1_runed_proto_init() }
@@ -772,13 +1163,17 @@ func file_runed_v1_runed_proto_init() {
if File_runed_v1_runed_proto != nil {
return
}
+ file_runed_v1_runed_proto_msgTypes[10].OneofWrappers = []any{
+ (*SetCentroidsRequest_Header)(nil),
+ (*SetCentroidsRequest_Batch)(nil),
+ }
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_runed_v1_runed_proto_rawDesc), len(file_runed_v1_runed_proto_rawDesc)),
NumEnums: 2,
- NumMessages: 10,
+ NumMessages: 15,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/gen/runed/v1/runed_grpc.pb.go b/gen/runed/v1/runed_grpc.pb.go
index 15a505b..d45af39 100644
--- a/gen/runed/v1/runed_grpc.pb.go
+++ b/gen/runed/v1/runed_grpc.pb.go
@@ -19,11 +19,12 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
- RunedService_Embed_FullMethodName = "/runed.v1.RunedService/Embed"
- RunedService_EmbedBatch_FullMethodName = "/runed.v1.RunedService/EmbedBatch"
- RunedService_Info_FullMethodName = "/runed.v1.RunedService/Info"
- RunedService_Health_FullMethodName = "/runed.v1.RunedService/Health"
- RunedService_Shutdown_FullMethodName = "/runed.v1.RunedService/Shutdown"
+ RunedService_Embed_FullMethodName = "/runed.v1.RunedService/Embed"
+ RunedService_EmbedBatch_FullMethodName = "/runed.v1.RunedService/EmbedBatch"
+ RunedService_Info_FullMethodName = "/runed.v1.RunedService/Info"
+ RunedService_Health_FullMethodName = "/runed.v1.RunedService/Health"
+ RunedService_Shutdown_FullMethodName = "/runed.v1.RunedService/Shutdown"
+ RunedService_SetCentroids_FullMethodName = "/runed.v1.RunedService/SetCentroids"
)
// RunedServiceClient is the client API for RunedService service.
@@ -48,6 +49,12 @@ type RunedServiceClient interface {
// Shutdown requests a graceful drain. The daemon stops accepting new
// requests, completes in-flight ones, and exits.
Shutdown(ctx context.Context, in *ShutdownRequest, opts ...grpc.CallOption) (*ShutdownResponse, error)
+ // SetCentroids replaces the daemon's IVF centroid set (header frame, then
+ // id-ordered batches — the same wire shape runespace's GetCentroids
+ // streams). rune-mcp relays the set here from the Console so Embed can route
+ // inserts (with_route) without runed ever dialing the index engine. The
+ // set is persisted to the daemon cache and survives restarts.
+ SetCentroids(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[SetCentroidsRequest, SetCentroidsResponse], error)
}
type runedServiceClient struct {
@@ -108,6 +115,19 @@ func (c *runedServiceClient) Shutdown(ctx context.Context, in *ShutdownRequest,
return out, nil
}
+func (c *runedServiceClient) SetCentroids(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[SetCentroidsRequest, SetCentroidsResponse], error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ stream, err := c.cc.NewStream(ctx, &RunedService_ServiceDesc.Streams[0], RunedService_SetCentroids_FullMethodName, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ x := &grpc.GenericClientStream[SetCentroidsRequest, SetCentroidsResponse]{ClientStream: stream}
+ return x, nil
+}
+
+// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
+type RunedService_SetCentroidsClient = grpc.ClientStreamingClient[SetCentroidsRequest, SetCentroidsResponse]
+
// RunedServiceServer is the server API for RunedService service.
// All implementations should embed UnimplementedRunedServiceServer
// for forward compatibility.
@@ -130,6 +150,12 @@ type RunedServiceServer interface {
// Shutdown requests a graceful drain. The daemon stops accepting new
// requests, completes in-flight ones, and exits.
Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error)
+ // SetCentroids replaces the daemon's IVF centroid set (header frame, then
+ // id-ordered batches — the same wire shape runespace's GetCentroids
+ // streams). rune-mcp relays the set here from the Console so Embed can route
+ // inserts (with_route) without runed ever dialing the index engine. The
+ // set is persisted to the daemon cache and survives restarts.
+ SetCentroids(grpc.ClientStreamingServer[SetCentroidsRequest, SetCentroidsResponse]) error
}
// UnimplementedRunedServiceServer should be embedded to have
@@ -154,6 +180,9 @@ func (UnimplementedRunedServiceServer) Health(context.Context, *HealthRequest) (
func (UnimplementedRunedServiceServer) Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Shutdown not implemented")
}
+func (UnimplementedRunedServiceServer) SetCentroids(grpc.ClientStreamingServer[SetCentroidsRequest, SetCentroidsResponse]) error {
+ return status.Error(codes.Unimplemented, "method SetCentroids not implemented")
+}
func (UnimplementedRunedServiceServer) testEmbeddedByValue() {}
// UnsafeRunedServiceServer may be embedded to opt out of forward compatibility for this service.
@@ -264,6 +293,13 @@ func _RunedService_Shutdown_Handler(srv interface{}, ctx context.Context, dec fu
return interceptor(ctx, in, info, handler)
}
+func _RunedService_SetCentroids_Handler(srv interface{}, stream grpc.ServerStream) error {
+ return srv.(RunedServiceServer).SetCentroids(&grpc.GenericServerStream[SetCentroidsRequest, SetCentroidsResponse]{ServerStream: stream})
+}
+
+// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
+type RunedService_SetCentroidsServer = grpc.ClientStreamingServer[SetCentroidsRequest, SetCentroidsResponse]
+
// RunedService_ServiceDesc is the grpc.ServiceDesc for RunedService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -292,6 +328,12 @@ var RunedService_ServiceDesc = grpc.ServiceDesc{
Handler: _RunedService_Shutdown_Handler,
},
},
- Streams: []grpc.StreamDesc{},
+ Streams: []grpc.StreamDesc{
+ {
+ StreamName: "SetCentroids",
+ Handler: _RunedService_SetCentroids_Handler,
+ ClientStreams: true,
+ },
+ },
Metadata: "runed/v1/runed.proto",
}
diff --git a/go.mod b/go.mod
index edbfebd..3791bb1 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/CryptoLabInc/runed
go 1.26.2
require (
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
)
@@ -11,5 +12,4 @@ require (
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
)
diff --git a/internal/backend/embed.go b/internal/backend/embed.go
index 4fe3a8d..19f538b 100644
--- a/internal/backend/embed.go
+++ b/internal/backend/embed.go
@@ -8,10 +8,24 @@ import (
"fmt"
"io"
"net/http"
+ "time"
)
const maxErrorBodyBytes = 4 << 10 // 4 KiB cap for reading an error body
+// embedRequestTimeout is an absolute ceiling on a single embed HTTP call.
+// http.DefaultClient has no timeout and the caller's ctx may carry none, so
+// without this a llama-server that accepts the TCP connection but never
+// responds (a hang, not a crash — a crash resets the connection) would block
+// the call forever. That call holds inflightMu.RLock, so a forever-blocked
+// embed would also wedge restartIfDead's inflightMu.Lock() — the recovery path
+// that exists precisely to replace a stuck server. The ceiling is far above
+// any legitimate embed, including deep-queue waits observed under heavy
+// concurrency (~10s), so it only ever trips on a genuinely wedged backend;
+// the request then fails, releasing the RLock, and the next EnsureStarted
+// restarts the child. A var (not const) so tests can shrink it.
+var embedRequestTimeout = 120 * time.Second
+
// ErrNotStarted is returned when Embed/EmbedBatch is invoked but the
// backend has no live llama-server (port == 0). Callers can recover by
// invoking EnsureStarted and retrying: the typical trigger is the idle
@@ -54,6 +68,13 @@ func (b *LlamaBackend) doJSON(ctx context.Context, path string, in any, out any)
return fmt.Errorf("marshal request: %w", err)
}
+ // Bound the call so a hung (not crashed) llama-server can't block it — and
+ // thus the inflightMu.RLock it holds — forever. WithTimeout fires at the
+ // earlier of the caller's deadline or our ceiling, so a caller with a
+ // tighter deadline still wins.
+ ctx, cancel := context.WithTimeout(ctx, embedRequestTimeout)
+ defer cancel()
+
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return err
@@ -77,6 +98,10 @@ func (b *LlamaBackend) doJSON(ctx context.Context, path string, in any, out any)
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode: %w", err)
}
+ // A completed request is the strongest proof of life there is — it keeps
+ // EnsureStarted's health verdict from mistaking a saturated child (whose
+ // /health probes starve under inference load) for a dead one.
+ b.noteAlive()
return nil
}
diff --git a/internal/backend/embedtimeout_test.go b/internal/backend/embedtimeout_test.go
new file mode 100644
index 0000000..d74fd5a
--- /dev/null
+++ b/internal/backend/embedtimeout_test.go
@@ -0,0 +1,70 @@
+//go:build !windows
+
+package backend
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+// TestStuckServerDoesNotWedgeDrain reproduces the deadlock a hung (not crashed)
+// llama-server could cause: an in-flight Embed holds inflightMu.RLock while its
+// HTTP call blocks, and Stop() (same inflightMu.Lock the recovery path takes)
+// would then wait forever. With embedRequestTimeout bounding the call, the
+// embed fails, releases the RLock, and the drain completes.
+func TestStuckServerDoesNotWedgeDrain(t *testing.T) {
+ stop := make(chan struct{})
+ fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ select {
+ case <-r.Context().Done(): // client (embedRequestTimeout) cancelled
+ case <-stop: // test teardown
+ }
+ }))
+ defer fake.Close() // runs last — after the handler is unblocked
+ defer close(stop) // runs first — lets any stuck handler return
+
+ orig := embedRequestTimeout
+ embedRequestTimeout = 300 * time.Millisecond
+ defer func() { embedRequestTimeout = orig }()
+
+ host, port := splitHostPort(t, fake.URL)
+ b := NewLlamaBackend(Config{Host: host})
+ cmd := attachPlaceholderChild(t, b, port)
+ defer cleanupPlaceholderChild(cmd)
+
+ embedErr := make(chan error, 1)
+ start := time.Now()
+ go func() {
+ _, err := b.Embed(context.Background(), "hello", true) // no caller deadline
+ embedErr <- err
+ }()
+
+ // Must fail on its own timeout, not hang forever.
+ select {
+ case err := <-embedErr:
+ if err == nil {
+ t.Fatal("embed against a hung server returned nil; expected a timeout error")
+ }
+ if d := time.Since(start); d > 3*time.Second {
+ t.Fatalf("embed took %v; embedRequestTimeout not bounding the call", d)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("embed did not return — embedRequestTimeout is not bounding the HTTP call")
+ }
+
+ // The drain (inflightMu.Lock, as restartIfDead does) must now complete.
+ drained := make(chan struct{})
+ go func() {
+ b.inflightMu.Lock()
+ b.inflightMu.Unlock()
+ close(drained)
+ }()
+ select {
+ case <-drained:
+ case <-time.After(2 * time.Second):
+ t.Fatal("inflightMu never drained — a stuck embed still wedges the recovery path")
+ }
+}
diff --git a/internal/backend/llama.go b/internal/backend/llama.go
index 0caf57f..469f123 100644
--- a/internal/backend/llama.go
+++ b/internal/backend/llama.go
@@ -15,10 +15,28 @@ import (
"regexp"
"strconv"
"sync"
+ "sync/atomic"
"syscall"
"time"
)
+// Health-verdict tuning. A saturated single-slot llama-server can miss the
+// quick per-RPC probe (its HTTP loop is starved by inference) while being
+// perfectly alive — killing it then severs every queued embed and the
+// restarted server saturates again immediately, a restart loop observed in
+// production (dozens of kills over 3 minutes, 244 embeds cut with EOF).
+const (
+ // quickProbeTimeout is the cheap per-RPC /health probe. Misses under
+ // saturation are expected and are NOT treated as death on their own.
+ quickProbeTimeout = 500 * time.Millisecond
+ // verdictProbeTimeout is the generous probe used when actually deciding
+ // whether to restart.
+ verdictProbeTimeout = 2 * time.Second
+ // aliveGrace: if any embed completed or probe succeeded this recently,
+ // the child is alive by definition — a failed quick probe means busy.
+ aliveGrace = 15 * time.Second
+)
+
type Config struct {
BinaryPath string
ModelPath string
@@ -80,6 +98,26 @@ type LlamaBackend struct {
// short-lived RPC context, so the resurrected llama-server outlives
// the request that woke it.
daemonCtx context.Context
+
+ // restartMu single-flights the drain-and-verdict restart path so a burst
+ // of RPCs that all missed the quick probe elects one leader; followers
+ // find the child verified (or restarted) and return. Lock order:
+ // restartMu → inflightMu → lifecycleMu.
+ restartMu sync.Mutex
+ // lastAlive is the unix-nano time of the last proof of life: a successful
+ // /health probe or a completed embed HTTP call. It distinguishes a busy
+ // child (making progress, probes starved) from a dead one.
+ lastAlive atomic.Int64
+}
+
+// noteAlive records a proof of life. Called on successful health probes and
+// completed embed calls (see embed.go).
+func (b *LlamaBackend) noteAlive() { b.lastAlive.Store(time.Now().UnixNano()) }
+
+// recentlyAlive reports whether a proof of life landed within grace.
+func (b *LlamaBackend) recentlyAlive(grace time.Duration) bool {
+ ns := b.lastAlive.Load()
+ return ns != 0 && time.Since(time.Unix(0, ns)) < grace
}
func NewLlamaBackend(cfg Config) *LlamaBackend {
@@ -129,32 +167,105 @@ func (b *LlamaBackend) Start(ctx context.Context) error {
// entry: a healthy backend returns within milliseconds (just a quick
// /health probe under lifecycleMu).
//
+// A failed quick probe on a running child is NOT treated as death: a
+// saturated llama-server starves its HTTP loop and misses the probe while
+// still making progress. If a proof of life (completed embed or successful
+// probe) landed within aliveGrace the child is considered busy and left
+// alone; only a child with no life signs goes to restartIfDead, which drains
+// in-flight work and re-probes before killing anything.
+//
// Pre-condition: Start has been called at least once so daemonCtx is
// recorded. Returns an error if the daemonCtx has been cancelled
// (process shutdown in progress).
func (b *LlamaBackend) EnsureStarted() error {
b.lifecycleMu.Lock()
- defer b.lifecycleMu.Unlock()
if b.daemonCtx == nil {
+ b.lifecycleMu.Unlock()
return errors.New("backend not initialized; Start must be called once first")
}
- if err := b.daemonCtx.Err(); err != nil {
+ dctx := b.daemonCtx
+ if err := dctx.Err(); err != nil {
+ b.lifecycleMu.Unlock()
return fmt.Errorf("backend daemon context done: %w", err)
}
b.mu.Lock()
haveCmd := b.cmd != nil
b.mu.Unlock()
- if haveCmd {
- if b.IsHealthy(b.daemonCtx) {
- return nil
+
+ if !haveCmd { // intentional idle-suspend (or a crash) — plain cold start
+ slog.Info("backend: cold start (resuming after suspend)")
+ start := time.Now()
+ err := b.startLocked(dctx)
+ if err == nil {
+ slog.Info("backend: cold start complete",
+ "port", b.Port(),
+ "duration_ms", time.Since(start).Milliseconds())
}
- slog.Warn("backend: process alive but unhealthy, restarting")
+ b.lifecycleMu.Unlock()
+ return err
+ }
+
+ if b.probeHealthy(dctx, quickProbeTimeout) {
+ b.lifecycleMu.Unlock()
+ return nil
+ }
+ if b.recentlyAlive(aliveGrace) {
+ // Probe starved but work is completing — saturated, not dead.
+ b.lifecycleMu.Unlock()
+ return nil
+ }
+ b.lifecycleMu.Unlock()
+ // No proof of life within grace: escalate — but restartIfDead still
+ // drains and re-probes before it kills anything.
+ return b.restartIfDead(dctx)
+}
+
+// restartIfDead is the only path that may kill an unresponsive child. It
+// tells busy apart from dead by construction:
+//
+// 1. restartMu single-flights bursts of suspicious RPCs;
+// 2. a generous re-probe catches a child a predecessor already fixed;
+// 3. the inflightMu writer lock DRAINS every in-flight embed — a saturated
+// child empties its queue right here (and nothing gets severed with EOF,
+// which the old path did by skipping this lock);
+// 4. a post-drain re-probe: a child that answers now was busy — restart is
+// skipped. Only a child that stays unresponsive with zero load is dead.
+func (b *LlamaBackend) restartIfDead(dctx context.Context) error {
+ b.restartMu.Lock()
+ defer b.restartMu.Unlock()
+
+ // Followers queued behind a leader re-check the cheap signals first: the
+ // leader's drain let embeds complete (stamping lastAlive) and a restart's
+ // startup health poll stamps it too — without this, every queued follower
+ // would run its own drain cycle in series.
+ if b.recentlyAlive(aliveGrace) {
+ return nil
+ }
+ if b.probeHealthy(dctx, verdictProbeTimeout) {
+ return nil // recovered (or a predecessor restarted it)
+ }
+
+ b.inflightMu.Lock()
+ defer b.inflightMu.Unlock()
+ b.lifecycleMu.Lock()
+ defer b.lifecycleMu.Unlock()
+
+ b.mu.Lock()
+ haveCmd := b.cmd != nil
+ b.mu.Unlock()
+ if haveCmd && b.probeHealthy(dctx, verdictProbeTimeout) {
+ slog.Info("backend: health recovered after draining in-flight work — busy, not dead; restart skipped")
+ return nil
+ }
+
+ if haveCmd {
+ slog.Warn("backend: unresponsive with no in-flight work, restarting")
_ = b.stopLocked(context.Background())
}
- slog.Info("backend: cold start (resuming after suspend)")
+ slog.Info("backend: cold start (restart after failed health verdict)")
start := time.Now()
- if err := b.startLocked(b.daemonCtx); err != nil {
+ if err := b.startLocked(dctx); err != nil {
return err
}
slog.Info("backend: cold start complete",
@@ -334,12 +445,35 @@ func (b *LlamaBackend) getCmd() *exec.Cmd {
return b.cmd
}
+// IsHealthy probes /health and stamps proof of life on success. noteAlive is
+// here, not in probeHealthy, so restart-decision probes (EnsureStarted quick,
+// restartIfDead verdict) don't reset the liveness clock as a side effect.
+//
+// Trade-off (TestQuickProbeStampEdge): without the quick-probe stamp, a rare
+// edge (idle → slow first embed → concurrent second request with a starved
+// quick probe) makes that one request pay the verdict probe (~1.7s vs ~0.5s).
+// No false restart, no drain block, self-corrects on the next embed. If it ever
+// matters, stamp in restartIfDead's verdict probe rather than undo the split.
func (b *LlamaBackend) IsHealthy(ctx context.Context) bool {
+ ok := b.probeHealthy(ctx, quickProbeTimeout)
+ if ok {
+ b.noteAlive()
+ }
+ return ok
+}
+
+// probeHealthy GETs /health with the given budget and reports whether the
+// server answered 200. It does NOT record proof of life — callers that treat a
+// success as liveness (IsHealthy) stamp it themselves; the direct callers
+// (EnsureStarted's quick probe, restartIfDead's verdict probe) intentionally
+// do not, so a probe made only to decide whether to restart doesn't also reset
+// the idle/liveness clock.
+func (b *LlamaBackend) probeHealthy(ctx context.Context, timeout time.Duration) bool {
port := b.Port()
if port == 0 {
return false
}
- ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
+ ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
url := fmt.Sprintf("http://%s:%d/health", b.cfg.Host, port)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
@@ -367,6 +501,12 @@ func (b *LlamaBackend) Serving(ctx context.Context) ServingState {
if b.IsHealthy(ctx) {
return ServingOK
}
+ if b.recentlyAlive(aliveGrace) {
+ // Probe starved by a saturated child that is still completing
+ // work — it IS serving, just busy. Reporting DEGRADED here made
+ // dashboards cry wolf during every heavy embed burst.
+ return ServingOK
+ }
return ServingDegraded
}
if st == childSuspended {
diff --git a/internal/backend/noteAliveedge_test.go b/internal/backend/noteAliveedge_test.go
new file mode 100644
index 0000000..121069c
--- /dev/null
+++ b/internal/backend/noteAliveedge_test.go
@@ -0,0 +1,92 @@
+//go:build !windows
+
+package backend
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// TestQuickProbeStampEdge measures the "idle → slow first embed → concurrent
+// second request" edge that the EnsureStarted quick-probe stamp covers. It
+// times the SECOND request's EnsureStarted while the first embed holds the
+// slot and /health is starved (quick probe misses). Current code stamps
+// lastAlive on the first request's quick probe, so the second short-circuits
+// fast; moving noteAlive to IsHealthy drops that stamp, so the second escalates
+// into restartIfDead (extra verdict-probe latency). The test asserts the second
+// request stays bounded either way (no false restart, self-correcting) and
+// prints the latency so the behavior difference is visible.
+func TestQuickProbeStampEdge(t *testing.T) {
+ var inflight atomic.Bool
+ embedStarted := make(chan struct{})
+ embedRelease := make(chan struct{})
+ var startedOnce atomic.Bool
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ if inflight.Load() {
+ // Starved under load: block past quickProbeTimeout (500ms) but
+ // recover within verdictProbeTimeout (2s).
+ select {
+ case <-time.After(1200 * time.Millisecond):
+ case <-r.Context().Done():
+ return
+ }
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+ mux.HandleFunc("/v1/embeddings", func(w http.ResponseWriter, r *http.Request) {
+ inflight.Store(true)
+ if !startedOnce.Swap(true) {
+ close(embedStarted)
+ }
+ <-embedRelease
+ inflight.Store(false)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"embedding":[0.1,0.2,0.3]}]}`))
+ })
+ fake := httptest.NewServer(mux)
+ defer fake.Close()
+ defer close(embedRelease)
+
+ host, port := splitHostPort(t, fake.URL)
+ b := NewLlamaBackend(Config{Host: host, BinaryPath: "/usr/bin/false"})
+ b.daemonCtx = context.Background()
+ cmd := attachPlaceholderChild(t, b, port)
+ defer cleanupPlaceholderChild(cmd)
+
+ // Request A: EnsureStarted (idle server → quick probe succeeds) then Embed
+ // (holds the slot). Mirrors server.go's Embed handler.
+ go func() {
+ if err := b.EnsureStarted(); err != nil {
+ t.Errorf("A EnsureStarted: %v", err)
+ return
+ }
+ _, _ = b.Embed(context.Background(), "A", true)
+ }()
+
+ select {
+ case <-embedStarted:
+ case <-time.After(3 * time.Second):
+ t.Fatal("embed A never reached the server")
+ }
+
+ // Request B: time its EnsureStarted while A holds the slot and /health is starved.
+ start := time.Now()
+ err := b.EnsureStarted()
+ elapsed := time.Since(start)
+ if err != nil {
+ t.Fatalf("B EnsureStarted returned error (unexpected restart/kill?): %v", err)
+ }
+ if b.getCmd() != cmd {
+ t.Fatal("B path restarted the child — false kill of a busy server")
+ }
+ t.Logf("B EnsureStarted latency while server busy: %v", elapsed.Round(time.Millisecond))
+ if elapsed > 5*time.Second {
+ t.Fatalf("B blocked too long (%v) — likely stuck on drain", elapsed)
+ }
+}
diff --git a/internal/backend/verdict_test.go b/internal/backend/verdict_test.go
new file mode 100644
index 0000000..8000241
--- /dev/null
+++ b/internal/backend/verdict_test.go
@@ -0,0 +1,139 @@
+//go:build !windows
+
+package backend
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// These tests pin the busy-vs-dead health verdict. The production failure
+// they guard against: a saturated single-slot llama-server starves its
+// /health endpoint, the old EnsureStarted treated one missed 500ms probe as
+// death and killed the child WITHOUT draining in-flight embeds — severing
+// them with EOF — and the restarted child saturated again immediately,
+// looping every ~6s.
+
+// newVerdictBackend wires a fake llama-server and a placeholder child into a
+// backend whose daemonCtx is set, mirroring the state after a real Start.
+func newVerdictBackend(t *testing.T, handler http.Handler) (*LlamaBackend, *httptest.Server) {
+ t.Helper()
+ fake := httptest.NewServer(handler)
+ t.Cleanup(fake.Close)
+ host, port := splitHostPort(t, fake.URL)
+ b := NewLlamaBackend(Config{Host: host})
+ cmd := attachPlaceholderChild(t, b, port)
+ t.Cleanup(func() { cleanupPlaceholderChild(cmd) })
+ b.daemonCtx = context.Background()
+ return b, fake
+}
+
+// TestEnsureStartedBusyChildIsLeftAlone: the quick probe fails but an embed
+// completed moments ago — EnsureStarted must treat the child as busy and
+// return nil without restarting (same cmd, no cold start).
+func TestEnsureStartedBusyChildIsLeftAlone(t *testing.T) {
+ b, _ := newVerdictBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable) // /health starved
+ }))
+ before := b.getCmd()
+
+ b.noteAlive() // an embed just completed
+ if err := b.EnsureStarted(); err != nil {
+ t.Fatalf("EnsureStarted on a busy child: %v", err)
+ }
+ if b.getCmd() != before {
+ t.Fatal("busy child was restarted despite a fresh proof of life")
+ }
+}
+
+// TestEnsureStartedDrainRecoverySkipsRestart: no recent proof of life, but
+// the child answers the generous verdict probe once its (simulated) queue
+// drains — restartIfDead must skip the restart and keep the same child.
+func TestEnsureStartedDrainRecoverySkipsRestart(t *testing.T) {
+ var busy atomic.Bool
+ busy.Store(true)
+ received := make(chan struct{})
+ release := make(chan struct{})
+ b, _ := newVerdictBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/health") {
+ if busy.Load() {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ // the in-flight embed: blocks until released, then "queue drained"
+ close(received)
+ <-release
+ busy.Store(false)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"embedding":[0.1]}]}`))
+ }))
+ before := b.getCmd()
+
+ embedDone := make(chan error, 1)
+ go func() {
+ _, err := b.Embed(context.Background(), "x", true)
+ embedDone <- err
+ }()
+ // Handshake, not a sleep: the drain assertion below is only meaningful
+ // once the embed actually holds its RLock inside the fake server.
+ select {
+ case <-received:
+ case <-time.After(2 * time.Second):
+ t.Fatal("embed never reached the fake llama-server")
+ }
+
+ ensureDone := make(chan error, 1)
+ go func() { ensureDone <- b.EnsureStarted() }()
+
+ // EnsureStarted must be draining (blocked on inflightMu), not killing:
+ select {
+ case err := <-ensureDone:
+ t.Fatalf("EnsureStarted returned (%v) without waiting for the in-flight embed", err)
+ case <-time.After(300 * time.Millisecond):
+ }
+
+ close(release) // queue drains; health flips to 200
+
+ if err := <-embedDone; err != nil {
+ t.Fatalf("in-flight embed was severed: %v", err)
+ }
+ select {
+ case err := <-ensureDone:
+ if err != nil {
+ t.Fatalf("EnsureStarted after drain recovery: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("EnsureStarted never returned after drain")
+ }
+ if b.getCmd() != before {
+ t.Fatal("child was restarted even though it recovered after the drain")
+ }
+}
+
+// TestEnsureStartedDeadChildIsRestarted: no proof of life, health stays down
+// through the drain — the child must actually be replaced. The relaunch uses
+// a binary that exits immediately, so EnsureStarted surfaces the start error;
+// what matters is that the dead child was removed.
+func TestEnsureStartedDeadChildIsRestarted(t *testing.T) {
+ b, _ := newVerdictBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable) // dead: never recovers
+ }))
+ b.cfg.BinaryPath = "/usr/bin/false"
+ before := b.getCmd()
+
+ err := b.EnsureStarted()
+ if err == nil {
+ t.Fatal("EnsureStarted should surface the failed relaunch")
+ }
+ if cur := b.getCmd(); cur == before {
+ t.Fatal("dead child was not replaced")
+ }
+}
diff --git a/internal/bootstrap/install.go b/internal/bootstrap/install.go
index dcf0ce1..86712f4 100644
--- a/internal/bootstrap/install.go
+++ b/internal/bootstrap/install.go
@@ -81,6 +81,12 @@ func EnsureAll(ctx context.Context, p *Paths, m *Manifest, logger *slog.Logger,
return "", "", "", fmt.Errorf("install lock: %w", err)
}
defer lock.Release()
+ // Install the notices before any third-party artifact can be downloaded.
+ // Failing closed keeps every successful artifact installation accompanied
+ // by the license text that permits its redistribution.
+ if err := installLicenses(p); err != nil {
+ return "", "", "", fmt.Errorf("install licenses: %w", err)
+ }
llamaBinPath, llamaSpec, err := ensureLlamaServer(ctx, p, m, logger, reporter)
if err != nil {
@@ -124,6 +130,9 @@ func EnsureLlamaServer(ctx context.Context, p *Paths, m *Manifest, logger *slog.
return "", fmt.Errorf("install lock: %w", err)
}
defer lock.Release()
+ if err := installLicenses(p); err != nil {
+ return "", fmt.Errorf("install licenses: %w", err)
+ }
llamaBinPath, llamaSpec, err := ensureLlamaServer(ctx, p, m, logger, reporter)
if err != nil {
@@ -166,6 +175,9 @@ func EnsureModel(ctx context.Context, p *Paths, m *Manifest, logger *slog.Logger
return "", "", fmt.Errorf("install lock: %w", err)
}
defer lock.Release()
+ if err := installLicenses(p); err != nil {
+ return "", "", fmt.Errorf("install licenses: %w", err)
+ }
modelPath, modelSpec, err := ensureModel(ctx, p, m, variant, logger, reporter)
if err != nil {
diff --git a/internal/bootstrap/install_test.go b/internal/bootstrap/install_test.go
index 29e22d3..c349085 100644
--- a/internal/bootstrap/install_test.go
+++ b/internal/bootstrap/install_test.go
@@ -226,6 +226,9 @@ func TestEnsureModel_NoLlamaServerEntryNeeded(t *testing.T) {
if gotVariant != variant {
t.Errorf("got variant %q, want %q", gotVariant, variant)
}
+ if _, err := os.Stat(filepath.Join(dir, "licenses", "THIRD_PARTY_LICENSES", "Qwen3-Embedding.Apache-2.0.LICENSE")); err != nil {
+ t.Fatalf("partial model bootstrap did not install license texts: %v", err)
+ }
}
// Stage tick fires on Ensure* entry so Health flips Phase even when
diff --git a/internal/bootstrap/licenses.go b/internal/bootstrap/licenses.go
new file mode 100644
index 0000000..3fbb419
--- /dev/null
+++ b/internal/bootstrap/licenses.go
@@ -0,0 +1,37 @@
+package bootstrap
+
+import (
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+
+ runed "github.com/CryptoLabInc/runed"
+)
+
+// installLicenses copies the embedded license texts (runed's own LICENSE plus
+// the third-party texts for llama-server and the Qwen3 GGUF model) into
+// $RUNED_HOME/licenses/, so the machine that just received the downloaded
+// artifacts also holds the licenses that cover them. Idempotent —
+// files are rewritten in place on every bootstrap, so license updates ship
+// with daemon updates.
+func installLicenses(p *Paths) error {
+ root := filepath.Join(p.Home, "licenses")
+ return fs.WalkDir(runed.LicenseFS, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ dst := filepath.Join(root, path)
+ if d.IsDir() {
+ return os.MkdirAll(dst, 0o755)
+ }
+ data, err := runed.LicenseFS.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("read embedded %s: %w", path, err)
+ }
+ if err := os.WriteFile(dst, data, 0o644); err != nil {
+ return fmt.Errorf("write %s: %w", dst, err)
+ }
+ return nil
+ })
+}
diff --git a/internal/bootstrap/licenses_test.go b/internal/bootstrap/licenses_test.go
new file mode 100644
index 0000000..35e6ffa
--- /dev/null
+++ b/internal/bootstrap/licenses_test.go
@@ -0,0 +1,118 @@
+package bootstrap
+
+import (
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ runed "github.com/CryptoLabInc/runed"
+)
+
+// installLicenses must land every embedded license text under
+// $RUNED_HOME/licenses/, byte-identical to the embedded copy.
+func TestInstallLicenses_WritesAllTextsVerbatim(t *testing.T) {
+ home := t.TempDir()
+ p := &Paths{Home: home}
+
+ if err := installLicenses(p); err != nil {
+ t.Fatalf("installLicenses: %v", err)
+ }
+
+ for _, path := range []string{
+ "LICENSE",
+ "NOTICE",
+ "THIRD_PARTY_LICENSES/README.md",
+ "THIRD_PARTY_LICENSES/llama.cpp.LICENSE",
+ "THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE",
+ } {
+ want, err := runed.LicenseFS.ReadFile(path)
+ if err != nil {
+ t.Fatalf("embedded %s missing: %v", path, err)
+ }
+ got, err := os.ReadFile(filepath.Join(home, "licenses", path))
+ if err != nil {
+ t.Fatalf("installed %s missing: %v", path, err)
+ }
+ if string(got) != string(want) {
+ t.Errorf("%s: installed copy differs from embedded text", path)
+ }
+ }
+}
+
+// Every public install entry point must stop before installing an artifact when
+// the accompanying license texts cannot be written.
+func TestEnsureEntryPointsFailWhenLicensesCannotBeInstalled(t *testing.T) {
+ tests := []struct {
+ name string
+ run func(*Paths, *Manifest) error
+ }{
+ {
+ name: "all",
+ run: func(p *Paths, m *Manifest) error {
+ _, _, _, err := EnsureAll(t.Context(), p, m, slog.Default(), nil)
+ return err
+ },
+ },
+ {
+ name: "llama server",
+ run: func(p *Paths, m *Manifest) error {
+ _, err := EnsureLlamaServer(t.Context(), p, m, slog.Default(), nil)
+ return err
+ },
+ },
+ {
+ name: "model",
+ run: func(p *Paths, m *Manifest) error {
+ _, _, err := EnsureModel(t.Context(), p, m, slog.Default(), nil)
+ return err
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv(EnvHome, home)
+ p, err := Resolve()
+ if err != nil {
+ t.Fatalf("resolve paths: %v", err)
+ }
+ // A regular file blocks creation of the required licenses directory.
+ if err := os.WriteFile(filepath.Join(home, "licenses"), []byte("blocked"), 0o600); err != nil {
+ t.Fatalf("block license directory: %v", err)
+ }
+ m := &Manifest{DefaultModel: "test-model"}
+ err = tc.run(p, m)
+ if err == nil || !strings.Contains(err.Error(), "install licenses") {
+ t.Fatalf("error = %v, want install licenses failure", err)
+ }
+ })
+ }
+}
+
+// A second run must overwrite cleanly (bootstrap runs on every launch).
+func TestInstallLicenses_Idempotent(t *testing.T) {
+ home := t.TempDir()
+ p := &Paths{Home: home}
+ if err := installLicenses(p); err != nil {
+ t.Fatalf("first install: %v", err)
+ }
+ // Corrupt one file; the rerun must restore the embedded text.
+ target := filepath.Join(home, "licenses", "LICENSE")
+ if err := os.WriteFile(target, []byte("tampered"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := installLicenses(p); err != nil {
+ t.Fatalf("second install: %v", err)
+ }
+ got, err := os.ReadFile(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want, _ := runed.LicenseFS.ReadFile("LICENSE")
+ if string(got) != string(want) {
+ t.Error("rerun did not restore the embedded LICENSE text")
+ }
+}
diff --git a/internal/ipc/resolve_test.go b/internal/ipc/resolve_test.go
new file mode 100644
index 0000000..4f49c2c
--- /dev/null
+++ b/internal/ipc/resolve_test.go
@@ -0,0 +1,124 @@
+//go:build !windows
+
+package ipc
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestResolveSocketPath_ShortPathUnchanged(t *testing.T) {
+ p := "/tmp/runed-test/embedding.sock"
+ if got := ResolveSocketPath(p); got != p {
+ t.Fatalf("short path changed: got %q want %q", got, p)
+ }
+}
+
+func TestResolveSocketPath_LongPathAliased(t *testing.T) {
+ long := "/" + strings.Repeat("d", 120) + "/embedding.sock"
+ if len(long) < sunPathLimit {
+ t.Fatalf("test setup: path not long enough (%d bytes)", len(long))
+ }
+ got := ResolveSocketPath(long)
+ wantDir := filepath.Join("/tmp", fmt.Sprintf("runed-%d", os.Getuid()))
+ if filepath.Dir(got) != wantDir || !strings.HasPrefix(filepath.Base(got), "runed-") || !strings.HasSuffix(got, ".sock") {
+ t.Fatalf("alias has wrong shape: %q", got)
+ }
+ if len(got) >= sunPathLimit {
+ t.Fatalf("alias too long (%d bytes): %q", len(got), got)
+ }
+ // Deterministic: the same canonical input yields the same alias — this is
+ // what lets the daemon and the client converge without a handshake.
+ if again := ResolveSocketPath(long); again != got {
+ t.Fatalf("not deterministic: %q vs %q", got, again)
+ }
+ // Distinct canonical inputs (different RUNED_HOMEs/users) must not collide.
+ other := "/" + strings.Repeat("e", 120) + "/embedding.sock"
+ if ResolveSocketPath(other) == got {
+ t.Fatalf("distinct canonical paths mapped to the same alias %q", got)
+ }
+ // Idempotent: resolving an already-resolved alias returns it unchanged, so
+ // defensive double-resolution at bind/dial sites is harmless.
+ if re := ResolveSocketPath(got); re != got {
+ t.Fatalf("alias not idempotent: %q -> %q", got, re)
+ }
+}
+
+func TestResolveSocketPath_RelativeAndAbsoluteSpellingsConverge(t *testing.T) {
+ relative := filepath.Join(strings.Repeat("d", 60), strings.Repeat("e", 60), "embedding.sock")
+ absolute, err := filepath.Abs(relative)
+ if err != nil {
+ t.Fatalf("abs: %v", err)
+ }
+ if ResolveSocketPath(relative) != ResolveSocketPath(absolute) {
+ t.Fatalf("relative and absolute spellings diverged: %q vs %q",
+ ResolveSocketPath(relative), ResolveSocketPath(absolute))
+ }
+}
+
+func TestResolveSocketPath_UncleanedSpellingsConverge(t *testing.T) {
+ // The client may get an uncleaned path via WithSocketPath while the
+ // spawned daemon recomputes it with filepath.Join (which cleans); both
+ // spellings must hash to the same alias.
+ clean := "/" + strings.Repeat("d", 120) + "/embedding.sock"
+ unclean := "/" + strings.Repeat("d", 120) + "//./embedding.sock"
+ if ResolveSocketPath(clean) != ResolveSocketPath(unclean) {
+ t.Fatalf("cleaned and uncleaned spellings diverged: %q vs %q",
+ ResolveSocketPath(clean), ResolveSocketPath(unclean))
+ }
+}
+
+// TestListen_LongPathBindsAndDials verifies that a canonical socket path over
+// the sun_path limit yields a listener reachable through the deterministic
+// short alias.
+func TestListen_LongPathBindsAndDials(t *testing.T) {
+ base := shortTempDir(t)
+ deep := filepath.Join(base, strings.Repeat("d", 60), strings.Repeat("e", 60))
+ if err := os.MkdirAll(deep, 0o700); err != nil {
+ t.Skipf("cannot create deep dir in this sandbox: %v", err)
+ }
+ canonical := filepath.Join(deep, "embedding.sock")
+ if len(canonical) < sunPathLimit {
+ t.Fatalf("test setup: canonical path not long enough (%d bytes)", len(canonical))
+ }
+
+ // Daemon side: binding the canonical path used to fail with
+ // "bind: invalid argument"; Listen now binds the alias.
+ lis, err := Listen(canonical)
+ if err != nil {
+ t.Fatalf("Listen on long canonical path: %v", err)
+ }
+ defer lis.Close()
+
+ resolved := ResolveSocketPath(canonical)
+ if resolved == canonical {
+ t.Fatal("long canonical path was not aliased")
+ }
+ // Client side: dialing the independently resolved alias reaches the
+ // daemon's listener.
+ conn, err := net.Dial("unix", resolved)
+ if err != nil {
+ t.Fatalf("dial resolved alias %s: %v", resolved, err)
+ }
+ conn.Close()
+
+ // The /tmp alias keeps the socket owner-only.
+ info, err := os.Stat(resolved)
+ if err != nil {
+ t.Fatalf("stat alias: %v", err)
+ }
+ if info.Mode()&0o777 != 0o700 {
+ t.Fatalf("want mode 0700 on alias, got %o", info.Mode()&0o777)
+ }
+ parent, err := os.Stat(filepath.Dir(resolved))
+ if err != nil {
+ t.Fatalf("stat alias parent: %v", err)
+ }
+ if parent.Mode().Perm() != 0o700 {
+ t.Fatalf("want alias parent mode 0700, got %o", parent.Mode().Perm())
+ }
+}
diff --git a/internal/ipc/resolve_unix.go b/internal/ipc/resolve_unix.go
new file mode 100644
index 0000000..943e3fd
--- /dev/null
+++ b/internal/ipc/resolve_unix.go
@@ -0,0 +1,79 @@
+//go:build !windows
+
+package ipc
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "syscall"
+)
+
+// sunPathLimit is the conservative cross-platform capacity of
+// sockaddr_un.sun_path: 104 bytes on macOS, 108 on Linux. A path at or over
+// the macOS limit fails bind/connect with EINVAL on at least one supported
+// platform, so we treat >= 104 as unusable everywhere.
+const sunPathLimit = 104
+
+// socketAliasDir is deliberately short and private to the current uid. Putting
+// the socket directly in world-writable /tmp would let another local user bind
+// the predictable alias first and impersonate or deny the daemon.
+func socketAliasDir() string {
+ return filepath.Join("/tmp", fmt.Sprintf("runed-%d", os.Getuid()))
+}
+
+// ResolveSocketPath returns a bindable/dialable path for the canonical socket
+// path. Short absolute paths are returned unchanged; a path at/over the
+// sun_path limit is mapped to a deterministic alias under a short, per-user
+// runtime directory so daemon and client converge without a handshake.
+//
+// Inputs are made absolute before both the length check and hash. In particular,
+// spawn may receive a relative WithSocketPath while runed resolves RUNED_HOME to
+// an absolute path; hashing their original spellings would make the two sides
+// choose different aliases. The alias is idempotent because it is already an
+// absolute path shorter than sunPathLimit.
+func ResolveSocketPath(canonical string) string {
+ cleaned := filepath.Clean(canonical)
+ if absolute, err := filepath.Abs(cleaned); err == nil {
+ cleaned = absolute
+ }
+ if len(cleaned) < sunPathLimit {
+ return cleaned
+ }
+ sum := sha256.Sum256([]byte(cleaned))
+ return filepath.Join(socketAliasDir(), "runed-"+hex.EncodeToString(sum[:16])+".sock")
+}
+
+// ensureSocketParent creates the socket's parent and, for a short-path alias,
+// verifies that the shared-runtime entry is a real directory owned by this uid
+// with no group/other access. An attacker-created symlink or foreign-owned
+// directory fails closed instead of moving the unauthenticated local gRPC
+// endpoint into an untrusted namespace.
+func ensureSocketParent(path string) error {
+ dir := filepath.Dir(path)
+ if dir != socketAliasDir() {
+ return os.MkdirAll(dir, 0o700)
+ }
+ if err := os.Mkdir(dir, 0o700); err != nil && !os.IsExist(err) {
+ return err
+ }
+ info, err := os.Lstat(dir)
+ if err != nil {
+ return err
+ }
+ if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
+ return fmt.Errorf("socket alias parent %s is not a real directory", dir)
+ }
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok || uint64(stat.Uid) != uint64(os.Getuid()) {
+ return fmt.Errorf("socket alias parent %s is not owned by the current user", dir)
+ }
+ if info.Mode().Perm() != 0o700 {
+ if err := os.Chmod(dir, 0o700); err != nil {
+ return fmt.Errorf("secure socket alias parent %s: %w", dir, err)
+ }
+ }
+ return nil
+}
diff --git a/internal/ipc/resolve_windows.go b/internal/ipc/resolve_windows.go
new file mode 100644
index 0000000..09fc0c5
--- /dev/null
+++ b/internal/ipc/resolve_windows.go
@@ -0,0 +1,9 @@
+//go:build windows
+
+package ipc
+
+import "path/filepath"
+
+// Windows support uses named pipes rather than sockaddr_un, so the Unix
+// short-path aliasing rule does not apply.
+func ResolveSocketPath(path string) string { return filepath.Clean(path) }
diff --git a/internal/ipc/uds_unix.go b/internal/ipc/uds_unix.go
index 699ca22..552ffb7 100644
--- a/internal/ipc/uds_unix.go
+++ b/internal/ipc/uds_unix.go
@@ -10,7 +10,6 @@ import (
"fmt"
"net"
"os"
- "path/filepath"
"sync"
"syscall"
)
@@ -28,8 +27,14 @@ import (
// which can delete a healthy daemon's socket after $RUNED_HOME is recreated
// out from under a running daemon. StillOwned exposes the same identity check
// so the daemon can self-evict when its socket is taken over.
+//
+// A path at/over the sun_path limit is transparently remapped via
+// ResolveSocketPath before binding; binding the canonical path
+// would fail with a cryptic "bind: invalid argument". Clients resolve the
+// same canonical path before dialing, so both sides meet at the alias.
func Listen(path string) (Listener, error) {
- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ path = ResolveSocketPath(path)
+ if err := ensureSocketParent(path); err != nil {
return nil, fmt.Errorf("mkdir parent: %w", err)
}
diff --git a/internal/route/route.go b/internal/route/route.go
new file mode 100644
index 0000000..7a2ac47
--- /dev/null
+++ b/internal/route/route.go
@@ -0,0 +1,158 @@
+// Package route holds the daemon's IVF centroid set and the plaintext
+// cluster assignment used to route inserts. runed never dials the index
+// engine: the set arrives via the SetCentroids RPC (relayed
+// runespace → console → rune-mcp → runed) and is cached on disk so a restart
+// does not need a re-push. Assignment must agree byte-for-byte with the
+// engine's insert routing: max inner product over l2-normalized vectors,
+// ties to the lowest id (mirrors runespace-go-sdk clustering).
+package route
+
+import (
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/gob"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+)
+
+// CentroidSet is an immutable snapshot of the engine's IVF centroid set.
+// Vectors[i] is cluster i's centroid (id order); Version is the set's
+// content hash and travels with every routed assignment so the engine can
+// reject routing done against a stale set. Preset is the evi preset the set
+// was trained for — a Version-hash ingredient, so carrying it is what makes
+// local recomputation (VerifyVersion) possible.
+type CentroidSet struct {
+ Version string
+ Dim int
+ Preset string
+ Vectors [][]float32
+}
+
+// versionPrefix mirrors the engine's content-hash tag.
+const versionPrefix = "sha256:"
+
+// ComputeVersion recreates the engine's content hash over the set: sha256 of
+// dim, nlist, preset, then every centroid's float32 bits in id order
+// (little-endian). MUST stay byte-identical to runespace's computeVersion
+// (runespace/internal/cluster/centroid.go) — that equality is what lets runed
+// verify a relayed or cached set against the version the engine minted.
+func ComputeVersion(s *CentroidSet) string {
+ h := sha256.New()
+ var u32 [4]byte
+ binary.LittleEndian.PutUint32(u32[:], uint32(s.Dim))
+ h.Write(u32[:])
+ binary.LittleEndian.PutUint32(u32[:], uint32(len(s.Vectors)))
+ h.Write(u32[:])
+ h.Write([]byte(s.Preset))
+ h.Write([]byte{0}) // length-delimit the preset string
+ for _, vec := range s.Vectors {
+ for _, f := range vec {
+ binary.LittleEndian.PutUint32(u32[:], math.Float32bits(f))
+ h.Write(u32[:])
+ }
+ }
+ return versionPrefix + hex.EncodeToString(h.Sum(nil))
+}
+
+// VerifyVersion recomputes the content hash and compares it to the carried
+// Version — the integrity gate at the set's two entry points (SetCentroids
+// receipt, disk-cache load). An empty Preset means the sender predates the
+// preset relay: the hash cannot be recreated without that ingredient, so
+// verification is skipped (legacy behavior, trust the tag).
+func (s *CentroidSet) VerifyVersion() error {
+ if s.Preset == "" {
+ return nil
+ }
+ if got := ComputeVersion(s); got != s.Version {
+ return fmt.Errorf("route: centroid content does not match its version tag (tag %s, content hashes to %s) — corrupt relay or cache", s.Version, got)
+ }
+ return nil
+}
+
+// Validate reports whether the set is internally consistent and matches the
+// embedding dimension the daemon serves.
+func (s *CentroidSet) Validate(wantDim int) error {
+ if s == nil || s.Version == "" || len(s.Vectors) == 0 {
+ return errors.New("route: centroid set is empty")
+ }
+ if s.Dim != wantDim {
+ return fmt.Errorf("route: centroid dim %d does not match embedding dim %d", s.Dim, wantDim)
+ }
+ for i, v := range s.Vectors {
+ if len(v) != s.Dim {
+ return fmt.Errorf("route: centroid %d has dim %d, want %d", i, len(v), s.Dim)
+ }
+ }
+ return nil
+}
+
+// Assign returns the cluster id whose centroid has the highest inner product
+// with vec. vec must be l2-normalized — runed embeddings already are
+// (llama.cpp last-pooling normalizes unconditionally). Ties resolve to the
+// lowest id, matching the SDK/engine metric exactly.
+func (s *CentroidSet) Assign(vec []float32) uint32 {
+ bestID := uint32(0)
+ best := math.Inf(-1)
+ for i, c := range s.Vectors {
+ var dot float64
+ for j := 0; j < len(c) && j < len(vec); j++ {
+ dot += float64(c[j]) * float64(vec[j])
+ }
+ if dot > best {
+ best, bestID = dot, uint32(i)
+ }
+ }
+ return bestID
+}
+
+const cacheFile = "centroids.gob"
+
+// Persist writes the set atomically to dir (temp + rename), so a torn write
+// never produces a half-cache. Best-effort semantics are the caller's call.
+func (s *CentroidSet) Persist(dir string) error {
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return fmt.Errorf("route: cache dir: %w", err)
+ }
+ tmp, err := os.CreateTemp(dir, cacheFile+".tmp-*")
+ if err != nil {
+ return fmt.Errorf("route: cache temp: %w", err)
+ }
+ defer os.Remove(tmp.Name())
+ if err := gob.NewEncoder(tmp).Encode(s); err != nil {
+ tmp.Close()
+ return fmt.Errorf("route: cache encode: %w", err)
+ }
+ if err := tmp.Close(); err != nil {
+ return fmt.Errorf("route: cache close: %w", err)
+ }
+ if err := os.Rename(tmp.Name(), filepath.Join(dir, cacheFile)); err != nil {
+ return fmt.Errorf("route: cache rename: %w", err)
+ }
+ return nil
+}
+
+// Load reads a previously persisted set. A missing cache returns
+// (nil, fs.ErrNotExist-wrapped error); a corrupt cache returns an error and
+// the caller should discard it and wait for the next SetCentroids push.
+// Corruption covers both shapes: a broken gob (decode error) and — the
+// insidious case — intact gob framing around flipped content bytes, which
+// only the §9.2 C5 hash recomputation can see.
+func Load(dir string) (*CentroidSet, error) {
+ f, err := os.Open(filepath.Join(dir, cacheFile))
+ if err != nil {
+ return nil, fmt.Errorf("route: cache open: %w", err)
+ }
+ defer f.Close()
+ var s CentroidSet
+ if err := gob.NewDecoder(f).Decode(&s); err != nil {
+ return nil, fmt.Errorf("route: cache decode: %w", err)
+ }
+ if err := s.VerifyVersion(); err != nil {
+ return nil, fmt.Errorf("route: cache verify: %w", err)
+ }
+ return &s, nil
+}
diff --git a/internal/route/route_test.go b/internal/route/route_test.go
new file mode 100644
index 0000000..b53d38c
--- /dev/null
+++ b/internal/route/route_test.go
@@ -0,0 +1,90 @@
+package route
+
+import (
+ "testing"
+)
+
+func testSet() *CentroidSet {
+ return &CentroidSet{
+ Version: "v-abc",
+ Dim: 3,
+ Vectors: [][]float32{{1, 0, 0}, {0, 1, 0}, {0, 0, 1}},
+ }
+}
+
+// TestValidate covers Validate's rejection cases — nil receiver, empty
+// vectors, dim mismatch against the served dimension, and a ragged row whose
+// length differs from Dim — plus the happy path.
+func TestValidate(t *testing.T) {
+ if err := testSet().Validate(3); err != nil {
+ t.Fatalf("valid set rejected: %v", err)
+ }
+ if err := testSet().Validate(4); err == nil {
+ t.Fatal("dim mismatch accepted")
+ }
+ var nilSet *CentroidSet
+ if err := nilSet.Validate(3); err == nil {
+ t.Fatal("nil set accepted")
+ }
+ empty := &CentroidSet{Version: "v", Dim: 3}
+ if err := empty.Validate(3); err == nil {
+ t.Fatal("empty vectors accepted")
+ }
+ ragged := testSet()
+ ragged.Vectors[1] = []float32{1}
+ if err := ragged.Validate(3); err == nil {
+ t.Fatal("ragged centroid accepted")
+ }
+}
+
+// TestAssign checks that Assign picks the centroid with the highest inner
+// product against the query, and that a tie resolves to the lowest cluster id
+// (matching the SDK/engine metric).
+func TestAssign(t *testing.T) {
+ s := testSet()
+ cases := []struct {
+ vec []float32
+ want uint32
+ }{
+ {[]float32{0.9, 0.1, 0}, 0},
+ {[]float32{0.1, 0.9, 0}, 1},
+ {[]float32{0, 0.2, 0.8}, 2},
+ // Tie between 0 and 1 resolves to the lowest id.
+ {[]float32{0.5, 0.5, 0}, 0},
+ }
+ for _, tc := range cases {
+ if got := s.Assign(tc.vec); got != tc.want {
+ t.Fatalf("Assign(%v) = %d, want %d", tc.vec, got, tc.want)
+ }
+ }
+}
+
+// TestPersistLoadRoundtrip persists a set and reloads it, asserting the
+// version/dim/shape survive the gob roundtrip and that the reloaded set still
+// routes correctly. (Preset is empty here, so Load's hash verification is
+// skipped — the content-hash path is covered by verify_test.go.)
+func TestPersistLoadRoundtrip(t *testing.T) {
+ dir := t.TempDir()
+ orig := testSet()
+ if err := orig.Persist(dir); err != nil {
+ t.Fatalf("persist: %v", err)
+ }
+ got, err := Load(dir)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if got.Version != orig.Version || got.Dim != orig.Dim || len(got.Vectors) != len(orig.Vectors) {
+ t.Fatalf("roundtrip mismatch: %+v vs %+v", got, orig)
+ }
+ if got.Assign([]float32{0, 1, 0}) != 1 {
+ t.Fatal("loaded set assigns wrong cluster")
+ }
+}
+
+// TestLoadMissing asserts Load errors (rather than returning an empty set)
+// when no cache file exists in the directory.
+func TestLoadMissing(t *testing.T) {
+ if _, err := Load(t.TempDir()); err == nil {
+ t.Fatal("missing cache should error")
+ }
+}
diff --git a/internal/route/verify_test.go b/internal/route/verify_test.go
new file mode 100644
index 0000000..c880ff9
--- /dev/null
+++ b/internal/route/verify_test.go
@@ -0,0 +1,126 @@
+package route
+
+import (
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/hex"
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func verifiedSet() *CentroidSet {
+ s := &CentroidSet{
+ Dim: 2,
+ Preset: "IP1",
+ Vectors: [][]float32{
+ {1, 0},
+ {0.5, 0.5},
+ },
+ }
+ s.Version = ComputeVersion(s)
+ return s
+}
+
+// ComputeVersion must follow the engine's exact recipe: sha256 over dim(LE),
+// nlist(LE), preset, NUL, then float32 bits in id order (LE). This golden
+// test hand-assembles those bytes independently so a silent recipe drift in
+// either implementation breaks it.
+func TestComputeVersionRecipe(t *testing.T) {
+ s := verifiedSet()
+ var buf []byte
+ u32 := func(v uint32) { b := make([]byte, 4); binary.LittleEndian.PutUint32(b, v); buf = append(buf, b...) }
+ u32(2) // dim
+ u32(2) // nlist
+ buf = append(buf, []byte("IP1")...)
+ buf = append(buf, 0)
+ for _, vec := range s.Vectors {
+ for _, f := range vec {
+ u32(math.Float32bits(f))
+ }
+ }
+ // 골든 비교: 손으로 조립한 재료 바이트를 직접 해싱해, 구현이 dim·nlist 순서,
+ // 엔디안, preset NUL 구분자까지 바이트 단위로 동일한 레시피를 따르는지 고정한다.
+ sum := sha256.Sum256(buf)
+ want := "sha256:" + hex.EncodeToString(sum[:])
+ got := ComputeVersion(s)
+ if got != want {
+ t.Fatalf("recipe drift: ComputeVersion=%s, hand-assembled=%s", got, want)
+ }
+ if !strings.HasPrefix(got, "sha256:") {
+ t.Fatalf("version prefix missing: %s", got)
+ }
+ // 같은 재료로 두 번 계산하면 결정적이어야 하고, 재료 하나만 바뀌어도 달라져야 한다.
+ if ComputeVersion(s) != s.Version {
+ t.Fatal("ComputeVersion is not deterministic")
+ }
+ preset2 := *s
+ preset2.Preset = "IP2"
+ if ComputeVersion(&preset2) == s.Version {
+ t.Fatal("preset must be a hash ingredient")
+ }
+}
+
+// TestVerifyVersion exercises the integrity gate: a set whose content matches
+// its tag passes, content mutated under an intact tag is rejected, and a legacy
+// set carrying no Preset skips verification (the hash can't be recomputed).
+func TestVerifyVersion(t *testing.T) {
+ s := verifiedSet()
+ if err := s.VerifyVersion(); err != nil {
+ t.Fatalf("valid set rejected: %v", err)
+ }
+ // 내용만 바뀌고 꼬리표는 그대로 — 무증상 손상의 형태 그대로 재현
+ s.Vectors[0][0] = 0.9999
+ if err := s.VerifyVersion(); err == nil {
+ t.Fatal("content corruption with intact tag was not detected")
+ }
+ // 레거시(전달자에 preset 없음): 재계산 불가 → 스킵
+ legacy := &CentroidSet{Version: "sha256:whatever", Dim: 2, Vectors: [][]float32{{1, 0}}}
+ if err := legacy.VerifyVersion(); err != nil {
+ t.Fatalf("legacy (no preset) must skip verification: %v", err)
+ }
+}
+
+// C5 end-to-end at the file layer: a bit flipped inside the gob's float
+// payload decodes cleanly but must now fail Load via the hash recomputation.
+func TestLoadDetectsBitrot(t *testing.T) {
+ dir := t.TempDir()
+ s := verifiedSet()
+ if err := s.Persist(dir); err != nil {
+ t.Fatalf("persist: %v", err)
+ }
+ if _, err := Load(dir); err != nil {
+ t.Fatalf("clean roundtrip must verify: %v", err)
+ }
+
+ path := filepath.Join(dir, "centroids.gob")
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // float32(1.0)의 gob 표현 바이트를 찾아 한 비트 뒤집는다 — 구조는 유지되고
+ // 내용만 변하는 손상. (float 위치를 못 찾으면 마지막 바이트를 뒤집는다.)
+ flipped := false
+ for i := len(raw) - 1; i > 0; i-- {
+ cand := append(append([]byte{}, raw[:i]...), raw[i]^0x01)
+ cand = append(cand, raw[i+1:]...)
+ if err := os.WriteFile(path, cand, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ got, err := Load(dir)
+ if err != nil && strings.Contains(err.Error(), "cache verify") {
+ flipped = true // 원하는 경로: decode 성공 + verify가 잡음
+ break
+ }
+ if err != nil {
+ continue // decode 자체가 깨진 위치 — 다른 바이트로 재시도
+ }
+ _ = got
+ t.Fatalf("corrupted cache loaded without complaint (byte %d)", i)
+ }
+ if !flipped {
+ t.Skip("no byte position produced intact-gob corruption in this encoding")
+ }
+}
diff --git a/internal/server/centroids.go b/internal/server/centroids.go
new file mode 100644
index 0000000..5ae085a
--- /dev/null
+++ b/internal/server/centroids.go
@@ -0,0 +1,106 @@
+package server
+
+import (
+ "errors"
+ "io"
+ "log/slog"
+
+ runedv1 "github.com/CryptoLabInc/runed/gen/runed/v1"
+ "github.com/CryptoLabInc/runed/internal/route"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// SetCentroidCacheDir sets where a newly pushed centroid set is persisted.
+// Called once by cmd/runed before Serve; empty disables persistence.
+func (s *Server) SetCentroidCacheDir(dir string) { s.centroidCacheDir = dir }
+
+// LoadCentroids installs a set directly (boot-time cache restore). It
+// validates against the daemon's embedding dimension and is a no-op on nil.
+func (s *Server) LoadCentroids(cs *route.CentroidSet) error {
+ if cs == nil {
+ return errors.New("server: nil centroid set")
+ }
+ if err := cs.Validate(int(vectorDim)); err != nil {
+ return err
+ }
+ s.centroids.Store(cs)
+ return nil
+}
+
+// SetCentroids assembles the pushed stream (header, then id-ordered batches)
+// into a route.CentroidSet, installs it atomically, and persists it to the
+// daemon cache best-effort. The push replaces any previous set — the version
+// in the header is the engine's content hash, so pushing the same set twice
+// is a cheap idempotent overwrite.
+func (s *Server) SetCentroids(stream runedv1.RunedService_SetCentroidsServer) error {
+ var cs *route.CentroidSet
+ for {
+ req, err := stream.Recv()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return err
+ }
+ switch p := req.GetPayload().(type) {
+ case *runedv1.SetCentroidsRequest_Header:
+ if cs != nil {
+ return status.Error(codes.InvalidArgument, "duplicate header frame")
+ }
+ cs = &route.CentroidSet{
+ Version: p.Header.GetVersion(),
+ Dim: int(p.Header.GetDim()),
+ Preset: p.Header.GetPreset(),
+ }
+ // Header nlist is only a capacity hint for the preallocation; the
+ // authoritative count is len(cs.Vectors), built from the batches
+ // that actually arrive and reported back in the response. We don't
+ // enforce header nlist == received count — the content hash
+ // (VerifyVersion) already pins the exact vectors, so a mismatched
+ // header can't slip a wrong set through.
+ if n := p.Header.GetNlist(); n > 0 {
+ cs.Vectors = make([][]float32, 0, n)
+ }
+ case *runedv1.SetCentroidsRequest_Batch:
+ if cs == nil {
+ return status.Error(codes.InvalidArgument, "batch frame before header")
+ }
+ for _, c := range p.Batch.GetCentroids() {
+ cs.Vectors = append(cs.Vectors, c.GetVec())
+ }
+ }
+ }
+ if cs == nil {
+ return status.Error(codes.InvalidArgument, "empty stream: header frame required")
+ }
+ if err := cs.Validate(int(vectorDim)); err != nil {
+ return status.Error(codes.InvalidArgument, err.Error())
+ }
+ // §9.2 C2: the version tag is a content hash — recompute it over the
+ // received vectors and reject a push whose content does not match its
+ // claim (relay assembly corruption). Skipped when the sender carried no
+ // preset (legacy chain), since the hash cannot be recreated without it.
+ if err := cs.VerifyVersion(); err != nil {
+ return status.Error(codes.InvalidArgument, err.Error())
+ }
+
+ // Install memory + disk under installMu so a concurrent push commits both
+ // as one unit; without it the two stores can interleave and diverge.
+ s.installMu.Lock()
+ s.centroids.Store(cs)
+ if s.centroidCacheDir != "" {
+ if err := cs.Persist(s.centroidCacheDir); err != nil {
+ // Persistence is a restart optimization, not correctness — the set
+ // is live in memory; the next push repopulates the cache.
+ slog.Warn("centroids: persist failed (set is live in memory)", "err", err, "dir", s.centroidCacheDir)
+ }
+ }
+ s.installMu.Unlock()
+ slog.Info("centroids: set installed", "version", cs.Version, "nlist", len(cs.Vectors), "dim", cs.Dim)
+
+ return stream.SendAndClose(&runedv1.SetCentroidsResponse{
+ Version: cs.Version,
+ Nlist: uint32(len(cs.Vectors)),
+ })
+}
diff --git a/internal/server/centroids_test.go b/internal/server/centroids_test.go
new file mode 100644
index 0000000..1dc70bd
--- /dev/null
+++ b/internal/server/centroids_test.go
@@ -0,0 +1,174 @@
+package server
+
+import (
+ "io"
+ "testing"
+
+ runedv1 "github.com/CryptoLabInc/runed/gen/runed/v1"
+ "github.com/CryptoLabInc/runed/internal/backend"
+ "github.com/CryptoLabInc/runed/internal/route"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// fakeSetCentroidsStream feeds canned frames and captures the close response.
+type fakeSetCentroidsStream struct {
+ grpc.ServerStream // nil — SetCentroids only uses Recv/SendAndClose
+ frames []*runedv1.SetCentroidsRequest
+ resp *runedv1.SetCentroidsResponse
+}
+
+func (f *fakeSetCentroidsStream) Recv() (*runedv1.SetCentroidsRequest, error) {
+ if len(f.frames) == 0 {
+ return nil, io.EOF
+ }
+ fr := f.frames[0]
+ f.frames = f.frames[1:]
+ return fr, nil
+}
+
+func (f *fakeSetCentroidsStream) SendAndClose(r *runedv1.SetCentroidsResponse) error {
+ f.resp = r
+ return nil
+}
+
+func header(version string, dim, nlist uint32) *runedv1.SetCentroidsRequest {
+ return &runedv1.SetCentroidsRequest{Payload: &runedv1.SetCentroidsRequest_Header{
+ Header: &runedv1.CentroidSetHeader{Version: version, Dim: dim, Nlist: nlist},
+ }}
+}
+
+// headerPreset is header with a non-empty Preset, so the receiver recomputes
+// and enforces the content hash instead of skipping it as a legacy push.
+func headerPreset(version, preset string, dim, nlist uint32) *runedv1.SetCentroidsRequest {
+ return &runedv1.SetCentroidsRequest{Payload: &runedv1.SetCentroidsRequest_Header{
+ Header: &runedv1.CentroidSetHeader{Version: version, Preset: preset, Dim: dim, Nlist: nlist},
+ }}
+}
+
+func batch(vecs ...[]float32) *runedv1.SetCentroidsRequest {
+ cs := make([]*runedv1.Centroid, len(vecs))
+ for i, v := range vecs {
+ cs[i] = &runedv1.Centroid{Id: uint32(i), Vec: v}
+ }
+ return &runedv1.SetCentroidsRequest{Payload: &runedv1.SetCentroidsRequest_Batch{
+ Batch: &runedv1.CentroidBatch{Centroids: cs},
+ }}
+}
+
+// dimVec returns a vectorDim-length vector with a single 1 at hot.
+func dimVec(hot int) []float32 {
+ v := make([]float32, vectorDim)
+ v[hot] = 1
+ return v
+}
+
+// TestSetCentroidsInstallsAndPersists drives a well-formed header+batch stream
+// and asserts the set is installed in memory (centroids.Load), the close
+// response echoes version and nlist, and a reloadable copy lands in the cache.
+func TestSetCentroidsInstallsAndPersists(t *testing.T) {
+ s := New("test")
+ s.SetCentroidCacheDir(t.TempDir())
+
+ st := &fakeSetCentroidsStream{frames: []*runedv1.SetCentroidsRequest{
+ header("v1", uint32(vectorDim), 2),
+ batch(dimVec(0), dimVec(1)),
+ }}
+ if err := s.SetCentroids(st); err != nil {
+ t.Fatalf("SetCentroids: %v", err)
+ }
+ if st.resp == nil || st.resp.Version != "v1" || st.resp.Nlist != 2 {
+ t.Fatalf("bad response: %+v", st.resp)
+ }
+ cs := s.centroids.Load()
+ if cs == nil || cs.Version != "v1" {
+ t.Fatal("set not installed")
+ }
+ // Persisted copy must be loadable and identical in shape.
+ loaded, err := route.Load(s.centroidCacheDir)
+ if err != nil {
+ t.Fatalf("cache not persisted: %v", err)
+ }
+ if loaded.Version != "v1" || len(loaded.Vectors) != 2 {
+ t.Fatalf("cache mismatch: %+v", loaded)
+ }
+}
+
+// TestSetCentroidsRejectsBadStreams asserts each malformed stream is rejected
+// with InvalidArgument and installs no set. Cases: empty stream, batch before
+// header, duplicate header, dim mismatch against the served dimension, empty
+// version, and a content-hash mismatch (Preset present so the hash is enforced,
+// version tag deliberately wrong) — the last exercises the SetCentroids
+// VerifyVersion gate that the no-preset cases skip.
+func TestSetCentroidsRejectsBadStreams(t *testing.T) {
+ cases := []struct {
+ name string
+ frames []*runedv1.SetCentroidsRequest
+ }{
+ {"empty stream", nil},
+ {"batch before header", []*runedv1.SetCentroidsRequest{batch(dimVec(0))}},
+ {"duplicate header", []*runedv1.SetCentroidsRequest{
+ header("v1", uint32(vectorDim), 1), header("v2", uint32(vectorDim), 1),
+ }},
+ {"dim mismatch", []*runedv1.SetCentroidsRequest{
+ header("v1", 8, 1), batch([]float32{1, 0, 0, 0, 0, 0, 0, 0}),
+ }},
+ {"empty version", []*runedv1.SetCentroidsRequest{
+ header("", uint32(vectorDim), 1), batch(dimVec(0)),
+ }},
+ {"content hash mismatch", []*runedv1.SetCentroidsRequest{
+ headerPreset("sha256:deadbeef", "IP1", uint32(vectorDim), 1), batch(dimVec(0)),
+ }},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ s := New("test")
+ err := s.SetCentroids(&fakeSetCentroidsStream{frames: tc.frames})
+ if status.Code(err) != codes.InvalidArgument {
+ t.Fatalf("want InvalidArgument, got %v", err)
+ }
+ if s.centroids.Load() != nil {
+ t.Fatal("bad stream must not install a set")
+ }
+ })
+ }
+}
+
+// TestEmbedWithRouteRequiresCentroids checks the routing precondition in
+// isolation: with a backend wired (so the bootstrap gate passes) but no
+// centroid set, an Embed with_route fails FAILED_PRECONDITION carrying the
+// NO_CENTROID_SET reason. The no-centroid check runs before the forward pass,
+// so the unstarted backend is never invoked. Asserting the reason (not just the
+// code) is what pins this to the routing branch rather than the bootstrap one.
+func TestEmbedWithRouteRequiresCentroids(t *testing.T) {
+ s := New("test")
+ s.SetBackend(backend.NewLlamaBackend(backend.Config{}), "test")
+
+ _, err := s.Embed(t.Context(), &runedv1.EmbedRequest{Text: "hi", WithRoute: true})
+ if status.Code(err) != codes.FailedPrecondition {
+ t.Fatalf("want FailedPrecondition, got %v", err)
+ }
+ if got := reasonOf(err); got != ReasonNoCentroidSet {
+ t.Fatalf("reason = %q, want %q", got, ReasonNoCentroidSet)
+ }
+}
+
+// TestLoadCentroidsValidates checks the boot-time restore path: LoadCentroids
+// rejects a set whose dim differs from the served dimension and accepts a
+// matching one, after which Info exposes the loaded centroid version.
+func TestLoadCentroidsValidates(t *testing.T) {
+ s := New("test")
+ bad := &route.CentroidSet{Version: "v", Dim: 8, Vectors: [][]float32{make([]float32, 8)}}
+ if err := s.LoadCentroids(bad); err == nil {
+ t.Fatal("dim-mismatched cache accepted")
+ }
+ good := &route.CentroidSet{Version: "v", Dim: int(vectorDim), Vectors: [][]float32{dimVec(3)}}
+ if err := s.LoadCentroids(good); err != nil {
+ t.Fatalf("valid cache rejected: %v", err)
+ }
+ info, _ := s.Info(t.Context(), &runedv1.InfoRequest{})
+ if info.CentroidSetVersion != "v" {
+ t.Fatalf("Info should expose centroid version, got %q", info.CentroidSetVersion)
+ }
+}
diff --git a/internal/server/reason_test.go b/internal/server/reason_test.go
new file mode 100644
index 0000000..1ceaac9
--- /dev/null
+++ b/internal/server/reason_test.go
@@ -0,0 +1,58 @@
+package server
+
+import (
+ "context"
+ "testing"
+
+ runedv1 "github.com/CryptoLabInc/runed/gen/runed/v1"
+ "google.golang.org/genproto/googleapis/rpc/errdetails"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// reasonOf extracts the ErrorInfo reason from a status error ("" if none).
+func reasonOf(err error) string {
+ st, ok := status.FromError(err)
+ if !ok {
+ return ""
+ }
+ for _, d := range st.Details() {
+ if info, ok := d.(*errdetails.ErrorInfo); ok {
+ return info.GetReason()
+ }
+ }
+ return ""
+}
+
+// The two precondition conditions must be machine-distinguishable by reason
+// while keeping the FAILED_PRECONDITION code (transport-level non-retry stays;
+// see TestServer_EmbedFailsBeforeBackendSet's rationale).
+func TestPreconditionErrCarriesReason(t *testing.T) {
+ for _, tc := range []struct{ reason, msg string }{
+ {ReasonBootstrapping, "daemon is bootstrapping; embed not yet available"},
+ {ReasonNoCentroidSet, "no centroid set loaded"},
+ } {
+ err := preconditionErr(tc.reason, tc.msg)
+ if got := status.Code(err); got != codes.FailedPrecondition {
+ t.Fatalf("%s: code = %v, want FailedPrecondition", tc.reason, got)
+ }
+ if got := reasonOf(err); got != tc.reason {
+ t.Fatalf("reason = %q, want %q", got, tc.reason)
+ }
+ }
+}
+
+// The bootstrap window (backend not yet wired) must surface the BOOTSTRAPPING
+// reason over the wire — this is what lets rune-mcp wait-and-retry instead of
+// misfiring the centroid resync.
+func TestEmbedBootstrapReasonOnWire(t *testing.T) {
+ s := New("test")
+ _, err := s.Embed(context.Background(), &runedv1.EmbedRequest{Text: "x"})
+ if got := reasonOf(err); got != ReasonBootstrapping {
+ t.Fatalf("Embed reason = %q, want %q", got, ReasonBootstrapping)
+ }
+ _, err = s.EmbedBatch(context.Background(), &runedv1.EmbedBatchRequest{Texts: []string{"x"}})
+ if got := reasonOf(err); got != ReasonBootstrapping {
+ t.Fatalf("EmbedBatch reason = %q, want %q", got, ReasonBootstrapping)
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index c056bbb..8a9a7e2 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -14,11 +14,39 @@ import (
runedv1 "github.com/CryptoLabInc/runed/gen/runed/v1"
"github.com/CryptoLabInc/runed/internal/backend"
+ "github.com/CryptoLabInc/runed/internal/route"
+ "google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
+// Machine-readable reasons attached (as an ErrorInfo detail, same pattern as
+// runespace's grpcerr) to the two FAILED_PRECONDITION conditions Embed can
+// return. The status code stays FAILED_PRECONDITION for both — deliberately
+// not retryable at the transport layer (see TestServer_EmbedFailsBeforeBackendSet)
+// — but the reasons need opposite application-level handling, so clients
+// (rune-mcp) branch on the reason instead of parsing the human message:
+//
+// ReasonBootstrapping — model still loading; wait and retry the same call.
+// ReasonNoCentroidSet — push a set via SetCentroids, then retry (§9.2 C4).
+const (
+ errDomain = "runed.v1"
+ ReasonBootstrapping = "BOOTSTRAPPING"
+ ReasonNoCentroidSet = "NO_CENTROID_SET"
+)
+
+// preconditionErr builds a FAILED_PRECONDITION status tagged with reason.
+// Detail attachment is best-effort: on failure the bare status still carries
+// the right code and message.
+func preconditionErr(reason, msg string) error {
+ st := status.New(codes.FailedPrecondition, msg)
+ if d, err := st.WithDetails(&errdetails.ErrorInfo{Reason: reason, Domain: errDomain}); err == nil {
+ return d.Err()
+ }
+ return st.Err()
+}
+
// Plan A constants for the Info RPC. Qwen3-Embedding-0.6B fixes these at
// model-load time; future revisions will source them from config or the
// model file. Hardcoded here because Plan A ships with exactly one model.
@@ -59,6 +87,18 @@ type Server struct {
// nil before any SetBootstrapStatus call.
bootstrapStatus atomic.Pointer[bootstrapState]
+ // centroids is the IVF set pushed via SetCentroids (or loaded from the
+ // disk cache at boot); nil until then, making with_route requests fail
+ // with FAILED_PRECONDITION. centroidCacheDir is where a newly pushed set
+ // is persisted (empty = no persistence, used by tests).
+ centroids atomic.Pointer[route.CentroidSet]
+ centroidCacheDir string
+ // installMu makes a SetCentroids install atomic across the in-memory
+ // store and the disk persist, so two concurrent pushes can't leave memory
+ // holding one set while the cache on disk holds another (which a restart
+ // would then silently serve). It guards only the tail, not the Recv loop.
+ installMu sync.Mutex
+
// Embed + EmbedBatch counter. Surfaced via HealthResponse.total_requests.
requests atomic.Int64
@@ -188,7 +228,13 @@ const embedMaxAttempts = 2
func (s *Server) Embed(ctx context.Context, req *runedv1.EmbedRequest) (*runedv1.EmbedResponse, error) {
b := s.backend.Load()
if b == nil {
- return nil, status.Error(codes.FailedPrecondition, "daemon is bootstrapping; embed not yet available")
+ return nil, preconditionErr(ReasonBootstrapping, "daemon is bootstrapping; embed not yet available")
+ }
+ // Fail before the forward pass: routing without a centroid set can never
+ // succeed, and the caller (rune-mcp) reacts by pushing SetCentroids first.
+ cs := s.centroids.Load()
+ if req.WithRoute && cs == nil {
+ return nil, preconditionErr(ReasonNoCentroidSet, "no centroid set loaded; push one via SetCentroids before requesting with_route")
}
s.requests.Add(1)
for attempt := 0; attempt < embedMaxAttempts; attempt++ {
@@ -197,7 +243,12 @@ func (s *Server) Embed(ctx context.Context, req *runedv1.EmbedRequest) (*runedv1
}
vec, err := b.Embed(ctx, req.Text, true)
if err == nil {
- return &runedv1.EmbedResponse{Vector: vec}, nil
+ resp := &runedv1.EmbedResponse{Vector: vec}
+ if req.WithRoute {
+ resp.ClusterId = cs.Assign(vec)
+ resp.CentroidSetVersion = cs.Version
+ }
+ return resp, nil
}
if !errors.Is(err, backend.ErrNotStarted) {
return nil, err
@@ -211,7 +262,11 @@ func (s *Server) Embed(ctx context.Context, req *runedv1.EmbedRequest) (*runedv1
func (s *Server) EmbedBatch(ctx context.Context, req *runedv1.EmbedBatchRequest) (*runedv1.EmbedBatchResponse, error) {
b := s.backend.Load()
if b == nil {
- return nil, status.Error(codes.FailedPrecondition, "daemon is bootstrapping; embed not yet available")
+ return nil, preconditionErr(ReasonBootstrapping, "daemon is bootstrapping; embed not yet available")
+ }
+ cs := s.centroids.Load()
+ if req.WithRoute && cs == nil {
+ return nil, preconditionErr(ReasonNoCentroidSet, "no centroid set loaded; push one via SetCentroids before requesting with_route")
}
s.requests.Add(1)
for attempt := 0; attempt < embedMaxAttempts; attempt++ {
@@ -225,6 +280,10 @@ func (s *Server) EmbedBatch(ctx context.Context, req *runedv1.EmbedBatchRequest)
}
for i, v := range vecs {
out.Embeddings[i] = &runedv1.EmbedResponse{Vector: v}
+ if req.WithRoute {
+ out.Embeddings[i].ClusterId = cs.Assign(v)
+ out.Embeddings[i].CentroidSetVersion = cs.Version
+ }
}
return out, nil
}
@@ -240,13 +299,17 @@ func (s *Server) EmbedBatch(ctx context.Context, req *runedv1.EmbedBatchRequest)
// Health reports STATUS_OK.
func (s *Server) Info(ctx context.Context, _ *runedv1.InfoRequest) (*runedv1.InfoResponse, error) {
mid, _ := s.modelIdentity.Load().(string)
- return &runedv1.InfoResponse{
+ info := &runedv1.InfoResponse{
DaemonVersion: s.version,
ModelIdentity: mid,
VectorDim: vectorDim,
MaxTextLength: s.maxTextLength.Load(),
MaxBatchSize: maxBatchSize,
- }, nil
+ }
+ if cs := s.centroids.Load(); cs != nil {
+ info.CentroidSetVersion = cs.Version
+ }
+ return info, nil
}
// Health maps backend state to the proto Status enum. SHUTTING_DOWN
diff --git a/internal/spawn/spawn.go b/internal/spawn/spawn.go
index 0c91374..9b3e163 100644
--- a/internal/spawn/spawn.go
+++ b/internal/spawn/spawn.go
@@ -12,6 +12,8 @@ import (
"path/filepath"
"syscall"
"time"
+
+ "github.com/CryptoLabInc/runed/internal/ipc"
)
// dialResult enumerates the meaningful outcomes of probing the daemon
@@ -81,7 +83,15 @@ func EnsureDaemon(ctx context.Context, socketPath string) error {
// We don't speak gRPC here — successful dial + immediate close is
// sufficient evidence that a listener exists. The actual Health RPC
// happens later when the caller's Connect runs.
+//
+// The canonical path is resolved via ipc.ResolveSocketPath before any
+// stat/dial: when $RUNED_HOME is deep, the daemon binds a short
+// deterministic alias, and probing the canonical path would always report
+// the daemon absent. EnsureDaemon deliberately keeps the CANONICAL path for
+// everything else — the spawn.lock location, and the RUNED_HOME the daemon
+// is launched with — so only the socket itself moves to the alias.
func probeDaemon(ctx context.Context, socketPath string) (dialResult, error) {
+ socketPath = ipc.ResolveSocketPath(socketPath)
// Pre-flight stat: if the path exists but isn't a socket, classify as
// hostile before attempting the dial. Linux returns ECONNREFUSED for
// dialing a regular file (which our errors.Is below would otherwise
diff --git a/internal/spawn/spawn_test.go b/internal/spawn/spawn_test.go
index 8933af8..e6ea94f 100644
--- a/internal/spawn/spawn_test.go
+++ b/internal/spawn/spawn_test.go
@@ -10,6 +10,8 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/CryptoLabInc/runed/internal/ipc"
)
// fakeRunedScript is a shell script that mimics what the real runed binary
@@ -52,6 +54,26 @@ func TestEnsureDaemon_AlreadyRunning(t *testing.T) {
}
}
+// A deep RUNED_HOME must still take the already-running fast path: both the
+// listener and spawn probe resolve the same short alias instead of attempting
+// to dial the over-limit canonical path.
+func TestEnsureDaemon_AlreadyRunningAtLongPath(t *testing.T) {
+ home := filepath.Join(shortTempDir(t), strings.Repeat("d", 60), strings.Repeat("e", 60))
+ canonical := filepath.Join(home, "embedding.sock")
+ lis, err := ipc.Listen(canonical)
+ if err != nil {
+ t.Fatalf("listen long path: %v", err)
+ }
+ defer lis.Close()
+
+ t.Setenv("RUNED_CONFIG", "/tmp/runed-no-such-file")
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := EnsureDaemon(ctx, canonical); err != nil {
+ t.Fatalf("EnsureDaemon long path: %v", err)
+ }
+}
+
// bindFakeListener opens a UDS at path that simply accepts connections
// and immediately closes them. Returns a stop func to unbind. We use it
// as a stand-in for "a daemon is reachable".
diff --git a/licenses.go b/licenses.go
new file mode 100644
index 0000000..d775a12
--- /dev/null
+++ b/licenses.go
@@ -0,0 +1,23 @@
+// Package runed exposes the repository's license texts as an embedded
+// filesystem so the self-bootstrap can install them next to the third-party
+// artifacts it downloads. Redistribution of llama-server (MIT) and
+// the Qwen3 GGUF weights (Apache-2.0) requires shipping the license texts
+// with the copies — embedding them in the daemon binary guarantees every
+// machine that receives the artifacts also receives the licenses.
+package runed
+
+import "embed"
+
+// LicenseFS holds this repository's own LICENSE (Apache-2.0) and NOTICE plus
+// the third-party license texts (with their attribution index). Apache-2.0
+// §4(d) requires redistributions to carry the NOTICE file, so it travels with
+// the LICENSE everywhere. Paths inside the FS mirror the repo:
+//
+// LICENSE
+// NOTICE
+// THIRD_PARTY_LICENSES/README.md
+// THIRD_PARTY_LICENSES/llama.cpp.LICENSE
+// THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE
+//
+//go:embed LICENSE NOTICE THIRD_PARTY_LICENSES
+var LicenseFS embed.FS
diff --git a/proto/runed/v1/runed.proto b/proto/runed/v1/runed.proto
index a5e2b38..0bba79e 100644
--- a/proto/runed/v1/runed.proto
+++ b/proto/runed/v1/runed.proto
@@ -26,24 +26,48 @@ service RunedService {
// Shutdown requests a graceful drain. The daemon stops accepting new
// requests, completes in-flight ones, and exits.
rpc Shutdown(ShutdownRequest) returns (ShutdownResponse);
+
+ // SetCentroids replaces the daemon's IVF centroid set (header frame, then
+ // id-ordered batches — the same wire shape runespace's GetCentroids
+ // streams). rune-mcp relays the set here from the Console so Embed can route
+ // inserts (with_route) without runed ever dialing the index engine. The
+ // set is persisted to the daemon cache and survives restarts.
+ rpc SetCentroids(stream SetCentroidsRequest) returns (SetCentroidsResponse);
}
message EmbedRequest {
// Input text. Maximum length is InfoResponse.max_text_length characters.
// Exceeding the limit returns INVALID_ARGUMENT.
string text = 1;
+
+ // When true the response also carries the vector's IVF cluster assignment
+ // (cluster_id + centroid_set_version). Requires a centroid set loaded via
+ // SetCentroids; otherwise the call fails with FAILED_PRECONDITION.
+ bool with_route = 2;
}
message EmbedResponse {
// L2-normalized embedding. The server always normalizes output
// (llama.cpp default); no opt-out. Length equals InfoResponse.vector_dim.
repeated float vector = 1 [packed = true];
+
+ // IVF hard single assignment (0..nlist-1). Only set when the request asked
+ // for routing (with_route); the vector is already normalized so the
+ // assignment metric is max inner product, matching runespace insert routing.
+ uint32 cluster_id = 2;
+
+ // Version (content hash) of the centroid set the assignment was routed
+ // against. Callers echo this to the index engine so a stale set is rejected.
+ string centroid_set_version = 3;
}
message EmbedBatchRequest {
// Input texts. Batch size must not exceed InfoResponse.max_batch_size.
// Each text individually bound by InfoResponse.max_text_length.
repeated string texts = 1;
+
+ // Same as EmbedRequest.with_route, applied to every embedding in the batch.
+ bool with_route = 2;
}
message EmbedBatchResponse {
@@ -70,6 +94,10 @@ message InfoResponse {
// Maximum batch size accepted by EmbedBatch.
int32 max_batch_size = 5;
+
+ // Version of the currently loaded IVF centroid set; empty when none is
+ // loaded (with_route requests fail until SetCentroids delivers one).
+ string centroid_set_version = 6;
}
message HealthRequest {}
@@ -121,3 +149,37 @@ message ShutdownResponse {
// True if the daemon accepted the shutdown request and is now draining.
bool accepted = 1;
}
+
+message SetCentroidsRequest {
+ oneof payload {
+ CentroidSetHeader header = 1;
+ CentroidBatch batch = 2;
+ }
+}
+
+// CentroidSetHeader opens the stream: identity and shape of the set.
+message CentroidSetHeader {
+ string version = 1; // content hash (sha256); must be non-empty
+ uint32 dim = 2; // must equal InfoResponse.vector_dim
+ uint32 nlist = 3; // total centroid count that will follow
+ // evi preset the set was trained for (e.g. "IP1") — a version-hash
+ // ingredient. When present, runed recomputes the content hash over the
+ // received vectors and rejects the push on mismatch; empty (legacy
+ // senders) skips that verification.
+ string preset = 4;
+}
+
+// CentroidBatch carries centroids in id order (append order == cluster id).
+message CentroidBatch {
+ repeated Centroid centroids = 1;
+}
+
+message Centroid {
+ uint32 id = 1;
+ repeated float vec = 2 [packed = true];
+}
+
+message SetCentroidsResponse {
+ string version = 1; // version now active
+ uint32 nlist = 2; // centroids loaded
+}