diff --git a/.github/assets/repo-badges.svg b/.github/assets/repo-badges.svg new file mode 100644 index 0000000..d359394 --- /dev/null +++ b/.github/assets/repo-badges.svg @@ -0,0 +1,7 @@ + + Embedding daemon, Qwen3 0.6B, Apache 2.0, Release v1.0.0-alpha + ROLEEmbedder daemon + MODELQwen3 0.6B + LICENSEApache 2.0 + RELEASEv1.0.0-alpha + diff --git a/.github/assets/repo-hero.svg b/.github/assets/repo-hero.svg new file mode 100644 index 0000000..94b32d7 --- /dev/null +++ b/.github/assets/repo-hero.svg @@ -0,0 +1,19 @@ + + runed — shared local embedding runtime + + + + + + + + + + LOCAL EMBEDDING RUNTIME + RUNED + One embedding model, shared by every session. + On-demand startup, verified artifacts, and idle suspension. + QWEN3 EMBEDDINGSONE DAEMONAUTO SUSPEND + + + diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..b5b74ed --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,126 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + version: + description: "Release version (for example, v1.0.0-alpha.1)" + required: true + type: string + target_branch: + description: "Existing release/* branch to update" + required: true + type: string + +permissions: + contents: write + +concurrency: + group: prepare-release-${{ github.repository }}-${{ inputs.target_branch }} + cancel-in-progress: false + +jobs: + update-release-badge: + name: Update release badge + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + RELEASE_VERSION: ${{ inputs.version }} + TARGET_BRANCH: ${{ inputs.target_branch }} + steps: + - name: Validate target branch + shell: bash + run: | + if [[ "$TARGET_BRANCH" != release/* ]]; then + echo "target_branch must start with release/" >&2 + exit 1 + fi + git check-ref-format --branch "$TARGET_BRANCH" + + - name: Check out release branch + uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_branch }} + fetch-depth: 0 + + - name: Update release badge + id: badge + shell: bash + run: | + node <<'NODE' + const fs = require("node:fs"); + + const rawVersion = (process.env.RELEASE_VERSION || "").trim(); + const version = rawVersion.startsWith("v") ? rawVersion : `v${rawVersion}`; + const semver = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?(?:\+[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/; + if (!semver.test(version) || version.length > 24) { + throw new Error(`Invalid release version: ${rawVersion}`); + } + + const candidates = [ + ".github/assets/badge-release.svg", + ".github/assets/repo-badges.svg", + ]; + const assets = candidates.filter((path) => + fs.existsSync(path) && fs.readFileSync(path, "utf8").includes("data-release-version=") + ); + if (assets.length !== 1) { + throw new Error(`Expected one marked release badge, found ${assets.length}`); + } + + const asset = assets[0]; + let svg = fs.readFileSync(asset, "utf8"); + const marker = /data-release-version="([^"]+)"/g; + const markers = [...svg.matchAll(marker)]; + if (markers.length !== 1) { + throw new Error("Release badge must contain exactly one version marker"); + } + const previous = markers[0][1]; + + const versionText = /(]*\bid="release-version"[^>]*>)[^<]*(<\/text>)/g; + if ([...svg.matchAll(versionText)].length !== 1) { + throw new Error("Release badge must contain exactly one release-version text node"); + } + + const title = /]*\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 — shared local embedding runtime + +

-`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. +

+ Embedding daemon · Qwen3 0.6B · Apache 2.0 · Release v1.0.0-alpha +

-## 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 +}