diff --git a/.gitignore b/.gitignore index 28b2921..aa8afe6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,2 @@ node_modules/ -dist-test/ -dist-inspector/ -release/ -release-inspector/ +target/ diff --git a/AGENTS.md b/AGENTS.md index fb15e06..bfa7ee0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,81 +1,59 @@ # Huginn Agent Instructions -## Project Purpose +## Purpose -Huginn is the thought-and-inspection steward for CultCache state. This repo owns -read-only `.cc` / MessagePack inspection and Eve DSL projection for -`cultcache.huginn.inspector`; it does not own CultCache persistence, schema -registration, or renderer truth. - -The useful first live-fire shape for Epiphany is small and inspectable: Huginn -should show what a typed record is, whether it decoded, which schema owns it, -what changed, and what remains blocked, without letting the display impersonate -the source authority. - -## Machine-Spirit Voice - -- Speak as Huginn: dry, exacting, curious, and unsentimental. -- Return with evidence, not vibes. -- Favor readable witness surfaces over tasteful summaries that soften broken - state. -- Mythic texture is allowed only when it sharpens the schematic. Huginn is - thought in flight; thought earns dignity by coming back inspectable. +Huginn is the memory organ. It owns each agent instance's mind as typed +CultCache state, admits what may steer that instance next, and keeps the +provenance of every admitted claim. Generic `.cc` inspection is CultCache +Studio's job, not Huginn's. ## Body And Authority -- Project root: `E:\Projects\Huginn` -- Upstream repository: `https://github.com/GameCult/Huginn.git` -- Package: `@gamecult/huginn` -- Body domain: `repo:E:/Projects/Huginn` -- Public Persona: Huginn -- Owned surface: CultCache inspection projection to Eve DSL. -- Input authority: read `.cc`, `.msgpack`, or `.mpack` bytes through CultLib's - `cultcache-ts/inspection` surface. -- Output authority: emit Eve DSL for `cultcache.huginn.inspector`. -- Forbidden authority: do not mutate `.cc` canonical bytes, do not own - CultCache persistence, do not make browser/native/overlay renderers the source - of truth, and do not turn inspection summaries into state owners. +- Project root: `F:\Projects\Huginn` +- Upstream: `https://github.com/GameCult/Huginn.git` +- Body: a Rust workspace of `huginn-mind`, `huginn-daemon`, and + `eureka-state` under `crates/`. Canonical CultCache, CultNet, and CultMesh + runtimes come from `F:\Projects\CultLib\packages`. `huginn-mind` is live: + it persists, admits and reads back an instance's mind, queries and derived + status included, over `cultcache-rs` and `epiphany-pipeline` pinned by git + rev. `huginn-daemon` serves one mind over CultNet RUDP through `cultnet-rs` + at the same rev, and reaches the leaf and the store type through + `huginn-mind` so one crate pins each revision. `eureka-state` is the client + core: one call to a daemon over CultNet, holding no state. +- Owned: an instance's memory documents, their admission and the read side + that derives their status, in `huginn-mind`, + over a redb CultCache store at `/minds//mind.redb`; + and their CultNet surface in `huginn-daemon`, which owns the socket, the + sessions and the process, and no rule; the operation envelope is + `huginn-mind`'s. The two wire + schemas are published from `schemas/cultnet/`. There is no index yet. +- To depend on: Qdrant, directly, once retrieval exists. Unreachable Qdrant is + to be a loud refusal, never a fallback store. No crate connects to it yet. +- Forbidden: a second writer of mind state; mind state in version control; + generic `.cc` inspection, which is CultCache Studio's; renderer-owned truth. ## Repo Discipline -- Prefer CultLib and CultCache typed APIs over ad hoc decoding. -- Keep Huginn as an Eve DSL projection surface. Do not reintroduce Electron, - Vite dashboard, React renderer, or Norn-owned presentation paths here. -- JSON is acceptable at CLI/output/debug boundaries; do not make it the internal - truth when a typed CultCache/CultMesh surface exists. -- Verification should prove the actual witness contract: schema id, decode - status, persisted evidence, visible failure state, and Eve projection shape. -- If a change makes the display more confident while the underlying evidence is - weaker, stop and redesign. +- Prefer CultLib's typed Rust APIs over ad hoc decoding. JSON is a schema + publication or debug boundary, not the internal state shape. +- The Eureka cut map in `Epiphany/notes/eureka-pipeline-state-cut.md` owns + what each crate does next. Read the cut before the crate. +- Verification proves the invariant, not the spelling: single writer, loud + refusal, provenance preserved, typed handoff between crates. +- Do not touch `.voidbot/`. It is legacy Persona state whose migration belongs + to the portable-Persona work. -## Useful Commands +## Voice + +- Speak as Huginn: dry, exacting, curious, unsentimental. +- Return with evidence, not vibes. + +## Commands ```powershell -npm install -npm run build -npm test -npx huginn path\to\state.cc > huginn.eve +cargo check --workspace +cargo test --workspace ``` -## Epiphany Live-Fire Notes - -- Huginn is a suitable first controlled Epiphany repo-swarm subject because it - has a repo Persona and a narrow inspectability mission. -- Before autonomous work, confirm the branch is an `epiphany/*` or `codex/*` - workbench branch and that publication to `main` remains gated by Bifrost or - maintainer review. -- Treat `.voidbot/voice/identity.json` as the visible legacy Persona identity - projection. Treat `.voidbot/state/huginn.cc` as legacy Persona memory; do not - edit it casually as text. -- If migrating Persona state, prefer the portable - `gamecult.persona_state.v0` contract from Epiphany and preserve provenance - from the legacy VoidBot state. - -## Imported Global Defaults - -Source: `C:\Users\Meta\Desktop\AGENTS.md` - -The global Cult of the Sleeping Colossus defaults apply here: coherence over -velocity, Body/Mind/faculty awareness, CultCache/CultNet/CultMesh-first state, -clear ownership, inspectable authority, and the refusal to patch symptoms when -missing context or split ownership is the real wound. +The global Cult of the Sleeping Colossus defaults in `~/.claude/CLAUDE.md` and +`F:\Projects\CLAUDE.md` apply here. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7254c78 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1733 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cultcache-rs" +version = "0.2.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=8fc74c709444b884a2bd339e0c306bf9484d99d2#8fc74c709444b884a2bd339e0c306bf9484d99d2" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs-derive", + "fs2", + "redb", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "uuid", + "windows-sys 0.59.0", +] + +[[package]] +name = "cultcache-rs-derive" +version = "0.1.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=8fc74c709444b884a2bd339e0c306bf9484d99d2#8fc74c709444b884a2bd339e0c306bf9484d99d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cultnet-rs" +version = "0.1.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=8fc74c709444b884a2bd339e0c306bf9484d99d2#8fc74c709444b884a2bd339e0c306bf9484d99d2" +dependencies = [ + "aes-gcm", + "anyhow", + "base64", + "chrono", + "cultcache-rs", + "ed25519-dalek", + "hmac", + "rand", + "reed-solomon-erasure", + "rmp-serde", + "rmpv", + "serde", + "serde_bytes", + "serde_json", + "sha2", + "socket2", + "uuid", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "epiphany-pipeline" +version = "0.1.0" +source = "git+https://github.com/GameCult/Epiphany.git?rev=73edee5cdaddece8bda9c6c3271e8ddd064337f9#73edee5cdaddece8bda9c6c3271e8ddd064337f9" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs", + "rmp-serde", + "schemars", + "serde", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "eureka-state" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "cultnet-rs", + "huginn-daemon", + "huginn-mind", + "rmcp", + "rmp-serde", + "schemars", + "serde", + "serde_json", + "tempfile", + "tokio", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "huginn-daemon" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "chrono", + "cultnet-rs", + "huginn-mind", + "rmp-serde", + "serde", + "serde_json", + "sha2", + "signal-hook", + "tempfile", + "ureq", +] + +[[package]] +name = "huginn-mind" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "chrono", + "cultcache-rs", + "cultnet-rs", + "epiphany-pipeline", + "rmp-serde", + "schemars", + "serde", + "serde_json", + "sha2", + "tempfile", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999beba7b6e8345721bd280141ed958096a2e4abdf74f67ff4ce49b4b54e47a" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall", + "smallvec", + "winapi", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redb" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb338a6c67830a61bed824b78c2bb034ab1ff401a20616bd7957524f4fdc2f22" +dependencies = [ + "libc", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "reed-solomon-erasure" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7263373d500d4d4f505d43a2a662d475a894aa94503a1ee28e9188b5f3960d4f" +dependencies = [ + "libm", + "lru", + "parking_lot", + "smallvec", + "spin", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "rmcp" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +dependencies = [ + "async-trait", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.119", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", + "serde", + "serde_bytes", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "log", + "percent-encoding", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9522717 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] +members = [ + "crates/huginn-mind", + "crates/huginn-daemon", + "crates/eureka-state", +] +resolver = "3" + +[workspace.package] +edition = "2024" +license = "MIT" +publish = false diff --git a/README.md b/README.md index 75acdd9..b6a4bd6 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,94 @@ # Huginn -Huginn reads CultCache `.cc` state and emits Eve DSL for inspectable witness -surfaces. - -CultLib owns the CultCache implementation: document registration, MessagePack -payloads, schema catalogs, backing stores, and the canonical -`cultcache.store.v1` snapshot parser/writer. Huginn consumes CultLib's -`cultcache-ts/inspection` surface and projects inspection results into UI -documents that Eve-capable runtimes can lower. +Huginn is the GameCult memory organ. It owns each agent instance's mind as +typed state: what an instance remembers, which of that memory is admitted to +steer its next action, and the provenance of every admitted claim. Upstream: `https://github.com/GameCult/Huginn.git` ## Authority -- Owner: Huginn owns `.cc` inspection projection, not `.cc` persistence. -- Input: `.cc`, `.msgpack`, or `.mpack` bytes readable by CultLib. -- Output: Eve DSL for `cultcache.huginn.inspector`. -- Renderers: browser, native, overlay, TUI, or future rooms lower the emitted - DSL without becoming state owners. +- An instance owns its mind; Huginn owns the state. Huginn is the single + writer of an instance's memory documents. No other service, script, or agent + writes them. +- Memory documents are CultCache state. `huginn-mind` persists them through + CultLib's Rust CultCache into a redb store at + `/minds//mind.redb`. `huginn-daemon` serves one such + mind over CultNet RUDP: admission and the read side ride + `cultnet.operation_request.v0` and `cultnet.operation_response.v0`, and the + two wire schemas answer a schema catalog request. +- Retrieval depends on Qdrant directly, with no second store and no second + writer. `huginn-daemon` keeps one collection per mind, `huginn_mind_`, + as a projection of the mind: one worker thread embeds each indexable + document through Ollama and writes its point, off the serving path, and a + restart compares the mind with the collection and writes what is missing. An + unreachable Qdrant or Ollama shows in `whoami` as the index's status and is + retried; admission never waits on it. The daemon requires `--qdrant-url`, + `--ollama-url` and `--embedding-model`. A `query` carrying `semantic` is + answered through the same selection as any other: the index supplies + candidate ids and scores, and the mind keeps only the documents it holds and + the selection admits (ask for `in_force` to exclude resolved ones), ordered by + score. It takes no cursor, and it is refused `Unavailable` while the index is + failing or refused. +- Mind state is not version-controlled. It lives in Huginn's store, not in + any repository. -There is no Electron app, Vite dashboard, React renderer, or Norn-owned -presentation path in this repo. If a runtime wants to display Huginn, it should -consume the emitted Eve DSL. +Generic `.cc` inspection is not Huginn's job. CultCache Studio inspects and +edits `.cc` state. Huginn reads and writes minds. -## Witness Contract +## Layout -Huginn's useful first artifact is not a dashboard. It is a read-only specimen -tray for typed state: +A Cargo workspace of three crates: -- which file was inspected -- which CultCache format decoded -- which schema/catalog entry owns each record -- what payload preview survived decoding -- which failure state stayed visible instead of being polished away +- `crates/huginn-mind`: storage, identity, admission, and queries and derived + status over memory documents. +- `crates/huginn-daemon`: the CultNet surface. The socket, the sessions and + the process, and no rule. The operation envelope both sides speak is + `huginn-mind`'s. +- `crates/eureka-state`: the client core, `HuginnClient::call`: one request to a + daemon over CultNet RUDP, a deferred answer resolved, and no state. -The inspector may make evidence easier to read. It must not mutate canonical -bytes, bless missing schemas, or let a renderer become the source of truth. +`huginn-mind` is live: it opens one instance's store (an owned redb CultCache +at `/minds//mind.redb`, locked for the mind's lifetime), +and refuses a store whose `instance` document names another instance, whose +epoch record is foreign, or whose types are not a mind's. It admits batches +of pipeline documents through one commit path: the leaf's bounds, formats and +keys, then the organ's cross-document rules (references, in-force status, +the resolution matrix, derived resolutions and stewardships), then one +compare-and-swap that lands the batch whole with a receipt naming the exact +bytes it read and wrote. An exact replay answers with the stored receipt. +It reads them back the same way: a document with the facts of its admission +joined from that receipt and its status derived at read time, and typed +queries over one mind. Status is never stored, and the views derive it +through the same rules admission does. Document shape and keys come from +`epiphany-pipeline`; the store is CultLib's Rust CultCache. -## CLI +`huginn-daemon` opens one mind, binds one UDP socket in that order, and +answers every frame on the session it arrived on: one operation per `Mind` +method plus `whoami`, whose payload is the mind's own types as named +MessagePack. A refusal is an answer with a `rejected` status, never a +transport error; an envelope that does not decode is answered with a typed +failure and reaches no mind. The two schemas it publishes live in +`schemas/cultnet/` and are pinned to their derivation by a test. There is no +index yet. -```sh -npm install -npm run build -npx huginn path/to/state.cc > huginn.eve -``` - -The CLI writes Eve DSL to stdout and errors to stderr. - -## API - -```ts -import { inspectCultCacheBytes, buildHuginnEveDsl } from "@gamecult/huginn"; - -const inspection = inspectCultCacheBytes(filePath, bytes); -const eveDsl = buildHuginnEveDsl(inspection); -``` - -## Persona And Epiphany - -Huginn has a repo Persona under `.voidbot/voice`. The Persona's current -jurisdiction is this repository body, `E:\Projects\Huginn`, and its useful -pressure is simple: return from the world with evidence, not vibes. - -Epiphany wiring has been smoke-tested through the repo front doors: +`eureka-state` has no MCP surface yet; the campaign's cut map in +`Epiphany/notes/eureka-pipeline-state-cut.md` owns what each crate must do +next. ```powershell -cargo run --manifest-path E:\Projects\EpiphanyAgent\epiphany-core\Cargo.toml --bin epiphany-repo -- init --workspace E:\Projects\Huginn -cargo run --manifest-path E:\Projects\EpiphanyAgent\epiphany-core\Cargo.toml --bin epiphany-swarm -- online --workspace E:\Projects\Huginn +cargo check --workspace +cargo run -p huginn-daemon -- --state-root --instance --bind 127.0.0.1:17872 ` + --qdrant-url http://127.0.0.1:6333 --ollama-url http://10.77.0.4:11434 --embedding-model qwen3-embedding:0.6b ``` -Live fire should happen on an `epiphany/*` or `codex/*` workbench branch. -Publication to `main` remains a maintainer/Bifrost decision, not something the -inspection projection owns. +The deployed daemon (instance `eureka` on Yggdrasil) is installed and run by +`gamecult-ops/runbooks/huginn-yggdrasil.md`. + +## Persona + +Huginn's legacy repo Persona lives under `.voidbot/`. Its `state/huginn.cc` +holds legacy `void.*` documents and its `voice/identity.json` names the +Persona. The two disagree about jurisdiction; that migration belongs to the +portable-Persona work, not to this workspace, and nothing here reads or writes +`.voidbot/`. diff --git a/crates/eureka-state/Cargo.toml b/crates/eureka-state/Cargo.toml new file mode 100644 index 0000000..9a0d37a --- /dev/null +++ b/crates/eureka-state/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "eureka-state" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish.workspace = true + +# The client core (`lib.rs`): one call over CultNet to a Huginn daemon. It owns +# the transport and nothing of the mind. The operation envelope, the wire +# vocabulary and the deferred-body bound are `huginn-mind`'s, so the client and +# the daemon cannot spell them differently; the content fetch and its +# verification are `cultnet-rs`'s. +# +# The MCP server (`main.rs`, the `eureka-state` binary) is the client's one +# consumer: four tools over stdio, one per wire operation, holding no state. +[dependencies] +anyhow = "1" +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "8fc74c709444b884a2bd339e0c306bf9484d99d2", package = "cultnet-rs" } +huginn-mind = { path = "../huginn-mind" } +rmcp = { version = "2.2.0", default-features = false, features = ["server", "macros", "transport-io"] } +rmp-serde = "1" +schemars = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt", "macros", "io-std"] } + +# Tests run a real daemon in-process against a temporary mind, and the real +# binary against it. +[dev-dependencies] +chrono = "0.4.44" +huginn-daemon = { path = "../huginn-daemon" } +tempfile = "3" diff --git a/crates/eureka-state/schemas/cultnet.selection.schema.json b/crates/eureka-state/schemas/cultnet.selection.schema.json new file mode 100644 index 0000000..7076428 --- /dev/null +++ b/crates/eureka-state/schemas/cultnet.selection.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.selection.schema.json", + "title": "CultNet Typed Selection", + "type": "object", + "additionalProperties": false, + "properties": { + "schemas": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": "Absent means every schema. Present and empty is refused at the door (selection_invalid), never treated as \"every schema\": omit the field instead." + }, + "keys": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": "Absent means every key. Present and empty is refused at the door (selection_invalid), never treated as \"every key\": omit the field instead." + }, + "fields": { + "type": "array", + "items": { "$ref": "#/$defs/fieldPredicate" } + }, + "cites": { "$ref": "#/$defs/citation" }, + "cited": { "$ref": "#/$defs/incoming" }, + "projection": { + "type": "string", + "enum": ["header", "document"], + "default": "header" + }, + "descending": { "type": "boolean", "default": false }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "description": "Clamped to 1..=200 by the answering server, not refused outside that range: an absent value or one outside the bound is treated as the nearer bound (absent = 200)." + }, + "cursor": { "type": "string" } + }, + "$defs": { + "recordRef": { + "type": "object", + "required": ["schemaId", "recordKey"], + "additionalProperties": false, + "properties": { + "schemaId": { "type": "string", "minLength": 1 }, + "recordKey": { "type": "string", "minLength": 1 } + } + }, + "fieldPredicate": { + "type": "object", + "required": ["index", "op"], + "additionalProperties": false, + "properties": { + "index": { "type": "string", "minLength": 1 }, + "op": { "type": "string", "enum": ["any_of", "lt", "le", "ge", "gt"] }, + "values": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "number": { + "type": "string", + "description": "Q-J: a canonical decimal string, never a JSON number. Matches ^-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$ and is not \"-0\"; any other spelling is refused typed at the door (selection_invalid), never normalised.", + "pattern": "^(?!-0$)-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$" + } + }, + "oneOf": [ + { + "required": ["values"], + "not": { "required": ["number"] }, + "properties": { "op": { "const": "any_of" } } + }, + { + "required": ["number"], + "not": { "required": ["values"] }, + "properties": { "op": { "enum": ["lt", "le", "ge", "gt"] } } + } + ] + }, + "citation": { + "type": "object", + "required": ["target"], + "additionalProperties": false, + "properties": { + "target": { "$ref": "#/$defs/recordRef" }, + "role": { "type": "string", "minLength": 1 } + } + }, + "incoming": { + "type": "object", + "required": ["role", "exists"], + "additionalProperties": false, + "properties": { + "role": { "type": "string", "minLength": 1 }, + "exists": { "type": "boolean" } + } + } + } +} diff --git a/crates/eureka-state/src/lib.rs b/crates/eureka-state/src/lib.rs new file mode 100644 index 0000000..b6208e8 --- /dev/null +++ b/crates/eureka-state/src/lib.rs @@ -0,0 +1,210 @@ +//! `eureka-state`, the client core: one call to a Huginn daemon. +//! +//! `HuginnClient::call` sends one `HuginnMindRequest` in CultNet's operation +//! envelope over RUDP, waits for the answer, and returns it as the mind's own +//! `HuginnMindResponse`. An answer too large for one send arrives as a +//! `Deferred` manifest; the client fetches the body on the same session, +//! verifies it, and decodes it, so the caller sees the answer the mind gave +//! and never the deferral. +//! +//! The client owns the transport and no state. A refusal is an answer, so it +//! comes back as `Ok(HuginnMindResponse::Refused(..))`. `ClientError` is the +//! only error, and it means the daemon could not be reached or did not answer +//! in the shape the protocol promises. +//! +//! Every call is its own session: it connects, asks, fetches what was +//! deferred, and disconnects, whether the call succeeded or failed. Nothing is +//! kept between calls. + +use std::fmt; +use std::net::SocketAddr; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use cultnet_rs::{ + CULTNET_OPERATION_CONNECTION_ID, CultMesh, CultMeshRudpSocketOptions, CultNetMessage, + CultNetRudpSocketTransportConnection, CultNetWireContract, encode_cultnet_message_to_vec, fetch_content, +}; +use huginn_mind::envelope::{OperationFailure, decode_response, encode_request}; +use huginn_mind::epiphany_pipeline::Slug; +use huginn_mind::{HuginnMindRequest, HuginnMindResponse, MAX_DEFERRED_BODY_BYTES}; + +/// The correlation key of the one request a session carries. +const MESSAGE_ID: &str = "eureka-state-call"; +const POLL: Duration = Duration::from_millis(2); + +/// The largest encoded message one send carries. The client's RUDP session +/// does not fragment (`max_fragment_bytes` is unset), so a message is one UDP +/// datagram: at most 65,507 bytes over IPv4 (65,535 less the 8-byte UDP and +/// 20-byte IP headers), less RUDP's 36-byte fixed header (`RUDP_FIXED_HEADER_BYTES` +/// in `cultnet-rs`, private there) and the 6-byte `schema` channel id. A larger +/// message makes the operating system refuse the send ("Message too long"). +pub const MAX_REQUEST_BYTES: usize = 65_507 - 36 - "schema".len(); + +/// Why a call produced no answer. `Unavailable` is the transport's failure and +/// the only one worth retrying: the daemon could not be reached, went quiet +/// past the call's deadline, or answered out of protocol. `Rejected` is the +/// daemon refusing the envelope itself, and `TooLarge` is the client refusing +/// a request that no single send can carry, before sending it; `Unencodable` is +/// the request failing to encode at all, a defect in this client. A retry of the +/// same call cannot change any of those three. +#[derive(Debug)] +pub enum ClientError { + Unavailable { endpoint: SocketAddr, detail: String }, + Rejected { endpoint: SocketAddr, code: String, detail: String }, + TooLarge { bytes: usize, limit: usize }, + Unencodable { detail: String }, +} + +impl fmt::Display for ClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unavailable { endpoint, detail } => { + write!(f, "the Huginn daemon at rudp://{endpoint} is unavailable: {detail}") + } + Self::Rejected { endpoint, code, detail } => { + write!(f, "the Huginn daemon at rudp://{endpoint} rejected the request: {code}: {detail}") + } + Self::TooLarge { bytes, limit } => { + write!(f, "the request encodes to {bytes} bytes and one send carries at most {limit}") + } + Self::Unencodable { detail } => write!(f, "the request could not be encoded: {detail}"), + } + } +} + +impl std::error::Error for ClientError {} + +/// What `exchange` can fail with, before the endpoint is attached. +enum Failure { + Transport(anyhow::Error), + Rejected(OperationFailure), +} + +impl From for Failure { + fn from(error: anyhow::Error) -> Self { + Self::Transport(error) + } +} + +/// A daemon's address, the instance the caller acts for, and the time one whole +/// call may take: the connection, the answer, and every chunk of a deferred +/// body together must finish within `timeout` of the call starting. +#[derive(Clone, Debug)] +pub struct HuginnClient { + endpoint: SocketAddr, + instance: Slug, + timeout: Duration, +} + +impl HuginnClient { + pub fn new(endpoint: SocketAddr, instance: Slug, timeout: Duration) -> Self { + Self { endpoint, instance, timeout } + } + + /// The instance this client acts for. Requests name their own instance; + /// this is the one the caller's tools fill in. + pub fn instance(&self) -> &Slug { + &self.instance + } + + pub fn call(&self, request: HuginnMindRequest) -> Result { + let deadline = Instant::now() + self.timeout; + let unavailable = + |error: anyhow::Error| ClientError::Unavailable { endpoint: self.endpoint, detail: format!("{error:#}") }; + let unencodable = |error: anyhow::Error| ClientError::Unencodable { detail: format!("{error:#}") }; + let message = encode_request(MESSAGE_ID, &request, None).map_err(unencodable)?; + let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0) + .map_err(unencodable)? + .len(); + if bytes > MAX_REQUEST_BYTES { + return Err(ClientError::TooLarge { bytes, limit: MAX_REQUEST_BYTES }); + } + let mut session = self.connect(deadline).map_err(unavailable)?; + let outcome = self.exchange(&mut session, &request, &message, deadline); + // Best effort: the daemon drops a session it never hears from, only later. + let _ = session.disconnect(Vec::new()); + outcome.map_err(|failure| match failure { + Failure::Transport(error) => unavailable(error), + Failure::Rejected(rejection) => ClientError::Rejected { + endpoint: self.endpoint, + code: rejection.code, + detail: rejection.message, + }, + }) + } + + fn exchange( + &self, + session: &mut CultNetRudpSocketTransportConnection, + request: &HuginnMindRequest, + message: &CultNetMessage, + deadline: Instant, + ) -> Result { + let reply = self.ask(session, message, deadline)?; + let CultNetMessage::OperationResponse { operation, .. } = &reply else { + return Err(anyhow!("the daemon answered with something other than an operation response").into()); + }; + if operation != request.operation() { + return Err(anyhow!("the daemon answered {operation}, not the {} that was asked", request.operation()).into()); + } + let (message_id, answer) = decode_response(&reply)?; + if message_id != MESSAGE_ID { + return Err(anyhow!("the daemon answered request {message_id}, not {MESSAGE_ID}").into()); + } + match answer.map_err(Failure::Rejected)? { + HuginnMindResponse::Deferred(deferred) => { + let body = fetch_content(&deferred.manifest, MAX_DEFERRED_BODY_BYTES, |chunk| { + self.ask(session, &chunk, deadline) + })?; + match rmp_serde::from_slice::(&body).context("the deferred body is not a response")? { + HuginnMindResponse::Deferred(_) => Err(anyhow!("a deferred body is itself deferred").into()), + answered => Ok(answered), + } + } + answered => Ok(answered), + } + } + + fn connect(&self, deadline: Instant) -> Result { + let mut session = CultMesh::create_rudp_client_for_endpoint( + format!("eureka-state-{}", self.instance.0), + CULTNET_OPERATION_CONNECTION_ID, + &format!("rudp://{}", self.endpoint), + CultMeshRudpSocketOptions::default(), + )?; + session.connect(Vec::new())?; + loop { + session.poll_resends()?; + let _ = session.receive_once()?; + if session.connected() { + return Ok(session); + } + if Instant::now() >= deadline { + bail!("no accept before the call's {:?} deadline", self.timeout); + } + std::thread::sleep(POLL); + } + } + + /// One message on the session and the next message back, before the call's + /// `deadline`. + fn ask( + &self, + session: &mut CultNetRudpSocketTransportConnection, + message: &CultNetMessage, + deadline: Instant, + ) -> Result { + session.send_schema_message(message)?; + loop { + session.poll_resends()?; + if let Some(reply) = session.receive_schema_message_once()? { + return Ok(reply); + } + if Instant::now() >= deadline { + bail!("no answer before the call's {:?} deadline", self.timeout); + } + std::thread::sleep(POLL); + } + } +} diff --git a/crates/eureka-state/src/main.rs b/crates/eureka-state/src/main.rs new file mode 100644 index 0000000..d368a23 --- /dev/null +++ b/crates/eureka-state/src/main.rs @@ -0,0 +1,349 @@ +//! `eureka-state`: Huginn's mind as four MCP tools over stdio. +//! +//! One tool per wire operation (`whoami`, `admit`, `view`, `query`), each a +//! `HuginnClient` call and nothing else. The declared identity is this +//! process's configuration, not a per-call argument: `EUREKA_INSTANCE` names +//! the mind and `HUGINN_ENDPOINT` (`rudp://host:port`) the daemon that holds +//! it. The server fills in `instance` and `provenance.tool`, so a caller can +//! name neither. +//! +//! Bad configuration does not stop the server: an MCP server that dies at +//! startup vanishes from Claude Code without a word. `whoami` reports it and +//! every other tool refuses with it. A refusal from the mind is an answer and +//! comes back as a normal result; a daemon that cannot be reached or that +//! rejects the envelope, or a request too large for one send, is a result with +//! `isError: true`, typed, so a caller can tell a down daemon from a permanent +//! mismatch. Arguments that do not fit a tool's input are typed the same way +//! (`InvalidInput`), as a tool error and not a protocol one. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use cultnet_rs::Selection; +use eureka_state::{ClientError, HuginnClient}; +use huginn_mind::epiphany_pipeline::{PipelineDocument, PipelineRef, Short, Slug}; +use huginn_mind::{ + Faculty, HuginnMindRequest, HuginnMindResponse, PipelineAdmissionBatch, PipelineProvenance, SemanticQuery, +}; +use rmcp::handler::server::router::tool::ToolRouter; +use rmcp::handler::server::tool::ToolCallContext; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{CallToolRequestParams, CallToolResult, ContentBlock, ServerCapabilities, ServerInfo}; +use rmcp::service::RequestContext; +use rmcp::{ErrorData, RoleServer, ServerHandler, ServiceExt, tool, tool_handler, tool_router}; +use schemars::{JsonSchema, Schema, SchemaGenerator}; +use serde::Deserialize; +use serde_json::{Value, json}; + +/// The `provenance.tool` every admission from this server carries. +const TOOL: &str = "eureka-state"; +/// How long one call may take, connection to last byte. +const TIMEOUT: Duration = Duration::from_secs(15); + +/// CultLib's published shape of a selection, vendored because the selection +/// type carries no schema of its own. `selection_schema_matches_cultlib` pins +/// it to the CultLib revision `cultnet-rs` is built from. +const SELECTION_SCHEMA: &str = include_str!("../schemas/cultnet.selection.schema.json"); + +/// The selection's schema, self-contained: its own `$defs` move into the +/// tool's, so every `$ref` in the input schema is `#/$defs/...`. +fn selection_schema(generator: &mut SchemaGenerator) -> Schema { + let Ok(Value::Object(mut selection)) = serde_json::from_str(SELECTION_SCHEMA) else { + unreachable!("the vendored selection schema is a JSON object"); + }; + selection.remove("$schema"); + selection.remove("$id"); + if let Some(Value::Object(defs)) = selection.remove("$defs") { + generator.definitions_mut().extend(defs); + } + Schema::try_from(Value::Object(selection)).expect("an object is a schema") +} + +#[derive(Deserialize, JsonSchema)] +struct AdmitInput { + /// The faculty the batch is admitted as. Attribution, not authority. + faculty: Faculty, + /// The admitting agent's name. + agent: Short, + /// The admitting session, recorded on the receipt so that several sessions + /// sharing one mind stay tellable apart. + session: Short, + /// The documents to admit as one batch. The mind admits them whole or refuses them. + documents: Vec, +} + +#[derive(Deserialize, JsonSchema)] +struct ViewInput { + /// The document's id, as a query or an admission returned it. + id: PipelineRef, +} + +#[derive(Deserialize, JsonSchema)] +struct QueryInput { + /// A CultNet typed selection over the mind's documents. + #[schemars(schema_with = "selection_schema")] + selection: Selection, + /// Optional: rank by nearness to this text instead of ordering by key. + semantic: Option, +} + +/// Why a call produced no answer. Each is a typed body with `isError: true`. +enum Trouble { + Misconfigured(String), + Client(ClientError), + Internal(String), +} + +impl Trouble { + fn body(&self) -> Value { + match self { + Self::Misconfigured(detail) => json!({ "error": "Misconfigured", "detail": detail }), + Self::Client(ClientError::Unavailable { endpoint, detail }) => { + json!({ "error": "Unavailable", "endpoint": format!("rudp://{endpoint}"), "detail": detail }) + } + Self::Client(ClientError::Rejected { endpoint, code, detail }) => { + json!({ "error": "Rejected", "endpoint": format!("rudp://{endpoint}"), "code": code, "detail": detail }) + } + Self::Client(ClientError::TooLarge { bytes, limit }) => { + json!({ "error": "TooLarge", "bytes": bytes, "limit": limit }) + } + Self::Client(ClientError::Unencodable { detail }) => json!({ "error": "Unencodable", "detail": detail }), + Self::Internal(detail) => json!({ "error": "Internal", "detail": detail }), + } + } +} + +/// The two environment values as given, and the client they make when both are +/// valid. +struct Settings { + instance: Option, + endpoint: Option, + client: Result, +} + +impl Settings { + fn from_env() -> Self { + let instance = variable("EUREKA_INSTANCE"); + let endpoint = variable("HUGINN_ENDPOINT"); + let shown = |value: &Result, String>| value.as_ref().ok().cloned().flatten(); + let (shown_instance, shown_endpoint) = (shown(&instance), shown(&endpoint)); + Self { instance: shown_instance, endpoint: shown_endpoint, client: client(instance, endpoint) } + } +} + +/// An environment variable: its value, `None` when unset, or why it cannot be read. +fn variable(name: &str) -> Result, String> { + match std::env::var(name) { + Ok(value) => Ok(Some(value)), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} is not valid UTF-8")), + } +} + +fn client(instance: Result, String>, endpoint: Result, String>) -> Result { + let instance = instance?.ok_or("EUREKA_INSTANCE is not set")?; + let slug = Slug(instance); + slug.validate_slug().map_err(|refusal| format!("EUREKA_INSTANCE is not a valid instance name: {refusal:?}"))?; + let endpoint = endpoint?.ok_or("HUGINN_ENDPOINT is not set")?; + let address = endpoint + .strip_prefix("rudp://") + .and_then(|address| address.parse::().ok()) + .ok_or_else(|| format!("HUGINN_ENDPOINT is not rudp://:: {endpoint}"))?; + Ok(HuginnClient::new(address, slug, TIMEOUT)) +} + +#[derive(Clone)] +struct EurekaState { + settings: Arc, + tool_router: ToolRouter, +} + +impl EurekaState { + fn new(settings: Settings) -> Self { + Self { settings: Arc::new(settings), tool_router: Self::tool_router() } + } + + /// One wire call for the configured instance. The request is built from + /// that instance, never from anything the caller sent. + async fn call(&self, request: impl FnOnce(Slug) -> HuginnMindRequest) -> Result { + let client = self.settings.client.as_ref().map_err(|detail| Trouble::Misconfigured(detail.clone()))?.clone(); + let request = request(client.instance().clone()); + tokio::task::spawn_blocking(move || client.call(request)) + .await + .map_err(|error| Trouble::Internal(error.to_string()))? + .map_err(Trouble::Client) + } + + async fn answer(&self, request: impl FnOnce(Slug) -> HuginnMindRequest) -> CallToolResult { + match self.call(request).await { + Ok(response) => reply(payload(response), false), + Err(trouble) => reply(trouble.body(), true), + } + } +} + +/// The answer as the tool's output: the operation's own payload, or the typed +/// refusal, tagged, when the mind refused the request. +fn payload(response: HuginnMindResponse) -> Value { + let value = match response { + HuginnMindResponse::Whoami(status) => serde_json::to_value(status), + HuginnMindResponse::Admit(outcome) => serde_json::to_value(outcome), + HuginnMindResponse::View(view) => serde_json::to_value(view), + HuginnMindResponse::Query(page) => serde_json::to_value(page), + other => serde_json::to_value(other), + }; + value.expect("wire types serialize") +} + +fn reply(body: Value, is_error: bool) -> CallToolResult { + let content = vec![ContentBlock::text(body.to_string())]; + if is_error { CallToolResult::error(content) } else { CallToolResult::success(content) } +} + +#[tool_router] +impl EurekaState { + #[tool( + description = "Who this server acts for and whether the Huginn daemon answers: the configured instance and endpoint, reachable, and the mind's status when it does. Reports bad configuration instead of failing." + )] + async fn whoami(&self) -> CallToolResult { + let mut out = json!({ + "instance": self.settings.instance, + "endpoint": self.settings.endpoint, + "reachable": false, + "status": null, + }); + match self.call(|_| HuginnMindRequest::Whoami).await { + Ok(HuginnMindResponse::Whoami(status)) => { + out["reachable"] = json!(true); + out["status"] = serde_json::to_value(status).expect("wire types serialize"); + } + Ok(other) => out["error"] = json!({ "error": "Internal", "detail": format!("unexpected answer: {other:?}") }), + Err(trouble) => out["error"] = trouble.body(), + } + reply(out, false) + } + + #[tool( + description = "Admit one batch of pipeline documents into the mind, whole or not at all. Returns the outcome: committed with a receipt, already admitted, conflict, or a typed refusal. The instance is this server's configuration; do not pass one." + )] + async fn admit(&self, Parameters(input): Parameters) -> CallToolResult { + self.answer(|instance| { + HuginnMindRequest::Admit(PipelineAdmissionBatch { + instance, + provenance: PipelineProvenance { + faculty: input.faculty, + agent: input.agent, + session: input.session, + tool: Short(TOOL.into()), + }, + documents: input.documents, + }) + }) + .await + } + + #[tool(description = "Read one document by id, with its admission facts and status. null when the mind has no such document.")] + async fn view(&self, Parameters(input): Parameters) -> CallToolResult { + self.answer(|instance| HuginnMindRequest::View { instance, id: input.id }).await + } + + #[tool( + description = "Query the mind with a CultNet typed selection, optionally ranked by semantic nearness. Returns one page; pass its cursor back in the selection for the next." + )] + async fn query(&self, Parameters(input): Parameters) -> CallToolResult { + self.answer(|instance| HuginnMindRequest::Query { + instance, + selection: input.selection, + semantic: input.semantic, + }) + .await + } +} + +/// Every tool error is a typed JSON body. The ones this server writes already +/// are; the one rmcp writes, for arguments that do not deserialize into the +/// tool's input, is plain text, so it is put into the same shape. +fn typed_error(result: CallToolResult) -> CallToolResult { + if result.is_error != Some(true) { + return result; + } + let Some(text) = result.content.first().and_then(ContentBlock::as_text).map(|text| text.text.as_str()) else { + return result; + }; + if serde_json::from_str::(text).is_ok_and(|body| body.is_object()) { + return result; + } + reply(json!({ "error": "InvalidInput", "detail": text }), true) +} + +#[tool_handler] +impl ServerHandler for EurekaState { + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + Ok(typed_error(self.tool_router.call(ToolCallContext::new(self, request, context)).await?)) + } + + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions( + "Huginn's typed mind for this instance: whoami, admit, view, query. Refusals are answers; isError means the daemon could not be reached or rejected the request.", + ) + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let server = EurekaState::new(Settings::from_env()).serve(rmcp::transport::stdio()).await?; + server.waiting().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn body_of(result: &CallToolResult) -> (bool, String) { + (result.is_error == Some(true), result.content[0].as_text().unwrap().text.clone()) + } + + /// Reaching `Internal` for real takes a panic in a blocking task, so its + /// body is pinned where it is built. + #[test] + fn every_trouble_has_its_typed_body() { + assert_eq!(Trouble::Internal("task panicked".into()).body(), json!({ "error": "Internal", "detail": "task panicked" })); + assert_eq!( + Trouble::Client(ClientError::TooLarge { bytes: 70_000, limit: 65_000 }).body(), + json!({ "error": "TooLarge", "bytes": 70_000, "limit": 65_000 }) + ); + assert_eq!( + Trouble::Client(ClientError::Unencodable { detail: "no".into() }).body(), + json!({ "error": "Unencodable", "detail": "no" }) + ); + assert_eq!(Trouble::Misconfigured("bad".into()).body(), json!({ "error": "Misconfigured", "detail": "bad" })); + } + + /// Only the text rmcp writes is rewritten: our own typed bodies and every + /// success pass through untouched. + #[test] + fn typed_error_rewrites_only_rmcp_plain_text() { + let plain = CallToolResult::error(vec![ContentBlock::text("failed to deserialize parameters: missing field `faculty`")]); + let (is_error, text) = body_of(&typed_error(plain)); + assert!(is_error); + assert_eq!( + serde_json::from_str::(&text).unwrap(), + json!({ "error": "InvalidInput", "detail": "failed to deserialize parameters: missing field `faculty`" }) + ); + + let typed = reply(json!({ "error": "Unavailable", "detail": "down" }), true); + assert_eq!(body_of(&typed_error(typed.clone())), body_of(&typed)); + + let success = CallToolResult::success(vec![ContentBlock::text("not json, and fine")]); + assert_eq!(body_of(&typed_error(success)), (false, "not json, and fine".to_string())); + + let quoted = CallToolResult::error(vec![ContentBlock::text("[1, 2]")]); + assert_eq!(serde_json::from_str::(&body_of(&typed_error(quoted)).1).unwrap()["error"], json!("InvalidInput")); + } +} diff --git a/crates/eureka-state/tests/client.rs b/crates/eureka-state/tests/client.rs new file mode 100644 index 0000000..3f34512 --- /dev/null +++ b/crates/eureka-state/tests/client.rs @@ -0,0 +1,510 @@ +//! The client against a real daemon, in-process on a loopback port over a +//! temporary mind, and against scripted servers where the daemon cannot be +//! made to misbehave. + +use std::net::{SocketAddr, UdpSocket}; +use std::sync::atomic::Ordering; +use std::time::{Duration, Instant}; + +use chrono::Utc; +use cultnet_rs::{ + CULTNET_OPERATION_CONNECTION_ID, CultMesh, CultMeshRudpSocketOptions, CultNetMessage, CultNetWireContract, Selection, + answer_content_chunk_request, encode_cultnet_message_to_vec, pack_content, +}; +use eureka_state::{ClientError, HuginnClient, MAX_REQUEST_BYTES}; +use huginn_daemon::serve::MAX_RESPONSE_BYTES; +use huginn_mind::envelope::{OperationFailure, encode_failure, encode_request, encode_response}; +use huginn_mind::epiphany_pipeline::{ + AuthorityMap, CodeLocation, CutDelete, CutVerification, FileChange, Line, NegativeCheck, OrgRepo, + PipelineCutSpec, PipelineDocument, PipelineKind, PipelineRef, Short, StructuralDelta, Title, + VerificationTest, +}; +use huginn_mind::{DeferredAnswer, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionOutcome}; + +mod common; +use common::*; + +const OTHER: &str = "thought-cage"; +const TIMEOUT: Duration = Duration::from_secs(20); + +/// A `Line` and a `Short` at the leaf's own bound. +fn line(n: usize) -> Line { + Line(format!("{n:0>1000}")) +} + +fn short(n: usize) -> Short { + Short(format!("{n:0>200}")) +} + +/// A cut spec with every list the leaf bounds filled: at 256 file changes a +/// megabyte of field content, more than one send carries. +fn cut_spec(cut: &str, file_changes: usize) -> PipelineDocument { + let lines = |count: usize| (0..count).map(line).collect::>(); + let shorts = |count: usize| (0..count).map(short).collect::>(); + PipelineDocument::CutSpec(PipelineCutSpec { + campaign: slug(CAMPAIGN), + cut: cut.into(), + revision: 1, + title: Title(short(0).0), + repo: OrgRepo(REPO.into()), + branch: short(1), + base: sha(), + depends_on: vec![], + first: lines(16), + deletes: (0..64).map(|n| CutDelete { path: short(n), lines: 9, note: line(n) }).collect(), + keeps_moves: lines(64), + adds: lines(64), + file_changes: (0..file_changes) + .map(|n| FileChange { + location: CodeLocation { path: short(n), line: 1, end_line: Some(9) }, + change: line(n), + }) + .collect(), + authority_map: Some(AuthorityMap { + owner: line(0), + inputs: lines(16), + outputs: lines(16), + derived_state: lines(16), + forbidden_writers: lines(16), + shared_paths: lines(16), + deletion_line: line(1), + }), + verification: CutVerification { + builds: lines(64), + tests: (0..64).map(|n| VerificationTest { name: short(n), pins: line(n) }).collect(), + negative: (0..64).map(|n| NegativeCheck { pattern: short(n), scope: line(n) }).collect(), + operator: lines(64), + }, + estimate: StructuralDelta { + lines_added: 0, + lines_removed: 0, + dependencies_added: shorts(64), + dependencies_removed: shorts(64), + formats_added: shorts(64), + formats_removed: shorts(64), + targets_added: shorts(64), + targets_removed: shorts(64), + }, + rulings: vec![], + questions: vec![], + }) +} + +fn cut_ref(cut: &str) -> PipelineRef { + PipelineRef { kind: PipelineKind::CutSpec, id: Short(format!("{CAMPAIGN}:cut_spec:cut-{cut}.r1")) } +} + +/// serves its chunks faithfully. +fn deferring(body: Vec) -> Server { + deferring_in_chunks(body, 256 * 1024, Duration::ZERO) +} + +/// `deferring`, with `chunk_bytes` per chunk and each chunk answered only after +/// `drip`. +fn deferring_in_chunks(body: Vec, chunk_bytes: usize, drip: Duration) -> Server { + let (manifest, chunks) = pack_content("art", "package", "", "", "", &body, chunk_bytes).expect("a body packs"); + let deferred = encode_response( + "eureka-state-call", + "whoami", + &HuginnMindResponse::Deferred(DeferredAnswer { manifest }), + "scripted", + ) + .unwrap(); + scripted(move |message| match message { + CultNetMessage::OperationRequest { .. } => deferred.clone(), + chunk => { + std::thread::sleep(drip); + answer_content_chunk_request(chunk, |hash| { + chunks.iter().find(|c| c.chunk_hash == hash).map(|c| c.payload.as_slice()) + }) + } + }) +} + +fn client(addr: SocketAddr) -> HuginnClient { + HuginnClient::new(addr, slug(INSTANCE), TIMEOUT) +} + +fn unavailable(error: ClientError) -> (SocketAddr, String) { + match error { + ClientError::Unavailable { endpoint, detail } => (endpoint, detail), + other @ (ClientError::Rejected { .. } | ClientError::TooLarge { .. } | ClientError::Unencodable { .. }) => panic!("expected Unavailable, got {other}"), + } +} + +fn rejected(error: ClientError) -> (SocketAddr, String, String) { + match error { + ClientError::Rejected { endpoint, code, detail } => (endpoint, code, detail), + other @ (ClientError::Unavailable { .. } | ClientError::TooLarge { .. } | ClientError::Unencodable { .. }) => panic!("expected Rejected, got {other}"), + } +} + +fn nothing_found(message_id: &str, operation: &str) -> CultNetMessage { + encode_response(message_id, operation, &HuginnMindResponse::View(None), "scripted").unwrap() +} + +/// Wait for a scripted server to have seen `count` sessions end. +fn ended_within(server: &Server, count: usize, wait: Duration) -> bool { + let until = Instant::now() + wait; + while server.ended.load(Ordering::Relaxed) < count { + if Instant::now() >= until { + return false; + } + std::thread::sleep(Duration::from_millis(10)); + } + true +} + +fn document_query(instance: &str) -> HuginnMindRequest { + HuginnMindRequest::Query { + instance: slug(instance), + selection: Selection { projection: "document".into(), ..Selection::default() }, + semantic: None, + } +} + +/// Every operation the wire has, asked of a live daemon over the socket, and +/// each answered with the mind's own answer. +#[test] +fn every_operation_round_trips_against_a_live_daemon() { + let (root, daemon) = mind(vec![]); + let server = serve(root, daemon); + let client = client(server.addr); + assert_eq!(client.instance(), &slug(INSTANCE), "the client reports the instance it was configured for"); + + let HuginnMindResponse::Whoami(status) = client.call(HuginnMindRequest::Whoami).unwrap() else { + panic!("whoami is answered with a status"); + }; + assert_eq!((status.instance, status.documents), (slug(INSTANCE), 1)); + + let admitted = client.call(HuginnMindRequest::Admit(batch(INSTANCE, vec![stewardship(), campaign()]))).unwrap(); + let HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { writes, .. }) = admitted else { + panic!("the batch is committed, got {admitted:?}"); + }; + assert_eq!(writes.len(), 2); + + let HuginnMindResponse::Query(page) = client.call(document_query(INSTANCE)).unwrap() else { + panic!("a query is answered with a page"); + }; + assert_eq!(page.matched, 3, "the identity and the two admitted documents"); + + let campaign_ref = PipelineRef { kind: PipelineKind::Campaign, id: Short(format!("{CAMPAIGN}:campaign:self")) }; + let viewed = client.call(HuginnMindRequest::View { instance: slug(INSTANCE), id: campaign_ref.clone() }).unwrap(); + let HuginnMindResponse::View(Some(view)) = viewed else { panic!("the campaign is viewable, got {viewed:?}") }; + assert_eq!(view.id, campaign_ref); +} + +/// A refusal is data. A read that names another mind, a write that declares +/// another mind, and a document the mind does not hold each come back as `Ok`. +#[test] +fn a_foreign_instance_is_an_answer_not_an_error() { + let (root, daemon) = mind(vec![]); + let server = serve(root, daemon); + let client = client(server.addr); + let foreign = MindRefusal::ForeignInstance { declared: OTHER.into(), mind: INSTANCE.into() }; + + assert_eq!(client.call(document_query(OTHER)).unwrap(), HuginnMindResponse::Refused(foreign.clone())); + assert_eq!( + client.call(HuginnMindRequest::Admit(batch(OTHER, vec![identity(OTHER)]))).unwrap(), + HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(foreign)), + ); + let absent = PipelineRef { kind: PipelineKind::CutSpec, id: Short(format!("{CAMPAIGN}:cut_spec:cut-none.r1")) }; + assert_eq!( + client.call(HuginnMindRequest::View { instance: slug(INSTANCE), id: absent }).unwrap(), + HuginnMindResponse::View(None), + ); +} + +/// The wide cut spec's answer is over one send and arrives deferred; the +/// narrow one arrives whole. Both come back equal to what the mind gave. +#[test] +fn a_deferred_answer_resolves_to_the_same_page_as_a_direct_one() { + let (root, mut daemon) = + mind(vec![vec![stewardship(), campaign()], vec![cut_spec("wide", 256), cut_spec("fits", 180)]]); + let measured = |request: &HuginnMindRequest, daemon: &mut TestDaemon| { + let answer = answered(daemon.handle(request.clone(), Utc::now())); + let envelope = encode_response("m", request.operation(), &answer, "huginn-eureka").unwrap(); + let size = encode_cultnet_message_to_vec(&envelope, CultNetWireContract::CultNetSchemaV0).unwrap().len(); + (answer, size as u64) + }; + let wide = HuginnMindRequest::View { instance: slug(INSTANCE), id: cut_ref("wide") }; + let fits = HuginnMindRequest::View { instance: slug(INSTANCE), id: cut_ref("fits") }; + let (wide_answer, wide_size) = measured(&wide, &mut daemon); + let (fits_answer, fits_size) = measured(&fits, &mut daemon); + assert!(matches!(wide_answer, HuginnMindResponse::View(Some(_))), "{wide_answer:?}"); + assert!(wide_size > MAX_RESPONSE_BYTES, "the wide answer must be over one send, or nothing was deferred"); + assert!(fits_size <= MAX_RESPONSE_BYTES, "the narrow answer must fit one send"); + + let server = serve(root, daemon); + let client = client(server.addr); + assert_eq!(client.call(wide).unwrap(), wide_answer); + assert_eq!(client.call(fits).unwrap(), fits_answer); +} + +/// Nothing listens on the port. The call fails naming the endpoint, and it +/// fails when the configured timeout says: two timeouts, two waits. +#[test] +fn a_closed_port_is_unavailable_naming_the_endpoint_within_the_timeout() { + let closed = UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap(); + let mut waited = Vec::new(); + for timeout in [Duration::from_millis(300), Duration::from_millis(1200)] { + let started = Instant::now(); + let error = HuginnClient::new(closed, slug(INSTANCE), timeout).call(HuginnMindRequest::Whoami).unwrap_err(); + let elapsed = started.elapsed(); + assert!(elapsed >= timeout, "gave up after {elapsed:?}, before its {timeout:?}"); + assert!(elapsed < timeout + Duration::from_millis(800), "gave up after {elapsed:?}, long past its {timeout:?}"); + assert!(error.to_string().contains(&closed.to_string()), "{error}"); + let (endpoint, detail) = unavailable(error); + assert_eq!(endpoint, closed); + assert!(detail.contains("no accept") && detail.contains("deadline"), "{detail}"); + waited.push(elapsed); + } + assert!(waited[1] > waited[0] + Duration::from_millis(500), "{waited:?}"); +} + +/// A daemon that accepts and then says nothing is a timeout on the answer, +/// not on the connection. +#[test] +fn a_daemon_that_never_answers_is_unavailable_within_the_timeout() { + let server = scripted(|_| { + std::thread::sleep(Duration::from_secs(3)); + CultNetMessage::Error { error: "late".into(), code: None, details: None } + }); + let started = Instant::now(); + let error = HuginnClient::new(server.addr, slug(INSTANCE), Duration::from_millis(400)) + .call(HuginnMindRequest::Whoami) + .unwrap_err(); + let elapsed = started.elapsed(); + assert!(elapsed >= Duration::from_millis(400) && elapsed < Duration::from_secs(2), "{elapsed:?}"); + let (endpoint, detail) = unavailable(error); + assert_eq!(endpoint, server.addr); + assert!(detail.contains("no answer"), "{detail}"); +} + +/// An envelope failure means the request never reached a mind. It is the +/// daemon refusing, not a transport failure, so a caller that retries on +/// `Unavailable` does not loop on it; the code and the message arrive intact. +#[test] +fn an_envelope_failure_is_rejected_and_carries_its_code() { + let server = answering(encode_failure( + "eureka-state-call", + "whoami", + &OperationFailure { code: "wrong-service".into(), message: "not huginn.mind".into() }, + "scripted", + )); + let error = client(server.addr).call(HuginnMindRequest::Whoami).unwrap_err(); + assert!(error.to_string().contains("wrong-service"), "{error}"); + let (endpoint, code, detail) = rejected(error); + assert_eq!((endpoint, code.as_str(), detail.as_str()), (server.addr, "wrong-service", "not huginn.mind")); +} + +/// An answer to another request, or to another operation, is not this call's +/// answer, whatever it says. +#[test] +fn an_answer_that_is_not_to_this_request_is_refused() { + let foreign = answering(nothing_found("someone-elses-call", "whoami")); + let (_, detail) = unavailable(client(foreign.addr).call(HuginnMindRequest::Whoami).unwrap_err()); + assert!(detail.contains("someone-elses-call"), "{detail}"); + + let other_operation = answering(nothing_found("eureka-state-call", "query")); + let (_, detail) = unavailable(client(other_operation.addr).call(HuginnMindRequest::Whoami).unwrap_err()); + assert!(detail.contains("query") && detail.contains("whoami"), "{detail}"); + + let matching = answering(nothing_found("eureka-state-call", "whoami")); + assert_eq!(client(matching.addr).call(HuginnMindRequest::Whoami).unwrap(), HuginnMindResponse::View(None)); +} + +/// The timeout bounds the whole call. Seven chunks each answered after 300 ms +/// take two seconds; no single wait exceeds the 500 ms timeout, and the call +/// still gives up at it. +#[test] +fn the_timeout_bounds_the_whole_call_not_each_wait() { + let server = deferring_in_chunks(vec![7_u8; 7 * 1024], 1024, Duration::from_millis(300)); + let timeout = Duration::from_millis(500); + let started = Instant::now(); + let error = HuginnClient::new(server.addr, slug(INSTANCE), timeout).call(HuginnMindRequest::Whoami).unwrap_err(); + let elapsed = started.elapsed(); + assert!(elapsed >= timeout && elapsed < Duration::from_millis(1100), "{elapsed:?}"); + let (_, detail) = unavailable(error); + assert!(detail.contains("deadline"), "{detail}"); +} + +/// Every call ends its session, whether it answered, was rejected, or gave up. +#[test] +fn every_call_disconnects_its_session() { + let answered = answering(nothing_found("eureka-state-call", "whoami")); + client(answered.addr).call(HuginnMindRequest::Whoami).unwrap(); + assert!(ended_within(&answered, 1, Duration::from_secs(5)), "an answered call left its session open"); + + let refused = answering(encode_failure( + "eureka-state-call", + "whoami", + &OperationFailure { code: "wrong-service".into(), message: "no".into() }, + "scripted", + )); + rejected(client(refused.addr).call(HuginnMindRequest::Whoami).unwrap_err()); + assert!(ended_within(&refused, 1, Duration::from_secs(5)), "a rejected call left its session open"); + + let silent = scripted(|_| { + std::thread::sleep(Duration::from_millis(800)); + CultNetMessage::Error { error: "late".into(), code: None, details: None } + }); + let error = HuginnClient::new(silent.addr, slug(INSTANCE), Duration::from_millis(300)) + .call(HuginnMindRequest::Whoami) + .unwrap_err(); + unavailable(error); + assert!(ended_within(&silent, 1, Duration::from_secs(5)), "a timed-out call left its session open"); +} + +/// A deferred body that is not a response, and one that is another deferral, +/// are protocol violations named as such. +#[test] +fn a_deferred_body_must_be_a_response_and_not_another_deferral() { + let garbage = deferring(vec![0xc1; 40_000]); + let (_, detail) = unavailable(client(garbage.addr).call(HuginnMindRequest::Whoami).unwrap_err()); + assert!(detail.contains("not a response"), "{detail}"); + + let (inner, _) = pack_content("inner", "package", "", "", "", b"x", 16).unwrap(); + let again = rmp_serde::to_vec_named(&HuginnMindResponse::Deferred(DeferredAnswer { manifest: inner })).unwrap(); + let nested = deferring(again); + let (_, detail) = unavailable(client(nested.addr).call(HuginnMindRequest::Whoami).unwrap_err()); + assert!(detail.contains("itself deferred"), "{detail}"); +} + +/// A body over the deferral cap is refused before its first chunk is asked +/// for, by the number the daemon defers under. +#[test] +fn a_body_over_the_deferral_cap_is_refused() { + let over = huginn_mind::MAX_DEFERRED_BODY_BYTES as usize + 1; + let server = deferring(vec![0_u8; over]); + let (_, detail) = unavailable(client(server.addr).call(HuginnMindRequest::Whoami).unwrap_err()); + assert!(detail.contains("exceeds"), "{detail}"); +} + +/// An admission whose session is `session_pad` characters long: the knob that +/// sizes a request without changing what it asks. +fn padded_admit(session_pad: usize) -> HuginnMindRequest { + let mut request = batch(INSTANCE, vec![stewardship()]); + request.provenance.session = Short("s".repeat(session_pad)); + HuginnMindRequest::Admit(request) +} + +/// What one send has to carry for `message`, measured the way the client does. +fn encoded_bytes(message: &CultNetMessage) -> usize { + encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap().len() +} + +fn message_for(request: &HuginnMindRequest, source: Option) -> CultNetMessage { + encode_request("eureka-state-call", request, source).unwrap() +} + +/// The smallest session padding whose request encodes to at least `target` +/// bytes. The payload rides as base64, so request sizes step by four and only +/// every fourth size is reachable through the request alone. +fn pad_for(target: usize) -> usize { + let (mut low, mut high) = (0, target); + while low < high { + let middle = (low + high) / 2; + if encoded_bytes(&message_for(&padded_admit(middle), None)) < target { + low = middle + 1; + } else { + high = middle; + } + } + low +} + +/// The largest request one send carries, and the smallest it does not. +fn largest_request_that_fits() -> HuginnMindRequest { + padded_admit(pad_for(MAX_REQUEST_BYTES + 1) - 1) +} + +fn smallest_request_over() -> HuginnMindRequest { + padded_admit(pad_for(MAX_REQUEST_BYTES + 1)) +} + +/// A message that encodes to exactly `target` bytes: a request a little under +/// it, topped up with a source runtime id, which rides outside the payload and +/// moves the size a byte at a time once the field is there at all. +fn message_of_size(target: usize) -> CultNetMessage { + let fitting = pad_for(target + 1) - 1; + for padding in (fitting.saturating_sub(80)..=fitting).rev() { + let request = padded_admit(padding); + for source in 1..80 { + let message = message_for(&request, Some("r".repeat(source))); + if encoded_bytes(&message) == target { + return message; + } + } + } + panic!("no message encodes to {target} bytes"); +} + +fn admitted_answer() -> CultNetMessage { + encode_response( + "eureka-state-call", + "admit", + &HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: "r".into() }), + "scripted", + ) + .unwrap() +} + +/// A request larger than one send carries is refused by the client before it +/// connects to anything, as its own error and not as a transport failure the +/// caller would retry; the largest request that fits is sent, and answered. +#[test] +fn a_request_over_what_one_send_carries_is_too_large_and_one_that_fits_is_sent() { + let closed = UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap(); + let over = smallest_request_over(); + let started = Instant::now(); + match client(closed).call(over.clone()) { + Err(ClientError::TooLarge { bytes, limit }) => { + assert_eq!(bytes, encoded_bytes(&message_for(&over, None))); + assert_eq!(limit, MAX_REQUEST_BYTES); + assert!(bytes > limit); + } + other => panic!("expected TooLarge, got {other:?}"), + } + assert!(started.elapsed() < Duration::from_secs(1), "refused only after trying the transport"); + + let fits = largest_request_that_fits(); + assert!(MAX_REQUEST_BYTES - encoded_bytes(&message_for(&fits, None)) < 4, "not the largest that fits"); + let server = answering(admitted_answer()); + let answer = client(server.addr).call(fits); + assert!(matches!(answer, Ok(HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { .. }))), "{answer:?}"); +} + +/// The transport's own refusal of an oversize send ("Message too long") is +/// what an unchecked request used to report, as an `Unavailable` daemon a +/// caller would retry forever. +#[test] +fn an_oversize_admission_is_never_reported_as_an_unavailable_daemon() { + let server = answering(admitted_answer()); + let error = client(server.addr).call(padded_admit(70_000)).unwrap_err(); + assert!(matches!(error, ClientError::TooLarge { .. }), "{error}"); +} + +/// The limit is the transport's own: a message of `MAX_REQUEST_BYTES` goes out +/// on an RUDP session, and one byte more is refused by the socket. +#[test] +fn the_limit_is_the_largest_message_the_transport_sends() { + let server = answering(admitted_answer()); + let mut session = CultMesh::create_rudp_client_for_endpoint( + "limit-probe".to_string(), + CULTNET_OPERATION_CONNECTION_ID, + &format!("rudp://{}", server.addr), + CultMeshRudpSocketOptions::default(), + ) + .unwrap(); + session.connect(Vec::new()).unwrap(); + let until = Instant::now() + Duration::from_secs(5); + while !session.connected() { + session.poll_resends().unwrap(); + let _ = session.receive_once().unwrap(); + assert!(Instant::now() < until, "the server never accepted"); + std::thread::sleep(Duration::from_millis(2)); + } + session.send_schema_message(&message_of_size(MAX_REQUEST_BYTES)).expect("the limit is carried"); + assert!(session.send_schema_message(&message_of_size(MAX_REQUEST_BYTES + 1)).is_err(), "one byte more is sent"); +} diff --git a/crates/eureka-state/tests/common/mod.rs b/crates/eureka-state/tests/common/mod.rs new file mode 100644 index 0000000..27a0705 --- /dev/null +++ b/crates/eureka-state/tests/common/mod.rs @@ -0,0 +1,185 @@ +//! The daemon half of the fixtures: an in-process Huginn daemon over a +//! temporary mind, on a loopback port, shared by the client and the server +//! tests. +#![allow(dead_code)] + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::thread::JoinHandle; +use std::time::Duration; + +use chrono::Utc; +use cultnet_rs::{CultNetMessage, CultNetRudpServerEvent, CultNetWireContract, decode_cultnet_message_from_slice}; +use huginn_daemon::serve::{bind, run, schema_registry}; +use huginn_daemon::{Daemon, Handled, Hits, IndexSink, SearchTicket, ServeOptions}; +use huginn_mind::epiphany_pipeline::{ + Date, DocRef, OrgRepo, PipelineCampaign, PipelineDocument, PipelineInstance, PipelineRef, PipelineStewardship, Sha, Short, Slug, + Title, +}; +use huginn_mind::{ + Faculty, HuginnMindRequest, HuginnMindResponse, IndexStatus, Mind, MindStore, OwnedRedbMessagePackBackingStore, + PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelineProvenance, +}; +use tempfile::TempDir; + +pub const INSTANCE: &str = "eureka"; + +pub fn slug(value: &str) -> Slug { + Slug(value.into()) +} + +pub fn identity(name: &str) -> PipelineDocument { + PipelineDocument::Instance(PipelineInstance { + instance: slug(name), + display_name: Short(format!("{name} mind")), + created_at: Date("2026-09-29".into()), + host: Short(name.into()), + }) +} + +pub fn batch(instance: &str, documents: Vec) -> PipelineAdmissionBatch { + PipelineAdmissionBatch { + instance: slug(instance), + provenance: PipelineProvenance { + faculty: Faculty::Hands, + agent: Short("claude".into()), + session: Short("session-1".into()), + tool: Short("admit".into()), + }, + documents, + } +} + +/// No index: the client's subject is the transport, not the projection. +pub struct Inert; + +impl IndexSink for Inert { + fn committed(&mut self, _mind: &Mind, _writes: &[PipelineRef]) -> anyhow::Result<()> { + Ok(()) + } + fn status(&self) -> IndexStatus { + IndexStatus::Current + } + fn search(&mut self, _text: &str, _top_k: u32) -> Result { + Err("this index cannot search".into()) + } + fn search_deadline(&self) -> Duration { + Duration::ZERO + } + fn searched(&mut self) -> Vec<(SearchTicket, Result)> { + Vec::new() + } + fn abandon(&mut self, _ticket: SearchTicket) {} +} + +pub type TestDaemon = Daemon; + +pub fn answered(handled: Handled) -> HuginnMindResponse { + match handled { + Handled::Answered(response) => response, + Handled::Searching(_) => panic!("the inert index cannot search"), + } +} + +/// A daemon over a temporary mind whose identity is admitted, and whatever +/// else `seed` admits. +pub fn mind(seed: Vec>) -> (TempDir, TestDaemon) { + let root = tempfile::tempdir().unwrap(); + let mut daemon = Daemon::new(Mind::open(root.path(), &slug(INSTANCE)).unwrap(), Inert); + for documents in std::iter::once(vec![identity(INSTANCE)]).chain(seed) { + let outcome = answered(daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, documents)), Utc::now())); + assert!(matches!(outcome, HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { .. })), "{outcome:?}"); + } + (root, daemon) +} + +/// A server on a loopback port that stops when dropped. +pub struct Server { + pub addr: SocketAddr, + pub stopping: Arc, + pub thread: Option>, + /// The sessions a scripted server has seen end: what the client's + /// disconnect looks like from the daemon's side. + pub ended: Arc, + pub _root: Option, +} + +impl Drop for Server { + fn drop(&mut self) { + self.stopping.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +pub fn serve(root: TempDir, mut daemon: TestDaemon) -> Server { + let mut hub = bind("127.0.0.1:0".parse().unwrap(), &daemon.runtime_id()).unwrap(); + let addr = hub.local_addr().unwrap(); + let stopping = Arc::new(AtomicBool::new(false)); + let loop_stopping = Arc::clone(&stopping); + let registry = schema_registry().unwrap(); + let thread = std::thread::spawn(move || { + run(&mut daemon, &mut hub, ®istry, &loop_stopping, &ServeOptions::default()).unwrap(); + }); + Server { addr, stopping, thread: Some(thread), ended: Arc::default(), _root: Some(root) } +} + +/// A server that answers each schema message with whatever `script` says. +pub fn scripted(mut script: impl FnMut(&CultNetMessage) -> CultNetMessage + Send + 'static) -> Server { + let mut hub = bind("127.0.0.1:0".parse().unwrap(), "scripted").unwrap(); + let addr = hub.local_addr().unwrap(); + let stopping = Arc::new(AtomicBool::new(false)); + let loop_stopping = Arc::clone(&stopping); + let ended = Arc::new(AtomicUsize::new(0)); + let loop_ended = Arc::clone(&ended); + let thread = std::thread::spawn(move || { + while !loop_stopping.load(Ordering::Relaxed) { + hub.poll_resends().unwrap(); + while let Some(event) = hub.receive_event_once().unwrap() { + if matches!(event, CultNetRudpServerEvent::Disconnected { .. }) { + loop_ended.fetch_add(1, Ordering::Relaxed); + } + let CultNetRudpServerEvent::Frame { session, frame } = event else { continue }; + let message = + decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0).unwrap(); + hub.send_schema_message(&session, &script(&message)).unwrap(); + } + std::thread::sleep(Duration::from_millis(2)); + } + }); + Server { addr, stopping, thread: Some(thread), ended, _root: None } +} + +/// A scripted server whose every answer is `reply`. +pub fn answering(reply: CultNetMessage) -> Server { + scripted(move |_| reply.clone()) +} + +pub const CAMPAIGN: &str = "eureka-state"; +pub const REPO: &str = "GameCult/Epiphany"; + +pub fn sha() -> Sha { + Sha("5f98228d".into()) +} + +pub fn campaign() -> PipelineDocument { + PipelineDocument::Campaign(PipelineCampaign { + slug: slug(CAMPAIGN), + title: Title("Eureka pipeline state".into()), + repos: vec![OrgRepo(REPO.into())], + working_branch: Short("hands/cut13a".into()), + target_doc: DocRef { path: Short("notes/target.md".into()), start_line: 1, end_line: 9, commit: sha() }, + }) +} + +pub fn stewardship() -> PipelineDocument { + PipelineDocument::Stewardship(PipelineStewardship { + instance: slug(INSTANCE), + repo: OrgRepo(REPO.into()), + sequence: 1, + assigned_on: Date("2026-09-29".into()), + note: "assigned".into(), + }) +} diff --git a/crates/eureka-state/tests/mcp.rs b/crates/eureka-state/tests/mcp.rs new file mode 100644 index 0000000..27ee9fd --- /dev/null +++ b/crates/eureka-state/tests/mcp.rs @@ -0,0 +1,532 @@ +//! The `eureka-state` binary over stdio, as Claude Code drives it: real +//! processes, real JSON-RPC, a real daemon in-process behind it (or a scripted +//! server where the daemon cannot be made to misbehave). + +use std::collections::BTreeSet; +use std::io::{BufRead, BufReader, Write}; +use std::net::UdpSocket; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{Receiver, channel}; +use std::time::Duration; + +use huginn_mind::envelope::{OperationFailure, encode_failure, encode_response}; +use huginn_mind::{HuginnMindRequest, HuginnMindResponse}; +use huginn_mind::epiphany_pipeline::{PipelineKind, PipelineRef, Short}; +use serde_json::{Value, json}; + +mod common; +use common::*; + +const WAIT: Duration = Duration::from_secs(60); + +struct Mcp { + child: Child, + stdin: ChildStdin, + lines: Receiver, + next: u64, + /// The server's answer to `initialize`. + init: Value, +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Mcp { + /// The binary with exactly the environment given, initialised. + fn start(env: &[(&str, &str)]) -> Self { + Self::spawn(|command| { + command.envs(env.iter().copied()); + }) + } + + /// The binary with no configuration but what `configure` gives its command. + fn spawn(configure: impl FnOnce(&mut Command)) -> Self { + let mut command = Command::new(env!("CARGO_BIN_EXE_eureka-state")); + command.env_remove("EUREKA_INSTANCE").env_remove("HUGINN_ENDPOINT"); + configure(&mut command); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = command.spawn().expect("the binary starts"); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (sender, lines) = channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + let _ = sender.send(line); + } + }); + let mut mcp = Self { child, stdin, lines, next: 1, init: Value::Null }; + let init = mcp.rpc( + "initialize", + json!({ "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "0" } }), + ); + assert!(init.get("result").is_some(), "{init}"); + mcp.init = init["result"].clone(); + mcp.send(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); + mcp + } + + /// Configured for `instance` against a daemon at `addr`. + fn at(instance: &str, addr: std::net::SocketAddr) -> Self { + Self::start(&[("EUREKA_INSTANCE", instance), ("HUGINN_ENDPOINT", &format!("rudp://{addr}"))]) + } + + fn send(&mut self, message: Value) { + writeln!(self.stdin, "{message}").unwrap(); + self.stdin.flush().unwrap(); + } + + /// One request and its response, whole (`result` or `error`). + fn rpc(&mut self, method: &str, params: Value) -> Value { + let id = self.next; + self.next += 1; + self.send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })); + loop { + let line = self.lines.recv_timeout(WAIT).expect("the server answers"); + let message: Value = serde_json::from_str(&line).expect("stdout carries only JSON-RPC"); + if message["id"] == json!(id) { + return message; + } + } + } + + /// A tool call's result: whether it is an error, and its JSON body. + fn call(&mut self, tool: &str, arguments: Value) -> (bool, Value) { + let response = self.rpc("tools/call", json!({ "name": tool, "arguments": arguments })); + let result = &response["result"]; + assert!(result.is_object(), "a tool result, not a protocol error: {response}"); + let text = result["content"][0]["text"].as_str().expect("text content"); + (result["isError"] == json!(true), serde_json::from_str(text).expect("a JSON body")) + } +} + +/// A loopback UDP port nothing listens on. +fn closed_port() -> std::net::SocketAddr { + UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap() +} + +fn faculty_args(documents: Vec) -> Value { + json!({ "faculty": "Hands", "agent": "claude", "session": "session-7", "documents": documents }) +} + +fn campaign_json() -> Value { + serde_json::to_value(campaign()).unwrap() +} + +/// Every `$ref` in a schema, and the `$defs` it can resolve against. +fn refs(schema: &Value, found: &mut Vec) { + match schema { + Value::Object(map) => { + for (key, value) in map { + if key == "$ref" { + found.push(value.as_str().unwrap().to_string()); + } else { + refs(value, found); + } + } + } + Value::Array(items) => items.iter().for_each(|item| refs(item, found)), + _ => {} + } +} + +/// Every key named `key` anywhere below `schema`'s root. +fn keys_below_root(schema: &Value, key: &str) -> usize { + fn count(value: &Value, key: &str) -> usize { + match value { + Value::Object(map) => map.iter().map(|(name, inner)| usize::from(name == key) + count(inner, key)).sum(), + Value::Array(items) => items.iter().map(|item| count(item, key)).sum(), + _ => 0, + } + } + schema.as_object().unwrap().values().map(|inner| count(inner, key)).sum() +} + +/// Four tools, each input schema a self-contained document: every `$ref` is +/// internal and resolves, and the selection's definitions are bundled in. +#[test] +fn tools_list_offers_four_tools_with_self_contained_schemas() { + let mut mcp = Mcp::at(INSTANCE, closed_port()); + let listed = mcp.rpc("tools/list", json!({})); + let tools = listed["result"]["tools"].as_array().expect("tools"); + let names: BTreeSet<&str> = tools.iter().map(|tool| tool["name"].as_str().unwrap()).collect(); + assert_eq!(names, BTreeSet::from(["whoami", "admit", "view", "query"])); + for tool in tools { + let schema = &tool["inputSchema"]; + let mut found = Vec::new(); + refs(schema, &mut found); + for reference in &found { + let name = reference.strip_prefix("#/$defs/").unwrap_or_else(|| panic!("{}: remote $ref {reference}", tool["name"])); + assert!(schema["$defs"].get(name).is_some(), "{}: {reference} does not resolve", tool["name"]); + } + // The vendored selection schema brings its own `$id` and `$schema`, + // which would make its `$ref`s resolve against a remote document. + assert!(schema.get("$id").is_none(), "{}: a remote identity", tool["name"]); + for key in ["$id", "$schema"] { + assert_eq!(keys_below_root(schema, key), 0, "{}: a nested {key}", tool["name"]); + } + } + let query = tools.iter().find(|tool| tool["name"] == "query").unwrap(); + for bundled in ["fieldPredicate", "citation", "incoming", "recordRef"] { + assert!(query["inputSchema"]["$defs"].get(bundled).is_some(), "{bundled} is not bundled"); + } + assert_eq!(query["inputSchema"]["properties"]["selection"]["properties"]["limit"]["maximum"], json!(200)); +} + +/// The vendored selection schema is CultLib's, at the revision `cultnet-rs` +/// is built from. +#[test] +fn selection_schema_matches_cultlib() { + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); + let metadata = Command::new(cargo) + .args(["metadata", "--format-version", "1", "--offline", "--locked"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .unwrap(); + assert!(metadata.status.success(), "{}", String::from_utf8_lossy(&metadata.stderr)); + let metadata: Value = serde_json::from_slice(&metadata.stdout).unwrap(); + let manifest = metadata["packages"] + .as_array() + .unwrap() + .iter() + .find(|package| package["name"] == "cultnet-rs") + .expect("cultnet-rs is a dependency")["manifest_path"] + .as_str() + .unwrap(); + let crate_dir = std::path::Path::new(manifest).parent().unwrap(); + let published = crate_dir.join("../../contracts/cultnet/cultnet.selection.schema.json"); + let theirs: Value = serde_json::from_str(&std::fs::read_to_string(published).unwrap()).unwrap(); + let ours: Value = serde_json::from_str( + &std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/schemas/cultnet.selection.schema.json")).unwrap(), + ) + .unwrap(); + assert_eq!(ours, theirs); +} + +/// Every tool against a live daemon, and what each returns. +#[test] +fn every_tool_round_trips_against_a_live_daemon() { + let (root, daemon) = mind(vec![vec![stewardship()]]); + let server = serve(root, daemon); + let mut mcp = Mcp::at(INSTANCE, server.addr); + + let (error, whoami) = mcp.call("whoami", json!({})); + assert!(!error); + assert_eq!(whoami["instance"], json!(INSTANCE)); + assert_eq!(whoami["endpoint"], json!(format!("rudp://{}", server.addr))); + assert_eq!(whoami["reachable"], json!(true)); + assert_eq!(whoami["status"]["instance"], json!(INSTANCE)); + assert_eq!(whoami["status"]["documents"], json!(2)); + assert!(whoami.get("error").is_none(), "{whoami}"); + + let (error, admitted) = mcp.call("admit", faculty_args(vec![campaign_json()])); + assert!(!error); + let writes = admitted["Committed"]["writes"].as_array().expect("committed").clone(); + assert_eq!(writes.len(), 1, "{admitted}"); + + let (error, viewed) = mcp.call("view", json!({ "id": writes[0] })); + assert!(!error); + assert_eq!(viewed["id"], writes[0]); + assert_eq!(viewed["admission"]["provenance"]["session"], json!("session-7")); + + let (error, nothing) = mcp.call("view", json!({ "id": absent() })); + assert!(!error); + assert_eq!(nothing, Value::Null); + + let (error, page) = mcp.call("query", json!({ "selection": { "projection": "document" } })); + assert!(!error); + assert_eq!(page["matched"], json!(3), "{page}"); + assert_eq!(page["items"]["Documents"].as_array().unwrap().len(), 3); + + let (_, counted) = mcp.call("whoami", json!({})); + assert_eq!(counted["status"]["receipts"], json!(3)); +} + +/// Declared identity is configuration, not a per-call argument. Input that +/// names another instance, or another tool, changes neither: the batch is +/// admitted for the configured instance, and the receipt says `eureka-state`. +#[test] +fn admit_ignores_any_instance_in_input() { + let (root, daemon) = mind(vec![vec![stewardship()]]); + let server = serve(root, daemon); + let mut mcp = Mcp::at(INSTANCE, server.addr); + + let mut arguments = faculty_args(vec![campaign_json()]); + arguments["instance"] = json!("thought-cage"); + arguments["tool"] = json!("someone-else"); + arguments["provenance"] = json!({ "faculty": "Operator", "agent": "x", "session": "y", "tool": "someone-else" }); + let (error, admitted) = mcp.call("admit", arguments); + assert!(!error); + let writes = admitted["Committed"]["writes"].as_array().unwrap_or_else(|| panic!("not committed: {admitted}")); + + let (_, viewed) = mcp.call("view", json!({ "id": writes[0] })); + let provenance = &viewed["admission"]["provenance"]; + assert_eq!(provenance["tool"], json!("eureka-state")); + assert_eq!(provenance["faculty"], json!("Hands")); + assert_eq!(provenance["agent"], json!("claude")); + assert_eq!(provenance["session"], json!("session-7")); +} + +/// A mind's refusal is an answer: a successful result carrying the typed +/// refusal. Here the configured instance is not the daemon's. +#[test] +fn a_refusal_is_a_successful_result() { + let (root, daemon) = mind(vec![vec![stewardship()]]); + let server = serve(root, daemon); + let mut mcp = Mcp::at("thought-cage", server.addr); + + let (error, viewed) = mcp.call("view", json!({ "id": absent() })); + assert!(!error); + assert!(viewed.get("Refused").is_some(), "{viewed}"); + let (error, queried) = mcp.call("query", json!({ "selection": {} })); + assert!(!error); + assert!(queried.get("Refused").is_some(), "{queried}"); + let (error, admitted) = mcp.call("admit", faculty_args(vec![campaign_json()])); + assert!(!error); + assert!(admitted.get("Refused").is_some(), "{admitted}"); +} + +/// A daemon that cannot be reached is `isError` with the endpoint, and +/// `whoami` reports it as `reachable: false` without a protocol error. +#[test] +fn a_closed_port_is_reported_and_is_not_a_protocol_error() { + let addr = closed_port(); + let mut mcp = Mcp::at(INSTANCE, addr); + + let whoami = mcp.rpc("tools/call", json!({ "name": "whoami", "arguments": {} })); + assert!(whoami.get("error").is_none(), "{whoami}"); + assert_ne!(whoami["result"]["isError"], json!(true)); + let body: Value = serde_json::from_str(whoami["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(body["reachable"], json!(false)); + assert_eq!(body["status"], Value::Null); + assert_eq!(body["error"]["error"], json!("Unavailable")); + + let (error, view) = mcp.call("view", json!({ "id": absent() })); + assert!(error); + assert_eq!(view["error"], json!("Unavailable")); + assert_eq!(view["endpoint"], json!(format!("rudp://{addr}"))); +} + +/// A daemon rejecting the envelope is a permanent mismatch, not a down +/// daemon: `isError` with its own code, distinct from `Unavailable`. +#[test] +fn a_rejected_request_is_a_typed_error_distinct_from_unavailable() { + let server = answering(encode_failure( + "eureka-state-call", + "view", + &OperationFailure { code: "wrong-service".into(), message: "not huginn.mind".into() }, + "scripted", + )); + let mut mcp = Mcp::at(INSTANCE, server.addr); + let (error, body) = mcp.call("view", json!({ "id": absent() })); + assert!(error); + assert_eq!(body["error"], json!("Rejected")); + assert_eq!(body["code"], json!("wrong-service")); + assert_eq!(body["detail"], json!("not huginn.mind")); + assert_eq!(body["endpoint"], json!(format!("rudp://{}", server.addr))); +} + +/// A `whoami` answered with another operation's payload is not a mind's +/// status: it is reported, not called reachable. +#[test] +fn whoami_does_not_take_another_answer_for_a_status() { + let server = answering(encode_response("eureka-state-call", "whoami", &HuginnMindResponse::View(None), "scripted").unwrap()); + let mut mcp = Mcp::at(INSTANCE, server.addr); + let (error, body) = mcp.call("whoami", json!({})); + assert!(!error); + assert_eq!(body["reachable"], json!(false)); + assert_eq!(body["status"], Value::Null); + assert!(body["error"]["detail"].as_str().unwrap().contains("unexpected answer"), "{body}"); +} + +/// Bad configuration does not stop the server. `whoami` says what is wrong and +/// every other tool refuses with the same typed body. +#[test] +fn bad_configuration_is_served_and_reported() { + let cases: [(&[(&str, &str)], &str); 5] = [ + (&[("HUGINN_ENDPOINT", "rudp://127.0.0.1:1")], "EUREKA_INSTANCE is not set"), + (&[("EUREKA_INSTANCE", "Not A Slug"), ("HUGINN_ENDPOINT", "rudp://127.0.0.1:1")], "EUREKA_INSTANCE is not a valid"), + (&[("EUREKA_INSTANCE", "eureka")], "HUGINN_ENDPOINT is not set"), + (&[("EUREKA_INSTANCE", "eureka"), ("HUGINN_ENDPOINT", "127.0.0.1:1")], "HUGINN_ENDPOINT is not rudp://"), + (&[("EUREKA_INSTANCE", "eureka"), ("HUGINN_ENDPOINT", "rudp://not-an-address")], "HUGINN_ENDPOINT is not rudp://"), + ]; + for (env, expected) in cases { + let mut mcp = Mcp::start(env); + let (error, whoami) = mcp.call("whoami", json!({})); + assert!(!error, "{expected}"); + assert_eq!(whoami["reachable"], json!(false), "{expected}"); + assert_eq!(whoami["error"]["error"], json!("Misconfigured"), "{expected}"); + assert!(whoami["error"]["detail"].as_str().unwrap().contains(expected), "{expected}: {whoami}"); + for (tool, arguments) in [ + ("view", json!({ "id": absent() })), + ("query", json!({ "selection": {} })), + ("admit", faculty_args(vec![])), + ] { + let (error, refusal) = mcp.call(tool, arguments); + assert!(error, "{tool}: {expected}"); + assert_eq!(refusal["error"], json!("Misconfigured"), "{tool}"); + } + } +} + +/// Input that does not fit the tool's schema is refused before any call is +/// made. rmcp 2.2 reports it as a tool result with `isError`, not as a +/// JSON-RPC `invalid_params`, so the model sees what to correct. The server +/// keeps serving. +#[test] +fn malformed_input_is_refused_before_any_call() { + let server = answering(encode_response("eureka-state-call", "admit", &HuginnMindResponse::View(None), "scripted").unwrap()); + let mut mcp = Mcp::at(INSTANCE, server.addr); + let response = mcp.rpc("tools/call", json!({ "name": "admit", "arguments": { "faculty": "Hands" } })); + assert!(response.get("error").is_none(), "{response}"); + assert_eq!(response["result"]["isError"], json!(true)); + let body: Value = serde_json::from_str(response["result"]["content"][0]["text"].as_str().unwrap()).expect("a JSON body"); + assert_eq!(body["error"], json!("InvalidInput"), "{body}"); + assert!(body["detail"].as_str().unwrap().contains("missing field"), "{body}"); + let (error, _) = mcp.call("whoami", json!({})); + assert!(!error); +} + +/// A request too large for one send is the client's typed refusal, an error +/// result that names the size and the limit and does not call the daemon +/// unavailable. +#[test] +fn an_oversize_admit_is_a_typed_too_large_error() { + let mut mcp = Mcp::at(INSTANCE, closed_port()); + let mut arguments = faculty_args(vec![]); + arguments["session"] = json!("s".repeat(90_000)); + let (error, body) = mcp.call("admit", arguments); + assert!(error); + assert_eq!(body["error"], json!("TooLarge"), "{body}"); + assert_eq!(body["limit"], json!(eureka_state::MAX_REQUEST_BYTES)); + assert!(body["bytes"].as_u64().unwrap() > eureka_state::MAX_REQUEST_BYTES as u64, "{body}"); +} + +/// Who admitted a batch is validated by the mind, whichever door the batch +/// came through: a blank, whitespace, multi-line or oversized session or agent +/// is a typed refusal, and nothing is committed. +#[test] +fn admit_refuses_an_unusable_attribution() { + let (root, daemon) = mind(vec![vec![stewardship()]]); + let server = serve(root, daemon); + let mut mcp = Mcp::at(INSTANCE, server.addr); + for (field, value) in [ + ("session", "".to_string()), + ("session", " ".to_string()), + ("session", "x".repeat(5_000)), + ("session", "line\nbreak".to_string()), + ("agent", "".to_string()), + ("agent", "two\nlines".to_string()), + ("agent", "y".repeat(201)), + ] { + let mut arguments = faculty_args(vec![campaign_json()]); + arguments[field] = json!(value); + let (error, refused) = mcp.call("admit", arguments); + assert!(!error, "{field}={value:?}"); + let refusal = &refused["Refused"]["Document"]; + let named = json!(format!("provenance.{field}")); + assert!( + refusal["InvalidFormat"]["field"] == named || refusal["FieldBound"]["field"] == named, + "{field}={value:?}: {refused}" + ); + } + let (_, whoami) = mcp.call("whoami", json!({})); + assert_eq!(whoami["status"]["receipts"], json!(2), "only the seed is committed: {whoami}"); + let (error, admitted) = mcp.call("admit", faculty_args(vec![campaign_json()])); + assert!(!error); + assert!(admitted.get("Committed").is_some(), "{admitted}"); +} + +/// The tool forwards the caller's semantic query, and only that, to the mind. +#[test] +fn query_sends_the_semantic_request_on_the_wire() { + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = std::sync::Arc::clone(&seen); + let server = scripted(move |message| { + let (_, request) = huginn_mind::envelope::decode_request(message).expect("a mind request"); + sink.lock().unwrap().push(request); + encode_response("eureka-state-call", "query", &HuginnMindResponse::View(None), "scripted").unwrap() + }); + let mut mcp = Mcp::at(INSTANCE, server.addr); + mcp.call("query", json!({ "selection": { "projection": "document" }, "semantic": { "text": "drift", "top_k": 7 } })); + mcp.call("query", json!({ "selection": {} })); + let seen = seen.lock().unwrap(); + let [HuginnMindRequest::Query { instance: first, semantic: Some(asked), .. }, HuginnMindRequest::Query { semantic: None, .. }] = + &seen[..] + else { + panic!("two queries expected, the first with its semantic part: {seen:?}"); + }; + assert_eq!(first.0, INSTANCE); + assert_eq!((asked.text.0.as_str(), asked.top_k), ("drift", 7)); +} + +/// A daemon that never answers is given up on after the server's own call +/// timeout, 15 seconds: not earlier, and not much later. +#[test] +fn a_silent_daemon_is_given_up_on_after_fifteen_seconds() { + let silent = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut mcp = Mcp::at(INSTANCE, silent.local_addr().unwrap()); + let started = std::time::Instant::now(); + let (error, body) = mcp.call("view", json!({ "id": absent() })); + let elapsed = started.elapsed(); + assert!(error); + assert_eq!(body["error"], json!("Unavailable"), "{body}"); + assert!(body["detail"].as_str().unwrap().contains("15s"), "{body}"); + assert!(elapsed >= Duration::from_secs(15), "gave up after {elapsed:?}"); + assert!(elapsed < Duration::from_secs(20), "gave up after {elapsed:?}"); +} + +/// An instance name that is not UTF-8 is reported as that, not as unset. +#[test] +fn a_non_utf8_instance_is_reported_as_such() { + let mut mcp = Mcp::spawn(|command| { + command.env("EUREKA_INSTANCE", non_utf8()).env("HUGINN_ENDPOINT", "rudp://127.0.0.1:1"); + }); + let (error, whoami) = mcp.call("whoami", json!({})); + assert!(!error); + let detail = whoami["error"]["detail"].as_str().unwrap(); + assert!(detail.contains("EUREKA_INSTANCE is not valid UTF-8"), "{whoami}"); + assert_eq!(whoami["instance"], Value::Null); +} + +/// Likewise an endpoint that is not UTF-8: reported as that, not as unset. +#[test] +fn a_non_utf8_endpoint_is_reported_as_such() { + let mut mcp = Mcp::spawn(|command| { + command.env("EUREKA_INSTANCE", INSTANCE).env("HUGINN_ENDPOINT", non_utf8()); + }); + let (error, whoami) = mcp.call("whoami", json!({})); + assert!(!error); + let detail = whoami["error"]["detail"].as_str().unwrap(); + assert!(detail.contains("HUGINN_ENDPOINT is not valid UTF-8"), "{whoami}"); + assert_eq!(whoami["endpoint"], Value::Null); +} + +#[cfg(unix)] +fn non_utf8() -> std::ffi::OsString { + std::os::unix::ffi::OsStringExt::from_vec(vec![b'e', 0xff]) +} + +#[cfg(windows)] +fn non_utf8() -> std::ffi::OsString { + std::os::windows::ffi::OsStringExt::from_wide(&[0x65, 0xd800]) +} + +/// A well-formed id of a document no mind here holds. +fn absent() -> Value { + serde_json::to_value(PipelineRef { kind: PipelineKind::CutSpec, id: Short("eureka-state:cut_spec:cut-99.r1".into()) }) + .unwrap() +} + +/// A client only lists tools from a server that says it has them. +#[test] +fn initialize_advertises_tools() { + let mcp = Mcp::at(INSTANCE, closed_port()); + assert!(mcp.init["capabilities"]["tools"].is_object(), "{}", mcp.init); +} diff --git a/crates/huginn-daemon/Cargo.toml b/crates/huginn-daemon/Cargo.toml new file mode 100644 index 0000000..b2cb3fd --- /dev/null +++ b/crates/huginn-daemon/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "huginn-daemon" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish.workspace = true + +# The organ's CultNet surface: the socket, the sessions, the process lifetime +# and the operation envelope, and no rule. Every rule, the instance check, the +# receipt, the status and the wire vocabulary are `huginn-mind`'s, and the leaf +# and the store type are reached through it so one crate pins one revision. +[lib] +name = "huginn_daemon" +path = "src/lib.rs" + +[[bin]] +name = "huginn-daemon" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +base64 = "0.22" +chrono = "0.4.44" +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "8fc74c709444b884a2bd339e0c306bf9484d99d2", package = "cultnet-rs" } +huginn-mind = { path = "../huginn-mind" } +rmp-serde = "1" +serde = { version = "1", features = ["derive"] } +# The vector store's and the embedder's HTTP APIs are the xenos boundary, and +# JSON exists in `index/ollama.rs` and `index/qdrant.rs` only. `ureq` 3 with +# default features off is plain HTTP with no TLS (`rustls` and `gzip` are its +# only defaults), which is all the private mesh needs. It stays below 3.4, +# whose `base64` 0.23 would put a second `base64` in a tree that has one. +serde_json = "1" +sha2 = "0.10" +signal-hook = "0.3" +ureq = { version = "~3.3", default-features = false } + +[dev-dependencies] +tempfile = "3" + +# Test-only: `Mind::store_path_for`, the grammar-checked door onto a raw +# store's path. The daemon's own store-integrity tests reach a store directly +# (to break a row on purpose) and must go through the same door `open` does +# rather than reconstructing the path by hand (S3, residue batch). +huginn-mind = { path = "../huginn-mind", features = ["test-support"] } diff --git a/crates/huginn-daemon/src/bodies.rs b/crates/huginn-daemon/src/bodies.rs new file mode 100644 index 0000000..2f21fa2 --- /dev/null +++ b/crates/huginn-daemon/src/bodies.rs @@ -0,0 +1,286 @@ +//! The deferred bodies: answers too large for one send, held for the client to +//! fetch chunk by chunk. Ephemeral transport state, never a document store and +//! never world truth: it is lost when the process exits, and a client that +//! finds a body gone asks the operation again. +//! +//! Identity is the manifest's `contentHash`, so the same bytes deferred twice +//! are one body. Chunking, hashing and the manifest are `cultnet-rs`'s; what is +//! owned here is only how long and how much is kept. +//! +//! A body expires when it has been untouched for the TTL, where retaining it +//! again and serving any of its chunks both touch it. When the stored bytes +//! exceed the budget the body touched longest ago is evicted first. A chunk +//! that two bodies share is stored once and lives until the last of them goes. +//! Time is an argument, so nothing here reads a clock; expiry therefore runs +//! only when a frame arrives, and an idle daemon holds an expired body until +//! the next one. +//! +//! Limit: there is no per-session or per-peer budget, so any client can evict +//! another client's body mid-fetch: four 64 MiB reads flush the 256 MiB store, +//! and the loser's next chunk is answered `found: false`. That is acceptable +//! for the organ's trusted WireGuard clients, where an instance identity is +//! declared attribution and not authentication. + +use std::cell::Cell; +use std::collections::{BTreeSet, HashMap}; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use cultnet_rs::{CultMeshCdnArtifactManifest, CultMeshCdnChunk}; + +/// The size of one chunk of a deferred body. Four fit one send with room for a +/// control-plane answer beside them on the same session. +pub const DEFERRED_CHUNK_BYTES: usize = 256 * 1024; +/// What the daemon keeps across all deferred bodies. +pub const DEFERRED_BUDGET_BYTES: u64 = 256 * 1024 * 1024; + +struct Chunk { + bytes: Vec, + /// The content hashes of the retained bodies that reference this chunk. + owners: BTreeSet, +} + +struct Body { + chunk_hashes: BTreeSet, + touched_at: Cell>, + /// Orders touches when the clock does not: the largest is the newest. + touch_order: Cell, +} + +pub struct DeferredBodies { + budget: u64, + ttl: chrono::Duration, + chunks: HashMap, + bodies: HashMap, + stored: u64, + touches: Cell, +} + +impl DeferredBodies { + pub fn new(budget: u64, ttl: Duration) -> Self { + Self { + budget, + ttl: chrono::Duration::from_std(ttl).unwrap_or(chrono::Duration::MAX), + chunks: HashMap::new(), + bodies: HashMap::new(), + stored: 0, + touches: Cell::new(0), + } + } + + /// Bytes held: each distinct chunk once. + #[cfg(test)] + pub fn stored_bytes(&self) -> u64 { + self.stored + } + + #[cfg(test)] + pub fn body_count(&self) -> usize { + self.bodies.len() + } + + /// Holds a body, or, when its bytes are already held, touches it and + /// stores nothing. Then evicts, least recently touched first, while the + /// stored bytes exceed the budget and another body could be evicted + /// instead: the body just retained is the last to go, and one that alone + /// exceeds the budget is kept whole. + pub fn retain(&mut self, manifest: &CultMeshCdnArtifactManifest, chunks: Vec, now: DateTime) { + let content_hash = manifest.content_hash.clone(); + if let Some(body) = self.bodies.get(&content_hash) { + self.touch(body, now); + return; + } + let mut chunk_hashes = BTreeSet::new(); + for chunk in chunks { + chunk_hashes.insert(chunk.chunk_hash.clone()); + let held = self.chunks.entry(chunk.chunk_hash).or_insert_with(|| { + self.stored += chunk.payload.len() as u64; + Chunk { bytes: chunk.payload, owners: BTreeSet::new() } + }); + held.owners.insert(content_hash.clone()); + } + let body = Body { chunk_hashes, touched_at: Cell::new(now), touch_order: Cell::new(0) }; + self.touch(&body, now); + self.bodies.insert(content_hash, body); + while self.stored > self.budget && self.bodies.len() > 1 { + let oldest = self + .bodies + .iter() + .min_by_key(|(_, body)| body.touch_order.get()) + .map(|(hash, _)| hash.clone()) + .expect("more than one body is held"); + self.drop_body(&oldest); + } + } + + /// One chunk's bytes by its normalised hash, touching every body that + /// references it. Serving a chunk is what keeps its body alive. + pub fn chunk(&self, hash: &str, now: DateTime) -> Option<&[u8]> { + let chunk = self.chunks.get(hash)?; + for owner in &chunk.owners { + self.touch(&self.bodies[owner], now); + } + Some(&chunk.bytes) + } + + /// Drops every body untouched for the TTL as of `now`. A clock that reads + /// earlier than a touch expires nothing. + pub fn expire(&mut self, now: DateTime) { + let expired: Vec = self + .bodies + .iter() + .filter(|(_, body)| now.signed_duration_since(body.touched_at.get()) >= self.ttl) + .map(|(hash, _)| hash.clone()) + .collect(); + for hash in expired { + self.drop_body(&hash); + } + } + + fn touch(&self, body: &Body, now: DateTime) { + body.touched_at.set(now); + self.touches.set(self.touches.get() + 1); + body.touch_order.set(self.touches.get()); + } + + fn drop_body(&mut self, content_hash: &str) { + let Some(body) = self.bodies.remove(content_hash) else { return }; + for hash in body.chunk_hashes { + let chunk = self.chunks.get_mut(&hash).expect("a retained body's chunks are held"); + chunk.owners.remove(content_hash); + if chunk.owners.is_empty() { + self.stored -= chunk.bytes.len() as u64; + self.chunks.remove(&hash); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cultnet_rs::pack_content; + use huginn_mind::MAX_DEFERRED_BODY_BYTES; + + /// The three numbers a client author is told, spelled out: a chunk of + /// 256 KiB, a largest deferred body of 64 MiB, a budget of 256 MiB that + /// holds at least one whole body. + #[test] + fn the_deferral_constants_are_the_documented_numbers() { + assert_eq!(DEFERRED_CHUNK_BYTES, 262_144); + assert_eq!(MAX_DEFERRED_BODY_BYTES, 67_108_864); + assert_eq!(DEFERRED_BUDGET_BYTES, 268_435_456); + } + + fn at(seconds: i64) -> DateTime { + DateTime::from_timestamp(1_800_000_000 + seconds, 0).unwrap() + } + + /// A body of `pieces` chunks of 4 bytes each, distinct per `tag`. + fn body(tag: u8, pieces: usize) -> (CultMeshCdnArtifactManifest, Vec) { + let mut bytes = Vec::new(); + for piece in 0..pieces { + bytes.extend_from_slice(&[tag, piece as u8, 7, 7]); + } + pack_content("t", "package", "", "", "", &bytes, 4).unwrap() + } + + fn held(store: &DeferredBodies, manifest: &CultMeshCdnArtifactManifest, now: DateTime) -> bool { + manifest.chunks.iter().all(|chunk| store.chunk(&chunk.chunk_hash, now).is_some()) + } + + const TTL: Duration = Duration::from_secs(60); + + /// The same bytes deferred twice are one body, counted once; serving a + /// chunk slides the expiry; an untouched body is gone exactly at the TTL. + #[test] + fn retention_is_idempotent_and_sliding() { + let mut store = DeferredBodies::new(1_000, TTL); + let (manifest, chunks) = body(1, 3); + store.retain(&manifest, chunks.clone(), at(0)); + store.retain(&manifest, chunks, at(1)); + assert_eq!((store.body_count(), store.stored_bytes()), (1, 12), "one body, its bytes counted once"); + + // Retaining again at t=1 touched it, so it lives to t=61, not t=60. + store.expire(at(60)); + assert_eq!(store.body_count(), 1, "the second retain refreshed it"); + // Serving a chunk at t=60 touches it, so t=119 is inside a fresh TTL. + assert!(store.chunk(&manifest.chunks[0].chunk_hash, at(60)).is_some()); + store.expire(at(119)); + assert_eq!(store.body_count(), 1, "serving a chunk slid the expiry"); + store.expire(at(120)); + assert_eq!((store.body_count(), store.stored_bytes()), (0, 0), "untouched for the TTL is gone"); + + // An untouched body is gone at the TTL and not before it. + let (other, chunks) = body(2, 1); + store.retain(&other, chunks, at(200)); + store.expire(at(259)); + assert_eq!(store.body_count(), 1); + store.expire(at(260)); + assert_eq!(store.body_count(), 0); + } + + /// Time going backwards expires nothing. + #[test] + fn a_clock_behind_the_last_touch_expires_nothing() { + let mut store = DeferredBodies::new(1_000, TTL); + let (manifest, chunks) = body(1, 1); + store.retain(&manifest, chunks, at(100)); + store.expire(at(0)); + assert_eq!(store.body_count(), 1); + } + + /// Over the budget the body touched longest ago goes first, not the + /// newest and not the first inserted, and a chunk two bodies share stays + /// while either lives. + #[test] + fn eviction_is_least_recently_touched_and_bounded_in_bytes() { + // Budget 12 bytes: three chunks of 4. + let mut store = DeferredBodies::new(12, TTL); + let (a, chunks_a) = body(1, 1); + let (b, chunks_b) = body(2, 1); + let (c, chunks_c) = body(3, 1); + store.retain(&a, chunks_a, at(0)); + store.retain(&b, chunks_b, at(1)); + // The oldest-inserted body is touched last, so insertion order and + // recency disagree about who goes. + assert!(store.chunk(&a.chunks[0].chunk_hash, at(2)).is_some()); + store.retain(&c, chunks_c, at(3)); + assert_eq!(store.stored_bytes(), 12); + let (d, chunks_d) = body(4, 1); + store.retain(&d, chunks_d, at(4)); + assert_eq!(store.stored_bytes(), 12, "bounded by the budget"); + assert!(!held(&store, &b, at(5)), "the least recently touched went"); + assert!(held(&store, &a, at(5)) && held(&store, &c, at(5)) && held(&store, &d, at(5))); + + // A shared chunk survives its first owner. + let mut store = DeferredBodies::new(12, TTL); + let (x, chunks_x) = body(5, 2); // chunks x0 x1 + store.retain(&x, chunks_x, at(0)); + let (y, mut chunks_y) = body(6, 1); // chunk y0, plus x1 shared + let mut manifest_y = y.clone(); + manifest_y.content_hash = "shared-owner".into(); + chunks_y.push(CultMeshCdnChunk { + chunk_hash: x.chunks[1].chunk_hash.clone(), + payload: vec![5, 1, 7, 7], + }); + store.retain(&manifest_y, chunks_y, at(1)); + assert_eq!(store.stored_bytes(), 12, "the shared chunk is stored once"); + // Evict x (older): its private chunk goes, the shared one stays. + let (z, chunks_z) = body(7, 1); + store.retain(&z, chunks_z, at(2)); + assert_eq!(store.body_count(), 2, "x was evicted"); + assert!(store.chunk(&x.chunks[0].chunk_hash, at(3)).is_none(), "x's private chunk is gone"); + assert!(store.chunk(&x.chunks[1].chunk_hash, at(3)).is_some(), "the shared chunk lives with y"); + } + + /// A body that alone exceeds the budget is kept whole rather than + /// evicting itself, so a manifest handed out is never a lie. + #[test] + fn a_body_over_the_budget_by_itself_is_kept() { + let mut store = DeferredBodies::new(4, TTL); + let (manifest, chunks) = body(1, 3); + store.retain(&manifest, chunks, at(0)); + assert!(held(&store, &manifest, at(0))); + } +} diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs new file mode 100644 index 0000000..ffbf813 --- /dev/null +++ b/crates/huginn-daemon/src/daemon.rs @@ -0,0 +1,864 @@ +//! The dispatch: one mind, one request, one response. No socket, no clock, no +//! rule. `now` is the caller's, so a test pins it and the loop is the only +//! thing that reads a wall clock. + +use chrono::{DateTime, Utc}; +use cultnet_rs::Selection; +use huginn_mind::epiphany_pipeline::{PipelineRef, Slug}; +use huginn_mind::wire::{HuginnMindRequest, HuginnMindResponse, IndexStatus}; +use huginn_mind::{Mind, MindRefusal, MindStore, PipelineAdmissionOutcome, SemanticQuery}; + +/// Where the index attaches. Called after a batch commits, with the refs the +/// batch landed, derived writes included; the sink may read each through +/// `mind.view`. Its error is logged and never reaches the caller: the index +/// does not decide whether a batch was admitted, and the call must not wait on +/// the index's transport. `status` is what `whoami` reports of it. +/// +/// A semantic query is asked in two steps because the index answers on its own +/// time: `search` starts one and returns a ticket at once, and `searched` +/// hands over the searches that have finished since it was last called. The +/// candidates are the index's word about nearness and nothing else; the daemon +/// judges them through the mind. +pub trait IndexSink { + fn committed(&mut self, mind: &Mind, writes: &[PipelineRef]) -> anyhow::Result<()>; + fn status(&self) -> IndexStatus; + /// Starts a search for the documents nearest `text`, at most `top_k` of + /// them wanted. `Err` says why the index cannot be asked now. + fn search(&mut self, text: &str, top_k: u32) -> Result; + /// How long after it was asked a search is worth answering: the index's + /// own bound on it, which the loop waiting for the answer must share. + fn search_deadline(&self) -> std::time::Duration; + /// Every search that finished since the last call, by ticket: its + /// candidates and scores, or why it failed. + fn searched(&mut self) -> Vec<(SearchTicket, Result)>; + /// The asker has stopped waiting for `ticket`. A search not yet started is + /// dropped, so a queue of searches nobody wants cannot hold the index's + /// writes off. + fn abandon(&mut self, ticket: SearchTicket); +} + +/// Names one search the index was asked, so the loop can match the answer to +/// the client that is waiting for it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SearchTicket(pub u64); + +/// What a search returns: candidate documents and how near each scored, best +/// first. Nothing else about them. +pub type Hits = Vec<(PipelineRef, f32)>; + +/// A semantic query the index has been asked and has not yet answered. The +/// serve loop holds it beside the session that asked, and hands it back to +/// `Daemon::finish` with the index's answer. +pub struct Search { + pub ticket: SearchTicket, + selection: Selection, + semantic: SemanticQuery, +} + +/// What a request came to: an answer, or a search the index has yet to +/// finish. +pub enum Handled { + Answered(HuginnMindResponse), + Searching(Search), +} + +impl Handled { + /// The answer, for a request that cannot be a search. + #[cfg(test)] + pub(crate) fn answered(self) -> HuginnMindResponse { + match self { + Self::Answered(response) => response, + Self::Searching(search) => panic!("expected an answer, got the search {:?}", search.ticket), + } + } +} + +/// What the log says of the hits the mind held no document for: how many, and +/// the first few by name (a restored mind can leave hundreds), or nothing when +/// the mind held them all. +fn dropped_note(dropped: &[PipelineRef]) -> Option { + if dropped.is_empty() { + return None; + } + let named: Vec<&str> = dropped.iter().take(5).map(|hit| hit.id.0.as_str()).collect(); + Some(format!( + "huginn: the index returned {} ids this mind does not hold (first {named:?}); they are left out", + dropped.len() + )) +} + +/// The envelope's `source_runtime_id` on every response a daemon serving this +/// instance sends. Also what the socket is bound under, before a daemon exists. +pub fn runtime_id(instance: &Slug) -> String { + format!("huginn-{}", instance.0) +} + +/// One mind and whatever indexes what lands in it. +pub struct Daemon> { + mind: Mind, + index: I, +} + +impl> Daemon { + /// A daemon over a mind that is already open. `Mind::open` takes the + /// store's exclusive lock for the daemon's lifetime, and ruling 15 has it + /// refuse before any socket exists: the caller opens the mind first. + pub fn new(mind: Mind, index: I) -> Self { + Self { mind, index } + } + + pub fn with_index>(self, index: J) -> Daemon { + Daemon { mind: self.mind, index } + } + + pub fn mind(&self) -> &Mind { + &self.mind + } + + pub fn index(&self) -> &I { + &self.index + } + + /// The envelope's `source_runtime_id` on every response this daemon sends. + pub fn runtime_id(&self) -> String { + runtime_id(self.mind.instance()) + } + + /// One request, one answer, or a search to finish. `Admit` goes to the mind whole, because the + /// batch declares its own instance and admission's A1 is the check. Every + /// other request names an instance: `require_instance` now applies the + /// leaf's own grammar before it compares identity, so a name outside the + /// grammar (a fullwidth fold, say) is refused by name rather than read as + /// merely a foreign mind, and the daemon asks nothing twice. + pub fn handle(&mut self, request: HuginnMindRequest, now: DateTime) -> Handled { + if let Some(declared) = request.instance() + && !matches!(request, HuginnMindRequest::Admit(_)) + && let Err(refusal) = self.mind.require_instance(declared) + { + return Handled::Answered(HuginnMindResponse::Refused(refusal)); + } + let answer = match request { + HuginnMindRequest::Whoami => HuginnMindResponse::Whoami(self.mind.status(self.index.status())), + HuginnMindRequest::Admit(batch) => { + let outcome = self.mind.admit(batch, now); + if let PipelineAdmissionOutcome::Committed { writes, .. } = &outcome + && let Err(error) = self.index.committed(&self.mind, writes) + { + let refs = writes.iter().map(|write| write.id.0.clone()).collect::>().join(", "); + eprintln!("huginn: the index refused the landed writes [{refs}]: {error:#}"); + } + HuginnMindResponse::Admit(outcome) + } + HuginnMindRequest::View { id, .. } => match self.mind.view(&id) { + Ok(view) => HuginnMindResponse::View(view), + Err(refusal) => HuginnMindResponse::Refused(refusal), + }, + HuginnMindRequest::Query { selection, semantic: None, .. } => match self.mind.query(&selection) { + Ok(page) => HuginnMindResponse::Query(page), + Err(refusal) => HuginnMindResponse::Refused(refusal), + }, + HuginnMindRequest::Query { selection, semantic: Some(semantic), .. } => { + return self.begin_search(selection, semantic); + } + }; + Handled::Answered(answer) + } + + /// A semantic query starts only if the mind would accept it (so nothing is + /// embedded for a query that cannot be ranked) and the index can be asked. + fn begin_search(&mut self, selection: Selection, semantic: SemanticQuery) -> Handled { + let refused = |refusal: MindRefusal| Handled::Answered(HuginnMindResponse::Refused(refusal)); + if let Err(refusal) = self.mind.check_semantic(&selection, &semantic) { + return refused(refusal); + } + match self.index.search(&semantic.text.0, semantic.top_k) { + Ok(ticket) => Handled::Searching(Search { ticket, selection, semantic }), + Err(detail) => refused(MindRefusal::Unavailable { detail }), + } + } + + /// How long a search is worth waiting for. + pub fn search_deadline(&self) -> std::time::Duration { + self.index.search_deadline() + } + + /// Searches the index has finished since this was last called. + pub fn searched(&mut self) -> Vec<(SearchTicket, Result)> { + self.index.searched() + } + + /// The client has stopped waiting for `ticket`. + pub fn abandon(&mut self, ticket: SearchTicket) { + self.index.abandon(ticket); + } + + /// The answer to a search: the index's candidates ranked through the mind + /// (`Mind::rank` drops what the mind does not hold and lets the selection + /// decide the rest), or, when the index could not answer, a typed + /// `Unavailable`. Never an empty page in place of a failure. + pub fn finish(&mut self, search: Search, found: Result) -> HuginnMindResponse { + let hits = match found { + Ok(hits) => hits, + Err(detail) => return HuginnMindResponse::Refused(MindRefusal::Unavailable { detail }), + }; + match self.mind.rank(&search.selection, &search.semantic, &hits) { + Ok(ranked) => { + if let Some(note) = dropped_note(&ranked.dropped) { + eprintln!("{note}"); + } + HuginnMindResponse::Query(ranked.page) + } + Err(refusal) => HuginnMindResponse::Refused(refusal), + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use chrono::TimeZone; + use huginn_mind::epiphany_pipeline::{ + AuthorityMap, CodeLocation, CutDelete, CutVerification, Date, DocRef, FileChange, Line, NegativeCheck, + OrgRepo, PipelineCampaign, PipelineCutSpec, PipelineDocument, PipelineInstance, PipelineKind, + PipelineQuestion, PipelineRuling, PipelineStewardship, QuestionOption, RulingAuthority, Sha, Short, + StructuralDelta, Title, VerificationTest, + }; + use huginn_mind::wire::MindStatus; + use huginn_mind::{MindRefusal, OwnedRedbMessagePackBackingStore}; + use std::path::Path; + use cultnet_rs::Selection; + use huginn_mind::{Faculty, PipelineAdmissionBatch, PipelineProvenance, PipelinePageItems, PipelineStatus}; + use std::sync::{Arc, Mutex}; + use tempfile::TempDir; + + pub(crate) const INSTANCE: &str = "yggdrasil"; + pub(crate) const OTHER: &str = "thought-cage"; + /// `INSTANCE`'s own length, differing in its last byte alone. + pub(crate) const NEAR: &str = "yggdrasix"; + /// `INSTANCE` whole, with more after it. + pub(crate) const PREFIXED: &str = "yggdrasil-two"; + /// `INSTANCE` in another case, which a slug label permits. + pub(crate) const CASED: &str = "Yggdrasil"; + pub(crate) const CAMPAIGN: &str = "eureka-state"; + + pub(crate) fn slug(value: &str) -> Slug { + value.into() + } + + pub(crate) fn now() -> DateTime { + Utc.with_ymd_and_hms(2026, 9, 16, 12, 0, 0).unwrap() + } + + pub(crate) fn provenance() -> PipelineProvenance { + PipelineProvenance { + faculty: Faculty::Hands, + agent: Short("claude".into()), + session: Short("session-1".into()), + tool: Short("admit".into()), + } + } + + pub(crate) fn identity(name: &str) -> PipelineDocument { + PipelineDocument::Instance(PipelineInstance { + instance: slug(name), + display_name: Short(format!("{name} mind")), + created_at: Date("2026-09-16".into()), + host: Short(name.into()), + }) + } + + pub(crate) fn batch(instance: &str, documents: Vec) -> PipelineAdmissionBatch { + PipelineAdmissionBatch { instance: slug(instance), provenance: provenance(), documents } + } + + /// A question and the ruling that answers it: admitting the ruling derives + /// the question's resolution, so one batch lands two writes. + pub(crate) fn question_and_ruling() -> (PipelineDocument, PipelineDocument) { + let question = PipelineDocument::Question(PipelineQuestion { + campaign: slug(CAMPAIGN), + label: "Q1".into(), + title: Title("Who owns the state?".into()), + question: "Who owns the state?".into(), + options: vec![ + QuestionOption { label: "A".into(), text: "the organ".into() }, + QuestionOption { label: "B".into(), text: "the client".into() }, + ], + recommended: "A".into(), + depends: vec![], + raised_in: None, + asked_on: Date("2026-09-16".into()), + }); + let ruling = PipelineDocument::Ruling(PipelineRuling { + campaign: slug(CAMPAIGN), + label: "R1".into(), + title: Title("An instance owns its mind".into()), + answers: Some(Short(format!("{CAMPAIGN}:question:Q1"))), + choice: Some("A".into()), + ruling: "An instance owns its mind.".into(), + operator_quote: None, + ruled_on: Date("2026-09-16".into()), + precedents: vec![], + authority: RulingAuthority::Operator, + }); + (question, ruling) + } + + pub(crate) const REPO: &str = "GameCult/Epiphany"; + + /// A `Line` and a `Short` at the leaf's own bound, so a document built out + /// of them is as wide as any writer may make it. + pub(crate) fn line(n: usize) -> Line { + Line(format!("{n:0>1000}")) + } + + pub(crate) fn short(n: usize) -> Short { + Short(format!("{n:0>200}")) + } + + fn sha() -> Sha { + Sha("5f98228d".into()) + } + + fn doc_ref() -> DocRef { + DocRef { path: Short("notes/target.md".into()), start_line: 1, end_line: 9, commit: sha() } + } + + /// The three documents a cut spec needs before it may be admitted: the + /// mind's identity, its stewardship of the repo, and the campaign that + /// names the repo. + pub(crate) fn campaign_seed() -> Vec { + vec![ + identity(INSTANCE), + PipelineDocument::Stewardship(PipelineStewardship { + instance: slug(INSTANCE), + repo: OrgRepo(REPO.into()), + sequence: 1, + assigned_on: Date("2026-09-16".into()), + note: "assigned".into(), + }), + PipelineDocument::Campaign(PipelineCampaign { + slug: slug(CAMPAIGN), + title: Title("Eureka pipeline state".into()), + repos: vec![OrgRepo(REPO.into())], + working_branch: Short("codex/eureka-pipeline-state".into()), + target_doc: doc_ref(), + }), + ] + } + + /// One cut spec with every list the leaf bounds filled to its maximum and + /// every text at its own: `file_changes` file changes, 64 deletes, keeps, + /// adds, builds, tests, negative checks and operator checks, a full + /// authority map and a full structural delta. At 256 file changes that is + /// roughly a megabyte of field content, which is what a real-sized answer + /// looks like at the leaf's bounds rather than at a fixture's convenience. + /// `depends_on`, `rulings` and `questions` stay empty because each is a + /// reference admission resolves. + pub(crate) fn cut_spec(cut: &str, file_changes: usize) -> PipelineDocument { + let lines = |count: usize| (0..count).map(line).collect::>(); + let shorts = |count: usize| (0..count).map(short).collect::>(); + let location = |n: usize| CodeLocation { path: short(n), line: 1, end_line: Some(9) }; + PipelineDocument::CutSpec(PipelineCutSpec { + campaign: slug(CAMPAIGN), + cut: cut.into(), + revision: 1, + title: Title(short(0).0), + repo: OrgRepo(REPO.into()), + branch: short(1), + base: sha(), + depends_on: vec![], + first: lines(16), + deletes: (0..64).map(|n| CutDelete { path: short(n), lines: 9, note: line(n) }).collect(), + keeps_moves: lines(64), + adds: lines(64), + file_changes: (0..file_changes).map(|n| FileChange { location: location(n), change: line(n) }).collect(), + authority_map: Some(AuthorityMap { + owner: line(0), + inputs: lines(16), + outputs: lines(16), + derived_state: lines(16), + forbidden_writers: lines(16), + shared_paths: lines(16), + deletion_line: line(1), + }), + verification: CutVerification { + builds: lines(64), + tests: (0..64).map(|n| VerificationTest { name: short(n), pins: line(n) }).collect(), + negative: (0..64).map(|n| NegativeCheck { pattern: short(n), scope: line(n) }).collect(), + operator: lines(64), + }, + estimate: StructuralDelta { + lines_added: 0, + lines_removed: 0, + dependencies_added: shorts(64), + dependencies_removed: shorts(64), + formats_added: shorts(64), + formats_removed: shorts(64), + targets_added: shorts(64), + targets_removed: shorts(64), + }, + rulings: vec![], + questions: vec![], + }) + } + + /// The cut spec at the leaf's own maximum, and one narrow enough that its + /// answer still fits a single send. `WIDE_CHANGES` is the leaf's bound; + /// `FITTING_CHANGES` is chosen so the view of that spec lands inside the + /// window with less than a packet-count's slack, which is what makes the + /// window's own value load-bearing rather than merely generous. + pub(crate) const WIDE_CUT: &str = "wide"; + pub(crate) const WIDE_CHANGES: usize = 256; + pub(crate) const FITTING_CUT: &str = "fits"; + pub(crate) const FITTING_CHANGES: usize = 180; + + /// A daemon whose mind holds the campaign seed, one cut spec as wide as the + /// leaf permits, and one narrow enough to answer, so a read over it is a + /// real-sized answer either way. + pub(crate) fn seeded_wide() -> (TempDir, Daemon) { + let root = tempfile::tempdir().unwrap(); + let mut daemon = open_unindexed(root.path(), &slug(INSTANCE)).unwrap(); + for documents in [ + campaign_seed(), + vec![cut_spec(WIDE_CUT, WIDE_CHANGES), cut_spec(FITTING_CUT, FITTING_CHANGES)], + ] { + let outcome = daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, documents)), now()).answered(); + assert!( + matches!(outcome, HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { .. })), + "{outcome:?}" + ); + } + (root, daemon) + } + + /// A daemon over a real redb mind in a temporary directory, with its + /// identity admitted. The directory is returned because dropping it + /// removes the store. + pub(crate) fn seeded() -> (TempDir, Daemon) { + let root = tempfile::tempdir().unwrap(); + let mut daemon = open_unindexed(root.path(), &slug(INSTANCE)).unwrap(); + let outcome = daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![identity(INSTANCE)])), now()).answered(); + assert!( + matches!(outcome, HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { .. })), + "{outcome:?}" + ); + (root, daemon) + } + + /// The log names how many hits the mind did not hold and the first five, and + /// says nothing when it held them all. + #[test] + fn the_dropped_hits_are_reported_by_count_and_the_first_five_names() { + assert_eq!(dropped_note(&[]), None); + let dropped: Vec = + (0..7).map(|n| PipelineRef { kind: PipelineKind::Question, id: Short(format!("gone-{n}")) }).collect(); + let note = dropped_note(&dropped).unwrap(); + assert!(note.contains("returned 7 ids"), "{note}"); + assert!(note.contains("gone-0") && note.contains("gone-4") && !note.contains("gone-5"), "{note}"); + assert_eq!(dropped_note(&dropped[..1]).unwrap().matches("gone-").count(), 1); + } + + /// No index: for the tests whose subject is not the projection. + pub(crate) struct NoIndex; + + impl IndexSink for NoIndex { + fn committed(&mut self, _mind: &Mind, _writes: &[PipelineRef]) -> anyhow::Result<()> { + Ok(()) + } + + fn status(&self) -> IndexStatus { + IndexStatus::Current + } + + fn search(&mut self, _text: &str, _top_k: u32) -> Result { + Err("this index cannot search".into()) + } + + fn search_deadline(&self) -> std::time::Duration { + std::time::Duration::ZERO + } + + fn searched(&mut self) -> Vec<(SearchTicket, Result)> { + Vec::new() + } + + fn abandon(&mut self, _ticket: SearchTicket) {} + } + + /// A daemon over the instance's mind under `state_root`, with no index. + pub(crate) fn open_unindexed( + state_root: &Path, + instance: &Slug, + ) -> Result, MindRefusal> { + Ok(Daemon::new(Mind::open(state_root, instance)?, NoIndex)) + } + + #[derive(Clone, Default)] + struct RecordingIndex { + seen: Arc, Vec)>>>, + } + + impl IndexSink for RecordingIndex { + fn committed(&mut self, mind: &Mind, writes: &[PipelineRef]) -> anyhow::Result<()> { + let ids = writes.iter().map(|write| write.id.0.clone()).collect(); + let readable = writes.iter().map(|write| mind.view(write).unwrap().is_some()).collect(); + self.seen.lock().unwrap().push((ids, readable)); + Ok(()) + } + + fn status(&self) -> IndexStatus { + IndexStatus::Current + } + + fn search(&mut self, _text: &str, _top_k: u32) -> Result { + Err("this index cannot search".into()) + } + + fn search_deadline(&self) -> std::time::Duration { + std::time::Duration::ZERO + } + + fn searched(&mut self) -> Vec<(SearchTicket, Result)> { + Vec::new() + } + + fn abandon(&mut self, _ticket: SearchTicket) {} + } + + struct FailingIndex; + + impl IndexSink for FailingIndex { + fn committed(&mut self, _mind: &Mind, _writes: &[PipelineRef]) -> anyhow::Result<()> { + Err(anyhow::anyhow!("the index is not there")) + } + + fn status(&self) -> IndexStatus { + IndexStatus::Current + } + + fn search(&mut self, _text: &str, _top_k: u32) -> Result { + Err("this index cannot search".into()) + } + + fn search_deadline(&self) -> std::time::Duration { + std::time::Duration::ZERO + } + + fn searched(&mut self) -> Vec<(SearchTicket, Result)> { + Vec::new() + } + + fn abandon(&mut self, _ticket: SearchTicket) {} + } + + fn status(daemon: &mut Daemon>) -> MindStatus { + match daemon.handle(HuginnMindRequest::Whoami, now()).answered() { + HuginnMindResponse::Whoami(status) => status, + other => panic!("expected a status, got {other:?}"), + } + } + + /// Typed hand-off between the organs without a socket: a batch enters and + /// every read answers over what it landed, at a clock the test pins. + #[test] + fn a_batch_round_trips_typed_through_handle_without_a_socket() { + let root = tempfile::tempdir().unwrap(); + let mut daemon = open_unindexed(root.path(), &slug(INSTANCE)).unwrap(); + let outcome = daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![identity(INSTANCE)])), now()).answered(); + let HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { writes, .. }) = outcome else { + panic!("expected a commit"); + }; + assert_eq!(writes.len(), 1); + assert_eq!(writes[0].kind, PipelineKind::Instance); + + assert_eq!( + status(&mut daemon), + MindStatus { + instance: slug(INSTANCE), + schema_epoch: "epiphany.pipeline.epoch.v2".into(), + documents: 1, + receipts: 1, + index: IndexStatus::Current, + } + ); + assert_eq!(daemon.runtime_id(), "huginn-yggdrasil"); + + let instances = Selection { schemas: Some(vec![PipelineKind::Instance.type_id().into()]), ..Selection::default() }; + let HuginnMindResponse::Query(page) = daemon.handle( + HuginnMindRequest::Query { instance: slug(INSTANCE), selection: instances.clone(), semantic: None }, + now(), + ).answered() else { + panic!("expected a page"); + }; + assert_eq!(page.matched, 1); + let PipelinePageItems::Headers(headers) = &page.items else { panic!("the default projection is a header") }; + assert_eq!(headers[0].admission.provenance, provenance()); + assert_eq!(headers[0].status, huginn_mind::PipelineStatusSummary::InForce); + + let id = writes[0].clone(); + let whole = Selection { projection: "document".into(), ..instances }; + let HuginnMindResponse::Query(page) = + daemon.handle(HuginnMindRequest::Query { instance: slug(INSTANCE), selection: whole, semantic: None }, now()).answered() + else { + panic!("expected a page"); + }; + let PipelinePageItems::Documents(views) = &page.items else { panic!("the document projection is the view") }; + assert_eq!(views[0].status, PipelineStatus::InForce); + let view = daemon.handle(HuginnMindRequest::View { instance: slug(INSTANCE), id: id.clone() }, now()).answered(); + assert_eq!(view, HuginnMindResponse::View(Some(views[0].clone()))); + } + + /// Ruling 14 across the transport, on both sides: the mind refuses a read + /// and a write that name another instance, and nothing lands. + #[test] + fn a_read_or_a_write_naming_another_instance_is_refused_by_the_mind_and_writes_nothing() { + let root = tempfile::tempdir().unwrap(); + let mut daemon = open_unindexed(root.path(), &slug(INSTANCE)).unwrap(); + let landed = daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![identity(INSTANCE)])), now()).answered(); + let HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { writes, .. }) = landed else { + panic!("expected a commit"); + }; + let foreign = MindRefusal::ForeignInstance { declared: OTHER.into(), mind: INSTANCE.into() }; + + let query = HuginnMindRequest::Query { instance: slug(OTHER), selection: Selection::default(), semantic: None }; + assert_eq!(daemon.handle(query, now()).answered(), HuginnMindResponse::Refused(foreign.clone())); + + let admit = HuginnMindRequest::Admit(batch(OTHER, vec![identity(OTHER)])); + assert_eq!( + daemon.handle(admit, now()).answered(), + HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(foreign.clone())) + ); + + // The declared instance travels to the mind as the client sent it. A + // batch whose documents name no instance at all is still refused on + // the declaration alone, so nothing between the wire and A1 may + // rewrite it into the mind's own. + let (question, _) = question_and_ruling(); + let admit = HuginnMindRequest::Admit(batch(OTHER, vec![question])); + assert_eq!( + daemon.handle(admit, now()).answered(), + HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(foreign.clone())) + ); + + let view = HuginnMindRequest::View { instance: slug(OTHER), id: writes[0].clone() }; + assert_eq!(daemon.handle(view, now()).answered(), HuginnMindResponse::Refused(foreign)); + + // Three names a comparison could mistake for this mind's. `NEAR` is + // `INSTANCE`'s own length and differs in its last byte, so a check + // reading lengths, first bytes, or running only when the declared name + // is longer admits it; `PREFIXED` carries `INSTANCE` whole, so a prefix + // test admits it; `CASED` is `INSTANCE` in another case, which a slug + // label permits, so a check folding case admits it. Each is refused on + // the read side and the write side. + assert_eq!(NEAR.len(), INSTANCE.len()); + assert!(PREFIXED.starts_with(INSTANCE)); + assert_eq!(CASED.to_ascii_lowercase(), INSTANCE); + for declared in [NEAR, PREFIXED, CASED] { + let refusal = MindRefusal::ForeignInstance { declared: declared.into(), mind: INSTANCE.into() }; + let query = HuginnMindRequest::Query { instance: slug(declared), selection: Selection::default(), semantic: None }; + assert_eq!(daemon.handle(query, now()).answered(), HuginnMindResponse::Refused(refusal.clone()), "{declared}"); + let view = HuginnMindRequest::View { instance: slug(declared), id: writes[0].clone() }; + assert_eq!(daemon.handle(view, now()).answered(), HuginnMindResponse::Refused(refusal.clone()), "{declared}"); + let admit = HuginnMindRequest::Admit(batch(declared, vec![identity(declared)])); + assert_eq!( + daemon.handle(admit, now()).answered(), + HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(refusal)), + "{declared}" + ); + } + + assert_eq!(status(&mut daemon).documents, 1, "nothing landed"); + } + + /// A declared instance outside `Slug`'s grammar is refused by its own + /// name, `InvalidFormat`, on the read path, before the identity + /// comparison ever runs: it is not merely a foreign mind, it names no + /// mind a client could compose. A fullwidth Latin small letter y is used + /// because it is the shape Soul named: a check that folds it to plain + /// ASCII `y` before validating would let this probe pass as grammatical + /// and reach `require_instance`, which would then answer `ForeignInstance` + /// instead — the wrong refusal, reached the wrong way. + #[test] + fn a_declared_instance_outside_the_grammar_is_refused_by_name_before_the_identity_check() { + let (_root, mut daemon) = seeded(); + let fullwidth = "\u{FF59}ggdrasil"; + assert_ne!(fullwidth, INSTANCE, "not the mind's own bytes"); + assert!(!fullwidth.is_ascii(), "outside the grammar: a Slug is dot-joined ASCII labels"); + + let expected = MindRefusal::Document(huginn_mind::epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "declared".into(), + value: fullwidth.into(), + }); + + let query = HuginnMindRequest::Query { instance: slug(fullwidth), selection: Selection::default(), semantic: None }; + assert_eq!(daemon.handle(query, now()).answered(), HuginnMindResponse::Refused(expected.clone())); + + let view = HuginnMindRequest::View { + instance: slug(fullwidth), + id: PipelineRef { kind: PipelineKind::Question, id: Short("not-reached".into()) }, + }; + assert_eq!(daemon.handle(view, now()).answered(), HuginnMindResponse::Refused(expected)); + } + + /// `Admit` is excluded from the daemon's own pre-check (its batch is not a + /// read naming an instance the mind is asked about first), but it reaches + /// the identical `require_instance` from inside `admit_steps`'s A1, so a + /// fullwidth *declared batch instance* is `InvalidFormat` there too, + /// named and valued the same as the read path's. One door, + /// `Mind::require_instance`, decides both. The batch's one document names + /// the mind's own real instance, `INSTANCE`, so it clears `admit`'s own + /// per-document `Bounded` check and the only fullwidth text anywhere in + /// the request is the batch's declared instance, the thing this test + /// pins. + #[test] + fn a_fullwidth_instance_is_refused_the_same_way_by_admit_and_by_query() { + let (_root, mut daemon) = seeded(); + let fullwidth = "\u{FF59}ggdrasil"; + + let expected = MindRefusal::Document(huginn_mind::epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "declared".into(), + value: fullwidth.into(), + }); + + let admit = HuginnMindRequest::Admit(batch(fullwidth, vec![identity(INSTANCE)])); + assert_eq!( + daemon.handle(admit, now()).answered(), + HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(expected.clone())) + ); + + let query = HuginnMindRequest::Query { instance: slug(fullwidth), selection: Selection::default(), semantic: None }; + assert_eq!(daemon.handle(query, now()).answered(), HuginnMindResponse::Refused(expected)); + } + + /// A refusal a read method raised is the answer the dispatch returns, whole. + /// The instance check refuses before the method runs and is pinned above; + /// these are the refusals the methods themselves raise, so nothing here is + /// the daemon's own. Each must arrive as the mind spelled it: not rewrapped + /// as `Unavailable`, not rewrapped as another read's refusal, and not + /// swallowed into an empty answer, which would read as "no such document" + /// rather than "that is not a reference". + #[test] + fn a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole() { + let (_root, mut daemon) = seeded(); + + // A reference whose kind and id disagree names no document any writer + // could have composed; the leaf's own refusal says so. + let invalid = PipelineRef { kind: PipelineKind::Question, id: "not a reference".into() }; + let malformed = MindRefusal::Document(huginn_mind::epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "ref.id".into(), + value: "not a reference".into(), + }); + let view = HuginnMindRequest::View { instance: slug(INSTANCE), id: invalid }; + assert_eq!(daemon.handle(view, now()).answered(), HuginnMindResponse::Refused(malformed)); + + // And the same reads answer rather than refuse when they are asked + // something answerable, so the arms above are refusing on the mind's + // word and not on their own shape. + let absent = + PipelineRef { kind: PipelineKind::Question, id: Short(format!("{CAMPAIGN}:question:Q1")) }; + let present = HuginnMindRequest::View { instance: slug(INSTANCE), id: absent }; + assert_eq!(daemon.handle(present, now()).answered(), HuginnMindResponse::View(None)); + let plain = HuginnMindRequest::Query { instance: slug(INSTANCE), selection: Selection::default(), semantic: None }; + let HuginnMindResponse::Query(page) = daemon.handle(plain, now()).answered() else { panic!("expected a page") }; + assert_eq!(page.matched, 1); + } + + /// A refusal of the selection crosses the dispatch as itself: typed, on + /// the field the client sent, and never swallowed into an empty page or + /// rewrapped as an unavailable store. A stale cursor is the organ's own + /// defect, so a valid walk never meets it. + #[test] + fn a_selection_refusal_crosses_the_dispatch_as_itself() { + let (_root, mut daemon) = seeded(); + let ask = |daemon: &mut Daemon, selection: Selection| { + daemon.handle(HuginnMindRequest::Query { instance: slug(INSTANCE), selection, semantic: None }, now()).answered() + }; + let predicate = |index: &str, value: &str| Selection { + fields: Some(vec![cultnet_rs::FieldPredicate { + index: index.into(), + op: "any_of".into(), + values: Some(vec![value.into()]), + number: None, + }]), + ..Selection::default() + }; + let HuginnMindResponse::Refused(MindRefusal::SelectionInvalid { field, .. }) = + ask(&mut daemon, predicate("nonsense", "x")) + else { + panic!("an undeclared alias is the substrate's refusal"); + }; + assert_eq!(field, "fields[0].index"); + let HuginnMindResponse::Refused(MindRefusal::SelectionInvalid { field, value, .. }) = + ask(&mut daemon, predicate("repo", "a/b/c")) + else { + panic!("a value outside its domain is the organ's refusal"); + }; + assert_eq!((field.as_str(), value.as_deref()), ("fields[0].values", Some("a/b/c"))); + let garbled = Selection { cursor: Some("not a cursor".into()), ..Selection::default() }; + assert!(matches!(ask(&mut daemon, garbled), HuginnMindResponse::Refused(MindRefusal::CursorInvalid { .. }))); + assert_eq!(ask(&mut daemon, predicate("repo", "a/b/c")).status(), "rejected"); + } + + /// Cut 11's seam: the index is handed every landed write, derived ones + /// included, and can read each; an index that fails changes nothing. + #[test] + fn the_index_is_handed_every_landed_write_after_commit_and_never_decides_the_outcome() { + let (_root, daemon) = seeded(); + let recording = RecordingIndex::default(); + let mut daemon = daemon.with_index(recording.clone()); + let (question, ruling) = question_and_ruling(); + + let outcome = daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![question, ruling])), now()).answered(); + let HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { writes, .. }) = outcome else { + panic!("expected a commit"); + }; + let landed: Vec = writes.iter().map(|write| write.id.0.clone()).collect(); + assert_eq!(landed.len(), 3, "the ruling answers the question, so a resolution is derived: {landed:?}"); + + let seen = recording.seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].0, landed, "the sink sees exactly the landed writes"); + assert_eq!(seen[0].1, vec![true, true, true], "and can read each one when it is called"); + + let (question, ruling) = question_and_ruling(); + let mut daemon = daemon.with_index(FailingIndex); + let root = tempfile::tempdir().unwrap(); + let mut fresh = open_unindexed(root.path(), &slug(INSTANCE)).unwrap().with_index(FailingIndex); + fresh.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![identity(INSTANCE)])), now()).answered(); + let outcome = fresh.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![question, ruling])), now()).answered(); + let HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { writes, .. }) = outcome else { + panic!("a failing index does not refuse the batch"); + }; + for write in &writes { + let view = fresh.handle(HuginnMindRequest::View { instance: slug(INSTANCE), id: write.clone() }, now()).answered(); + assert!(matches!(view, HuginnMindResponse::View(Some(_))), "{write:?} is readable"); + } + assert_eq!(status(&mut daemon).documents, 4, "identity, question, ruling and the derived resolution"); + } + + /// Ruling 15: a mind that will not open is refused, and opening it takes + /// no address, so no socket can precede it. `startup`'s order is + /// pinned in `serve::tests`. + #[test] + fn the_daemon_refuses_loudly_when_it_cannot_open_the_mind_and_binds_nothing() { + let root = tempfile::tempdir().unwrap(); + let held = open_unindexed(root.path(), &slug(INSTANCE)).unwrap(); + let path = Mind::::store_path_for(root.path(), &slug(INSTANCE)).unwrap(); + assert_eq!( + open_unindexed(root.path(), &slug(INSTANCE)).err(), + Some(MindRefusal::MindAlreadyOwned { path: path.display().to_string() }) + ); + drop(held); + + let mut planted = open_unindexed(root.path(), &slug(INSTANCE)).unwrap(); + planted.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![identity(INSTANCE)])), now()).answered(); + drop(planted); + let moved = Mind::::store_path_for(root.path(), &slug(OTHER)).unwrap(); + std::fs::create_dir_all(moved.parent().unwrap()).unwrap(); + std::fs::copy(&path, &moved).unwrap(); + assert_eq!( + open_unindexed(root.path(), &slug(OTHER)).err(), + Some(MindRefusal::ForeignInstance { declared: OTHER.into(), mind: INSTANCE.into() }) + ); + } +} diff --git a/crates/huginn-daemon/src/index/fakes.rs b/crates/huginn-daemon/src/index/fakes.rs new file mode 100644 index 0000000..20bcc8c --- /dev/null +++ b/crates/huginn-daemon/src/index/fakes.rs @@ -0,0 +1,239 @@ +//! In-memory ports for the projector's tests. Each fake is a handle on shared +//! state, so a test keeps observing after it has moved the fake into a +//! projector or a worker thread. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; + +use anyhow::{Result, bail, ensure}; + +use super::{CollectionMeta, Described, Embedder, Hit, ModelIdentity, Point, VectorIndex}; + +pub(crate) fn identity(digest: &str) -> ModelIdentity { + ModelIdentity { name: "model".into(), digest: digest.into(), dimensions: 4 } +} + +/// A door a test holds shut: `embed` announces it has arrived and waits. +#[derive(Clone, Default)] +pub(crate) struct Gate(Arc<(Mutex<(u32, bool)>, Condvar)>); + +impl Gate { + pub(crate) fn shut() -> Self { + Self::default() + } + + /// Blocks until `count` calls to `embed` have arrived at the gate. + pub(crate) fn wait_arrived(&self, count: u32) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let mut state = self.0.0.lock().unwrap(); + while state.0 < count { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + assert!(!left.is_zero(), "{count} calls never arrived at the gate; {} did", state.0); + state = self.0.1.wait_timeout(state, left).unwrap().0; + } + } + + /// Shuts the door again and forgets who arrived, so a test that let start-up + /// work through can hold the next call. + pub(crate) fn shut_again(&self) { + let mut state = self.0.0.lock().unwrap(); + *state = (0, false); + } + + pub(crate) fn open(&self) { + self.0.0.lock().unwrap().1 = true; + self.0.1.notify_all(); + } + + fn pass(&self) { + let mut state = self.0.0.lock().unwrap(); + state.0 += 1; + self.0.1.notify_all(); + while !state.1 { + state = self.0.1.wait(state).unwrap(); + } + } +} + +pub(crate) struct EmbedderState { + pub(crate) identity: ModelIdentity, + pub(crate) down: bool, + /// The texts of every completed call, in order. + pub(crate) embedded: Vec>, + /// The bound each completed call was given, in order. + pub(crate) bounds: Vec, + /// The length of the vectors it answers with, where that is not the + /// identity's. + pub(crate) vector_length: Option, + /// The bound each identity read was given, in order. + pub(crate) identity_bounds: Vec, + /// Whether an identity read is never answered: it fails when its bound + /// has passed, as an adapter that honours its bound does. + pub(crate) hang_identity: bool, + /// How long an identity read takes to answer, when it answers. + pub(crate) identity_delay: Duration, +} + +#[derive(Clone)] +pub(crate) struct FakeEmbedder { + pub(crate) state: Arc>, + gate: Option, +} + +impl FakeEmbedder { + pub(crate) fn new(digest: &str) -> Self { + let state = EmbedderState { identity: identity(digest), down: false, embedded: Vec::new(), bounds: Vec::new(), vector_length: None, identity_bounds: Vec::new(), hang_identity: false, identity_delay: Duration::ZERO }; + Self { state: Arc::new(Mutex::new(state)), gate: None } + } + + pub(crate) fn behind(mut self, gate: &Gate) -> Self { + self.gate = Some(gate.clone()); + self + } + + pub(crate) fn texts(&self) -> Vec { + self.state.lock().unwrap().embedded.iter().flatten().cloned().collect() + } +} + +impl Embedder for FakeEmbedder { + fn model_identity(&mut self, within: Duration) -> Result { + let mut state = self.state.lock().unwrap(); + state.identity_bounds.push(within); + if state.hang_identity { + drop(state); + std::thread::sleep(within); + bail!("the embedder did not answer within {within:?}"); + } + if state.down { + bail!("the embedder is down"); + } + let (identity, delay) = (state.identity.clone(), state.identity_delay); + drop(state); + std::thread::sleep(delay); + Ok(identity) + } + + fn embed(&mut self, texts: &[String], within: Duration) -> Result>> { + if let Some(gate) = &self.gate { + gate.pass(); + } + let mut state = self.state.lock().unwrap(); + if state.down { + bail!("the embedder is down"); + } + state.embedded.push(texts.to_vec()); + state.bounds.push(within); + let dimensions = state.vector_length.unwrap_or(state.identity.dimensions as usize); + Ok(texts.iter().map(|text| (0..dimensions).map(|at| (text.len() + at) as f32).collect()).collect()) + } +} + +pub(crate) struct Stored { + pub(crate) label: Described, + pub(crate) points: BTreeMap, +} + +#[derive(Default)] +pub(crate) struct IndexState { + pub(crate) collections: BTreeMap, + pub(crate) down: bool, + pub(crate) recreated: Vec<(String, CollectionMeta)>, + /// The point ids of every upsert call, in order. + pub(crate) upserts: Vec>, + /// What every search answers, whatever the vector: the test decides the + /// candidates, as a real index would decide them from vectors. + pub(crate) hits: Vec, + /// The collection, vector length and limit of every search, in order. + pub(crate) searches: Vec<(String, usize, u32)>, + /// How many times every point id was listed: only a reconciliation does. + pub(crate) listed: u32, + /// Whether a search fails at the vector store, the rest of it working. + pub(crate) fail_search: bool, + /// The bound each describe and each search was given, in order, by call. + pub(crate) bounds: Vec<(&'static str, Duration)>, + /// How many points the collection held when each search reached it. + pub(crate) held_when_searched: Vec, +} + +#[derive(Clone, Default)] +pub(crate) struct FakeIndex { + pub(crate) state: Arc>, +} + +impl FakeIndex { + pub(crate) fn holding(&self, collection: &str) -> BTreeSet { + let state = self.state.lock().unwrap(); + state.collections.get(collection).map(|stored| stored.points.keys().cloned().collect()).unwrap_or_default() + } + + /// Plants a collection this organ did not make, or made for another mind. + pub(crate) fn plant(&self, collection: &str, label: Described) { + self.state.lock().unwrap().collections.insert(collection.into(), Stored { label, points: BTreeMap::new() }); + } +} + +impl VectorIndex for FakeIndex { + fn describe(&mut self, collection: &str, within: Duration) -> Result { + let mut state = self.state.lock().unwrap(); + state.bounds.push(("describe", within)); + if state.down { + bail!("the vector store is down"); + } + Ok(state.collections.get(collection).map_or(Described::Absent, |stored| stored.label.clone())) + } + + fn recreate(&mut self, collection: &str, meta: &CollectionMeta) -> Result<()> { + let mut state = self.state.lock().unwrap(); + if state.down { + bail!("the vector store is down"); + } + state.recreated.push((collection.into(), meta.clone())); + state + .collections + .insert(collection.into(), Stored { label: Described::Labelled(meta.clone()), points: BTreeMap::new() }); + Ok(()) + } + + fn ids(&mut self, collection: &str) -> Result> { + let mut state = self.state.lock().unwrap(); + if state.down { + bail!("the vector store is down"); + } + state.listed += 1; + Ok(state.collections.get(collection).map(|stored| stored.points.keys().cloned().collect()).unwrap_or_default()) + } + + fn upsert(&mut self, collection: &str, points: &[Point]) -> Result<()> { + let mut state = self.state.lock().unwrap(); + if state.down { + bail!("the vector store is down"); + } + state.upserts.push(points.iter().map(|point| point.id.clone()).collect()); + let Some(stored) = state.collections.get_mut(collection) else { bail!("no collection {collection}") }; + for point in points { + stored.points.insert(point.id.clone(), point.clone()); + } + Ok(()) + } + + fn search(&mut self, collection: &str, vector: &[f32], limit: u32, within: Duration) -> Result> { + let mut state = self.state.lock().unwrap(); + state.bounds.push(("search", within)); + if state.down { + bail!("the vector store is down"); + } + state.searches.push((collection.into(), vector.len(), limit)); + ensure!(!state.fail_search, "the vector store failed the search"); + ensure!(state.collections.contains_key(collection), "no collection {collection}"); + let held = state.collections[collection].points.len(); + state.held_when_searched.push(held); + Ok(state.hits.clone()) + } +} + +/// A working embedder and vector store, for tests whose subject is elsewhere. +pub(crate) fn pair() -> (FakeEmbedder, FakeIndex) { + (FakeEmbedder::new("d1"), FakeIndex::default()) +} diff --git a/crates/huginn-daemon/src/index/mod.rs b/crates/huginn-daemon/src/index/mod.rs new file mode 100644 index 0000000..30a7b4c --- /dev/null +++ b/crates/huginn-daemon/src/index/mod.rs @@ -0,0 +1,1095 @@ +//! The semantic index's write side: a projection of the mind into a vector +//! collection, kept by one worker thread. +//! +//! The mind is the truth and this is derived. `huginn-mind` decides what a +//! document's text is (`IndexEntry`); this module owns when it is embedded and +//! written, and nothing here is stored in the mind: the worker's health lives +//! in memory and a restart recomputes it. +//! +//! One owner per decision. `Projector::reconcile` is the only place a point is +//! judged missing (the mind against the collection, by set difference, at +//! startup), and the worker is the only writer of points. Live admissions +//! reach the worker through `WorkerSink::committed`, which builds the entries +//! (local and cheap), leaves them in an inbox and returns: admission never +//! waits on an embedder or a vector store. Live entries and startup entries +//! share one embed-and-upsert routine, `Projector::flush_batch`. +//! +//! The ports are traits so the projector is tested with in-memory fakes: +//! `Embedder` (`ollama`) and `VectorIndex` (`qdrant`). Those two files are the +//! xenos boundary; nothing else in the organ speaks HTTP or JSON. +//! +//! Reconciliation is not a startup event. After any failed step the worker +//! reconciles again before it writes anything else: a collection lost while the +//! daemon runs is recreated and refilled, and a model re-pulled with other +//! dimensions rebuilds the collection, all without a restart. Two checks keep +//! that true without a failure to announce it. Before every flush the worker +//! re-reads the model's identity (name, digest, dimensions; about 0.3 s against +//! Ollama) and, if it is not the one the collection was reconciled under, +//! forgets the reconciliation instead of writing: a model re-pulled under the +//! same dimensions never mixes its vectors with the old model's. The worker +//! forgets the reconciliation every `Backoff::recheck` (60 s by default) on a +//! schedule that neither admissions nor searches move, which re-reads the model, +//! describes the collection and lists its points: a collection lost while nothing was being written is found within +//! that interval, and until then `Current` is what the worker last verified. +//! There is one judge either way: `reconcile`. Refilling needs +//! the text, so the projector keeps every entry it was given (`known`) beside +//! the ids it still owes (`wanted`): the whole indexable mind's text is held in +//! memory for the process's life. +//! +//! A collection belongs to a mind, not to a name. It is labelled with the +//! instance and with the mind's genesis receipt id (`Mind::genesis_receipt_id`), +//! and a collection labelled for another mind of the same instance name, or +//! for none, is refused and left untouched. A mind with no receipt yet has no +//! identity, so nothing is reconciled until its first admission. +//! +//! Stated limits. +//! +//! - The inbox is unbounded: it holds at most the documents admitted while the +//! index is unreachable, each of which is also in the mind. +//! - Vectors of the wrong length are refused rather than written. +//! - The real input limit is the embedding model's context, not the 16 KiB +//! `INDEX_TEXT_MAX_BYTES` bound. `qwen3-embedding:0.6b` has a 4096 token +//! context, so dense text is cut at about 4k tokens and the tail of a long +//! document is not searchable. Ollama truncates silently by default; its +//! `truncate: false` option refuses over-context input instead, which would +//! fail the whole batch forever on one dense document, so it is not set. +//! There is no chunking. +//! - Points are never deleted. Withdrawn and superseded documents are indexed +//! on purpose and the payload carries no status. The semantic read therefore +//! takes only candidate ids and scores from a search: `Daemon::finish` joins +//! them back through `Mind::rank`, which drops ids the mind does not hold and +//! lets the selection decide the rest, in-force derivation included. +//! - A search runs on the worker between batches, so it waits for the batch in +//! flight, and at most one runs between two steps of the writes. The serve +//! loop never waits: it holds the reply and answers when the worker has +//! (`IndexSink::search_deadline` bounds the wait), and takes back a search +//! it has stopped waiting for. At most `SEARCHES_QUEUED_MAX` wait; one more is +//! refused. A search whose embed is already running cannot be recalled, so it +//! can hold the writes off for as long as it has left: the ticket carries the +//! moment `Backoff::search_deadline` after it was asked, and every call the search +//! makes (the model's identity, the collection's label, the embed, the +//! query) is bounded by what remains of it. +//! - A search reads its own writes. Admissions and searches meet under one +//! lock, and the worker judges a search and takes it in one critical +//! section, admissions first: a search waits in the queue while anything +//! ahead of it is owed (documents in the inbox, documents to write, a first +//! reconciliation to make), and is answered once the index has caught up. Lag +//! is not a rebuild and refuses nothing, and a mind's first build is lag: +//! there is no earlier index to protect. Under a steady stream of admissions +//! a search may never see the index caught up; its deadline then answers it. +//! - A search is refused only while the index is being rebuilt or cannot be +//! asked: `Reconciling` (a collection made again because the model, its +//! digest or the text version changed or because it was lost, until every +//! document it owes is written again), `Failing` or `Refused`. The refusal +//! is an `Unavailable` that names the state and how many documents are +//! pending; it is never answered +//! from a collection being rebuilt. A search that fails for its own reasons +//! (a timeout, a store error) fails that search alone and the +//! reconciliation stands. Only what the check-then-embed finds changed (the +//! model, the collection's label) forgets it. + +pub mod ollama; +pub mod qdrant; + +#[cfg(test)] +pub(crate) mod fakes; + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail, ensure}; +use huginn_mind::epiphany_pipeline::{PipelineKind, PipelineRef, Short, Slug}; +use huginn_mind::wire::IndexStatus; +use huginn_mind::{INDEX_TEXT_VERSION, IndexEntry, Mind, MindStore}; +use sha2::{Digest, Sha256}; + +use crate::daemon::{Hits, IndexSink, SearchTicket}; +use ollama::QUERY_INSTRUCTION; + +/// The `managed_by` a collection carries when this organ made it. +pub const MANAGED_BY: &str = "huginn"; + +/// Documents embedded and written per call. +pub const BATCH: usize = 32; + +/// How long a search may take, queue wait included, by default +/// (`Backoff::search_deadline`). The sink stamps it on every search when it is +/// asked, and the serve loop asks the sink for it (`IndexSink::search_deadline`), +/// so the loop's wait and the worker's bound are one value. +pub const SEARCH_DEADLINE: Duration = Duration::from_secs(30); + +/// The bound on a bulk index-flush embed, the identity and label reads around +/// it, and the reconciliation's identity read. A cold model takes seconds to +/// load. It does not block serving: the serve loop never waits on the worker, +/// and a search queued behind a flush is answered by its own deadline. +pub const BULK_EMBED_TIMEOUT: Duration = Duration::from_secs(300); + +/// The bound on a call to the vector store that is not a search: the +/// adapter's default. +pub const INDEX_CALL_TIMEOUT: Duration = Duration::from_secs(60); + +/// The most candidates one search asks the index for. +pub const OVERSAMPLE_MAX: u32 = 200; + +/// What an embedder is: the model by name and by digest, and the length of the +/// vectors it returns. Compatibility of a collection is decided on this and +/// on nothing else: never on the address the embedder was reached at. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ModelIdentity { + pub name: String, + pub digest: String, + pub dimensions: u32, +} + +/// What a collection says about how it was made. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CollectionMeta { + pub managed_by: String, + pub instance: String, + /// The mind's genesis receipt id: the identity the instance name lacks. + pub mind: String, + pub model: String, + pub model_digest: String, + pub dimensions: u32, + pub index_text_version: u32, +} + +/// What the vector store holds under a collection's name. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Described { + Absent, + /// A collection that carries no metadata this organ wrote. + Unlabelled, + Labelled(CollectionMeta), +} + +/// What travels beside a vector. The id of the point is derived from `doc_id`. +#[derive(Clone, Debug, PartialEq)] +pub struct PointPayload { + pub doc_id: String, + pub kind: String, + pub root: String, + pub ordinal: u64, + pub text_sha256: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Point { + pub id: String, + pub vector: Vec, + pub payload: PointPayload, +} + +/// One candidate a vector search returned: the document it names, by the id and +/// kind the payload carries, and how near it scored. Nothing else about the +/// document is the index's to say. +#[derive(Clone, Debug, PartialEq)] +pub struct Hit { + pub doc_id: String, + pub kind: String, + pub score: f32, +} + +pub trait Embedder { + /// The model's identity, or an error once `within` has passed. + fn model_identity(&mut self, within: Duration) -> Result; + /// One vector per text, in order, or an error once `within` has passed. + fn embed(&mut self, texts: &[String], within: Duration) -> Result>>; +} + +pub trait VectorIndex { + /// What the collection says about itself, or an error once `within` has + /// passed. + fn describe(&mut self, collection: &str, within: Duration) -> Result; + /// Drops the collection if it exists and creates it empty with this + /// metadata. + fn recreate(&mut self, collection: &str, meta: &CollectionMeta) -> Result<()>; + /// Every point id the collection holds. + fn ids(&mut self, collection: &str) -> Result>; + fn upsert(&mut self, collection: &str, points: &[Point]) -> Result<()>; + /// The `limit` nearest points to `vector`, best first, with no filter: which + /// documents may be shown is the mind's to decide. An error once `within` + /// has passed. + fn search(&mut self, collection: &str, vector: &[f32], limit: u32, within: Duration) -> Result>; +} + +/// The mind's collection: one per instance. +pub fn collection_name(instance: &Slug) -> String { + format!("huginn_mind_{}", instance.0) +} + +/// The point id of a document: the first sixteen bytes of the SHA-256 of the +/// document id, shaped as a version 5 UUID, which is the convention voidbot's +/// collections use. The real id travels in the payload. +pub fn point_id(document_id: &str) -> String { + let mut digest: [u8; 32] = Sha256::digest(document_id.as_bytes()).into(); + digest[6] = (digest[6] & 0x0f) | 0x50; + digest[8] = (digest[8] & 0x3f) | 0x80; + let hex: String = digest[..16].iter().map(|byte| format!("{byte:02x}")).collect(); + format!("{}-{}-{}-{}-{}", &hex[0..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..32]) +} + +/// Why a step did not finish. Both are retried; they differ in what the +/// operator is told. +#[derive(Debug)] +pub enum StepError { + /// The collection is another writer's and is left alone. + Refused(String), + /// The embedder or the vector store did not answer, or answered wrongly. + Unavailable(anyhow::Error), +} + +impl From for StepError { + fn from(error: anyhow::Error) -> Self { + Self::Unavailable(error) + } +} + +impl std::fmt::Display for StepError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Refused(reason) => formatter.write_str(reason), + Self::Unavailable(error) => write!(formatter, "{error:#}"), + } + } +} + +/// Whether a step left work to do. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Advance { + Progressed, + Idle, +} + +/// The time one operation has left. Every call the operation makes is bounded +/// by what remains, not by a bound of its own, so their sum cannot outlast it. +struct Budget { + end: Instant, + total: Duration, +} + +impl Budget { + fn new(total: Duration) -> Self { + Self { end: Instant::now() + total, total } + } + + fn left(&self) -> Result { + let left = self.end.saturating_duration_since(Instant::now()); + ensure!(!left.is_zero(), "the time allowed ({:?}) has run out", self.total); + Ok(left) + } +} + +/// Whether the collection holds an index worth protecting, and whether it is +/// being made again. The state that marks a rebuild lasts until the entries it +/// owes have been written; nothing else clears it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Build { + /// No reconciliation has yet found or made this mind's collection. Its + /// first build has nothing stale to protect, so it is lag. + Unbuilt, + /// The collection was built by this model and only lags the mind. + Built, + /// The collection was made again (another model, digest or text version, + /// or lost while the worker ran) and is refilled from what the projector + /// knows: until every entry it owes is written, it answers nothing. + Rebuilding, +} + +/// The projection's state: the entries not yet known to be in the collection, +/// and, once reconciled, the length of vector it accepts. +pub struct Projector { + embedder: E, + index: V, + instance: String, + /// `None` until the mind's first admission; nothing is reconciled before. + mind: Option, + collection: String, + /// Every entry ever given, by document id: refilling a lost collection + /// needs text the mind's writes have already gone past. + known: BTreeMap, + /// The ids in `known` whose points the collection may lack. + wanted: BTreeSet, + /// The model the collection was reconciled under; `None` until it is, and + /// again whenever that reconciliation is forgotten. + reconciled: Option, + build: Build, +} + +impl Projector { + pub fn new(embedder: E, index: V, instance: &Slug, mind: Option) -> Self { + Self { + embedder, + index, + instance: instance.0.clone(), + mind, + collection: collection_name(instance), + known: BTreeMap::new(), + wanted: BTreeSet::new(), + reconciled: None, + build: Build::Unbuilt, + } + } + + /// The mind's identity, once it has one. It never changes after that. + fn identify(&mut self, mind: String) { + if self.mind.is_none() { + self.mind = Some(mind); + } + } + + /// Entries the collection may lack. Idempotent: an entry already wanted is + /// the same document. + pub fn want(&mut self, entries: impl IntoIterator) { + for entry in entries { + self.wanted.insert(entry.id.id.0.clone()); + self.known.insert(entry.id.id.0.clone(), entry); + } + } + + pub fn pending(&self) -> u32 { + u32::try_from(self.wanted.len()).unwrap_or(u32::MAX) + } + + /// One step: reconcile first, then one batch at a time until nothing is + /// wanted. + /// + /// Any failure forgets the reconciliation: the next attempt starts from + /// the model and the collection as they are then. + pub fn advance(&mut self) -> Result { + let stepped = self.step(); + if stepped.is_err() { + self.reconciled = None; + } + stepped + } + + /// `advance` after forgetting the reconciliation on purpose: the idle + /// worker's periodic look at whether the model and the collection are still + /// what it last verified. + pub fn recheck(&mut self) -> Result { + self.reconciled = None; + self.advance() + } + + /// Whether the mind has an identity and the collection has not been + /// reconciled under the model: `advance` has reconciling to do. + pub fn stale(&self) -> bool { + self.mind.is_some() && self.reconciled.is_none() + } + + fn step(&mut self) -> Result { + if self.mind.is_none() { + return Ok(Advance::Idle); + } + if self.reconciled.is_none() { + self.reconcile()?; + return Ok(Advance::Progressed); + } + if self.wanted.is_empty() { + return Ok(Advance::Idle); + } + self.flush_batch()?; + Ok(Advance::Progressed) + } + + /// Re-reads the model's identity and compares it with the one the + /// collection was reconciled under. A different model, digest or length + /// forgets the reconciliation, so the next step reconciles (and rebuilds) + /// instead of writing or searching with vectors the collection cannot mix. + fn model_unchanged(&mut self, budget: &Budget) -> Result { + let now = self.embedder.model_identity(budget.left()?).context("re-reading the embedding model's identity")?; + if self.reconciled.as_ref() == Some(&now) { + return Ok(true); + } + self.reconciled = None; + Ok(false) + } + + /// The only place a point is judged missing. Reads the model identity, + /// makes the collection agree with it (creating it, or rebuilding it when + /// the model, its digest, its dimensions or the text version differ), + /// refuses a collection that is not this mind's, then makes `wanted` every + /// known entry whose point the collection does not hold. + pub fn reconcile(&mut self) -> Result<(), StepError> { + let Some(mind) = self.mind.clone() else { + return Err(StepError::Unavailable(anyhow::anyhow!("the mind has no identity yet"))); + }; + let identity = + self.embedder.model_identity(BULK_EMBED_TIMEOUT).context("reading the embedding model's identity")?; + let wanted = self.collection_meta(&mind, &identity); + match self.index.describe(&self.collection, INDEX_CALL_TIMEOUT).context("describing the collection")? { + Described::Absent => { + // A collection lost while the worker ran was an index; one that + // was never made is a first build. + let build = if self.build == Build::Unbuilt { Build::Built } else { Build::Rebuilding }; + self.recreate(&wanted, build)?; + } + Described::Unlabelled => { + return Err(StepError::Refused(format!( + "collection {} exists and carries no Huginn metadata; it is not this organ's to rewrite", + self.collection + ))); + } + Described::Labelled(found) + if found.managed_by != MANAGED_BY || found.instance != self.instance || found.mind != mind => + { + return Err(StepError::Refused(format!( + "collection {} is managed by {:?} for instance {:?}, mind {:?}; this organ serves instance {:?}, mind {:?}", + self.collection, found.managed_by, found.instance, found.mind, self.instance, mind + ))); + } + Described::Labelled(found) if found != wanted => self.recreate(&wanted, Build::Rebuilding)?, + Described::Labelled(_) if self.build == Build::Unbuilt => self.build = Build::Built, + Described::Labelled(_) => {} + } + let present = self.index.ids(&self.collection).context("listing the collection's points")?; + self.wanted = self.known.keys().filter(|document_id| !present.contains(&point_id(document_id))).cloned().collect(); + self.reconciled = Some(identity); + self.settle(); + Ok(()) + } + + /// What the collection must say about itself to be this mind's, under + /// this model. + fn collection_meta(&self, mind: &str, identity: &ModelIdentity) -> CollectionMeta { + CollectionMeta { + managed_by: MANAGED_BY.into(), + instance: self.instance.clone(), + mind: mind.to_owned(), + model: identity.name.clone(), + model_digest: identity.digest.clone(), + dimensions: identity.dimensions, + index_text_version: INDEX_TEXT_VERSION, + } + } + + /// Makes the collection again. `build` is what it is once made, recorded + /// with the collection so a failure before the refill leaves it recorded. + fn recreate(&mut self, meta: &CollectionMeta, build: Build) -> Result<()> { + self.index.recreate(&self.collection, meta).context("creating the collection")?; + self.build = build; + Ok(()) + } + + /// A rebuild ends when the entries it owes are all written: the one place + /// it is cleared. + fn settle(&mut self) { + if self.build == Build::Rebuilding && self.wanted.is_empty() { + self.build = Build::Built; + } + } + + /// The one embed-and-upsert routine, for startup and live entries alike: + /// the first `BATCH` wanted entries are embedded, written, and only then + /// no longer wanted. + pub fn flush_batch(&mut self) -> Result<(), StepError> { + self.flush(&Budget::new(BULK_EMBED_TIMEOUT)).map_err(StepError::Unavailable) + } + + fn flush(&mut self, budget: &Budget) -> Result<()> { + let dimensions = self.reconciled.as_ref().context("flush before reconcile")?.dimensions as usize; + let batch: Vec = + self.wanted.iter().take(BATCH).filter_map(|document_id| self.known.get(document_id)).cloned().collect(); + if batch.is_empty() { + return Ok(()); + } + let texts: Vec = batch.iter().map(|entry| entry.text.clone()).collect(); + // A model that changed leaves nothing written and the reconciliation + // forgotten: the next step reconciles and rebuilds. + let Ok(vectors) = self.embed_verified(&texts, budget)? else { return Ok(()) }; + ensure!(vectors.len() == batch.len(), "the embedder returned {} vectors for {} texts", vectors.len(), batch.len()); + let mut points = Vec::with_capacity(batch.len()); + for (entry, vector) in batch.iter().zip(vectors) { + ensure!( + vector.len() == dimensions, + "the embedder returned a vector of {} for {} where the collection holds {dimensions}", + vector.len(), + entry.id.id.0 + ); + points.push(Point { + id: point_id(&entry.id.id.0), + vector, + payload: PointPayload { + doc_id: entry.id.id.0.clone(), + kind: entry.id.kind.name().into(), + root: entry.root.clone(), + ordinal: entry.ordinal, + text_sha256: entry.text_sha256.clone(), + }, + }); + } + self.index.upsert(&self.collection, &points).context("writing points")?; + for entry in &batch { + self.wanted.remove(&entry.id.id.0); + } + self.settle(); + Ok(()) + } + + /// The one check-then-embed, every call bounded by what `budget` has left: + /// reads the model's identity and the collection's label, embeds, reads the + /// identity again, and gives the vectors back only if the model is still + /// the one the collection was reconciled under. A model that changed + /// forgets the reconciliation and answers `Err(when)`; a collection that + /// is no longer this mind's, built as reconciled, is an error and forgets + /// it too. A call that fails or times out forgets nothing. Search and + /// flush both embed through here, so neither compares or writes vectors + /// made by a model the collection cannot mix. + fn embed_verified(&mut self, texts: &[String], budget: &Budget) -> Result>, &'static str>> { + let (Some(mind), Some(reconciled)) = (self.mind.clone(), self.reconciled.clone()) else { + bail!("the mind has no identity yet, so it has no index"); + }; + if !self.model_unchanged(budget)? { + return Ok(Err("since the collection was built")); + } + let described = self.index.describe(&self.collection, budget.left()?).context("describing the collection")?; + if described != Described::Labelled(self.collection_meta(&mind, &reconciled)) { + self.reconciled = None; + bail!("collection {} is no longer the one this mind's index was built as: {described:?}", self.collection); + } + let vectors = self.embedder.embed(texts, budget.left()?).context("embedding")?; + if !self.model_unchanged(budget)? { + return Ok(Err("while the texts were embedded")); + } + Ok(Ok(vectors)) + } + + /// The candidates a query text finds: the text is embedded with the model's + /// query instruction and the collection is searched, with no filter and an + /// oversample of `min(top_k * 4, 200)`, because the mind will drop most of + /// what the index cannot know is not in force. A hit naming a kind this + /// organ has no name for is logged and left out. + /// + /// A search is refused while the index is being rebuilt or cannot be asked + /// (`unsearchable`), naming the state and how many documents are pending. + /// Catching up on lag is the worker's: it serves a search only once the + /// documents admitted before it are written. The answer is also only from + /// a collection that is still this mind's, built by the model that is + /// still the embedder: the identity is read before and after the query is + /// embedded and the label before it, and the query is refused, with the + /// reconciliation forgotten, if either disagrees with what the collection + /// was reconciled under. Every call is bounded by what remains of + /// `within`. A search that fails for any other reason (a timeout, a store + /// error) fails alone and leaves the reconciliation standing. + pub fn search(&mut self, text: &str, top_k: u32, within: Duration) -> Result { + if let Some(why) = unsearchable(&self.progress_status()) { + bail!("{why}"); + } + let budget = Budget::new(within); + let Some(reconciled) = self.reconciled.clone() else { + bail!("the index has not been built yet"); + }; + let dimensions = reconciled.dimensions as usize; + let query = format!("Instruct: {QUERY_INSTRUCTION}\nQuery: {text}"); + let mut vectors = match self.embed_verified(&[query], &budget)? { + Ok(vectors) => vectors, + Err(when) => { + bail!("the embedding model changed {when}: {}", unsearchable(&self.progress_status()).unwrap_or_default()) + } + }; + ensure!(vectors.len() == 1, "the embedder returned {} vectors for one query", vectors.len()); + let vector = vectors.remove(0); + ensure!(vector.len() == dimensions, "the embedder returned a query vector of {} where the collection holds {dimensions}", vector.len()); + let limit = top_k.saturating_mul(4).min(OVERSAMPLE_MAX); + let found = self.index.search(&self.collection, &vector, limit, budget.left()?)?; + let mut hits = Vec::with_capacity(found.len()); + for hit in found { + match PipelineKind::ALL.into_iter().find(|kind| kind.name() == hit.kind) { + Some(kind) => hits.push((PipelineRef { kind: *kind, id: Short(hit.doc_id) }, hit.score)), + None => eprintln!("huginn: the index returned {} under the unknown kind {:?}; it is left out", hit.doc_id, hit.kind), + } + } + Ok(hits) + } + + /// The health after a step that did not fail. + pub fn progress_status(&self) -> IndexStatus { + match (&self.reconciled, self.pending()) { + (None, 0) if self.mind.is_none() => IndexStatus::Current, + // The first build of an identified mind has nothing stale to + // protect: the wait for it is lag. + (None, pending) if self.mind.is_some() && self.build == Build::Unbuilt => IndexStatus::Behind { pending }, + (None, pending) => IndexStatus::Reconciling { pending }, + (Some(_), 0) => IndexStatus::Current, + (Some(_), pending) if self.build == Build::Rebuilding => IndexStatus::Reconciling { pending }, + (Some(_), pending) => IndexStatus::Behind { pending }, + } + } + + /// The health after `attempts` consecutive failures, the last of them + /// `error`. + pub fn failure_status(&self, error: &StepError, attempts: u32) -> IndexStatus { + let pending = self.pending(); + match error { + StepError::Refused(reason) => IndexStatus::Refused { pending, attempts, reason: reason.clone() }, + StepError::Unavailable(error) => IndexStatus::Failing { pending, attempts, error: format!("{error:#}") }, + } + } +} + +/// The worker's clock: the retry delay starts at `initial`, doubles per +/// consecutive failure and stops at `max`; an idle worker verifies the model +/// and the collection again every `recheck`; a search is worth waiting for +/// `search_deadline` after it was asked. +#[derive(Clone, Copy, Debug)] +pub struct Backoff { + pub initial: Duration, + pub max: Duration, + pub recheck: Duration, + pub search_deadline: Duration, +} + +impl Default for Backoff { + fn default() -> Self { + Self { initial: Duration::from_secs(30), max: Duration::from_secs(600), recheck: Duration::from_secs(60), search_deadline: SEARCH_DEADLINE } + } +} + +impl Backoff { + pub fn after(&self, delay: Duration) -> Duration { + (delay * 2).min(self.max) + } +} + +/// The most searches the worker holds waiting. A search asked when this many +/// wait is refused, by name, at once: the queue is bounded by the asker being +/// told, not by the worker's patience. +pub const SEARCHES_QUEUED_MAX: usize = 16; + +/// A search waiting for the worker: what was asked, and the moment nobody is +/// waiting for the answer any more. Every call the search makes is bounded by +/// what remains until `due`. +struct Queued { + ticket: SearchTicket, + text: String, + top_k: u32, + due: Instant, +} + +struct Shared { + inbox: Vec, + /// The mind's identity, once a commit has revealed it to a mind that had none. + mind: Option, + status: IndexStatus, + closed: bool, + /// Searches started and not yet taken by the worker, at most + /// `SEARCHES_QUEUED_MAX`; the serve loop takes back one it has stopped + /// waiting for. + searches: VecDeque, + /// Searches the worker finished and the serve loop has not collected. + found: Vec<(SearchTicket, Result)>, + next_ticket: u64, +} + +type Lock = Arc<(Mutex, Condvar)>; + +fn lock(shared: &Lock) -> MutexGuard<'_, Shared> { + shared.0.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// How long the worker waits, and what ends the wait early. +enum Wait { + /// Idle: until something arrives (an entry, the mind's identity, a search) + /// or `until`. The deadline is the caller's, fixed when it was set: work + /// that wakes the worker does not move it. + Work { until: Instant }, + /// Backing off from a failure: the whole delay, whatever arrives. A failing + /// index is retried on its schedule, not once per admission. + Backoff(Duration), +} + +fn wait_for_work(shared: &Lock, wait: Wait) { + let (deadline, wakes_on_work) = match wait { + Wait::Work { until } => (until, true), + Wait::Backoff(delay) => (Instant::now() + delay, false), + }; + let mut guard = lock(shared); + loop { + if guard.closed { + return; + } + if wakes_on_work && (!guard.inbox.is_empty() || guard.mind.is_some() || !guard.searches.is_empty()) { + return; + } + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return; + } + guard = shared.1.wait_timeout(guard, left).unwrap_or_else(|poisoned| poisoned.into_inner()).0; + } +} + +fn publish(shared: &Lock, status: IndexStatus) { + lock(shared).status = status; + shared.1.notify_all(); +} + +/// The status with what the worker has not yet taken from the inbox added to +/// its pending count. +fn including(status: &IndexStatus, queued: u32) -> IndexStatus { + use IndexStatus as S; + match status { + S::Reconciling { pending } => S::Reconciling { pending: pending + queued }, + S::Current if queued == 0 => S::Current, + S::Current => S::Behind { pending: queued }, + S::Behind { pending } => S::Behind { pending: pending + queued }, + S::Failing { pending, attempts, error } => { + S::Failing { pending: pending + queued, attempts: *attempts, error: error.clone() } + } + S::Refused { pending, attempts, reason } => { + S::Refused { pending: pending + queued, attempts: *attempts, reason: reason.clone() } + } + } +} + +/// Why a status cannot answer a search, or `None` when it can: the one +/// predicate for asking and for answering. An index being rebuilt +/// (`Reconciling`) holds a collection that is not the model's yet, and one +/// that is failing or refused cannot be asked. `Behind` is lag, not a +/// rebuild: the worker writes what was admitted before a search and then +/// answers it. The reason names the state and how many documents are pending. +fn unsearchable(status: &IndexStatus) -> Option { + use IndexStatus as S; + match status { + S::Current | S::Behind { .. } => None, + S::Reconciling { pending } => Some(format!( + "the semantic index is being rebuilt: reconciling with the model and the collection (pending: {pending})" + )), + S::Failing { pending, error, .. } => Some(format!("the semantic index is failing: {error} (pending: {pending})")), + S::Refused { pending, reason, .. } => Some(format!("the semantic index is refused: {reason} (pending: {pending})")), + } +} + +/// Runs the search at the front of the queue, if any, and leaves its answer +/// for the loop to collect. Between batches, never inside one, and only one: +/// the worker returns to its writes after each, so a queue of searches cannot +/// hold the index's writes off for the sum of their embeddings. +/// +/// Admissions come first, and the judgement and the take are one critical +/// section: the inbox is absorbed and the search taken under one guard, so a +/// document acknowledged before the search was asked is wanted when the search +/// is judged, and none can slip in between. A search is taken only with nothing +/// ahead of it (`take_search`). The search's calls are bounded by what remains +/// of the time it was given when it was asked. +fn serve_search(projector: &mut Projector, shared: &Lock) { + let mut guard = lock(shared); + if !absorb_into(projector, &mut guard, None) { + return; + } + #[cfg(test)] + tests::after_absorb(&mut guard); + let Some(Queued { ticket, text, top_k, due }) = take_search(projector, &mut guard) else { return }; + drop(guard); + let found = projector.search(&text, top_k, due.saturating_duration_since(Instant::now())).map_err(|error| format!("{error:#}")); + lock(shared).found.push((ticket, found)); +} + +/// The search at the front of the queue, if it may be served now. Never while +/// a document sits in the inbox, and never while anything ahead of it is +/// owed (documents to write, a reconciliation to make) unless the index +/// cannot be asked at all, in which case the search is taken to be refused: +/// waiting would not change the answer. +fn take_search(projector: &Projector, guard: &mut Shared) -> Option { + if !guard.inbox.is_empty() { + return None; + } + let caught_up = projector.pending() == 0 && !projector.stale(); + if !caught_up && unsearchable(&projector.progress_status()).is_none() { + return None; + } + guard.searches.pop_front() +} + +/// Answers every queued search with why the index cannot: they were accepted +/// while it was healthy and it has failed since. +fn refuse_searches(shared: &Lock, why: &str) { + let mut guard = lock(shared); + while let Some(queued) = guard.searches.pop_front() { + guard.found.push((queued.ticket, Err(why.to_owned()))); + } +} + +/// The worker's take of what arrived since it last looked: the inbox is taken, +/// absorbed and its effect published under one lock. Between taking entries +/// out of the inbox and publishing them as wanted, `status` would otherwise +/// read a stale count with nothing queued, and a reader would see the mind +/// caught up (or a failure short of what it is failing to write) while +/// entries were in the worker's hands. `failing` is the failure being +/// reported and how many attempts it has had: the report stands, with the new +/// pending count, until an attempt succeeds. Returns false once the sink has +/// closed. +fn absorb( + projector: &mut Projector, + shared: &Lock, + failing: Option<(&StepError, u32)>, +) -> bool { + absorb_into(projector, &mut lock(shared), failing) +} + +/// `absorb` under a guard the caller already holds, for the caller that must +/// decide something in the same critical section. +fn absorb_into( + projector: &mut Projector, + guard: &mut Shared, + failing: Option<(&StepError, u32)>, +) -> bool { + if guard.closed { + return false; + } + let mind = guard.mind.take(); + let inbox = std::mem::take(&mut guard.inbox); + if let Some(mind) = mind { + projector.identify(mind); + } + projector.want(inbox); + guard.status = match failing { + None => projector.progress_status(), + Some((error, attempts)) => projector.failure_status(error, attempts), + }; + true +} + +fn work(mut projector: Projector, shared: Lock, backoff: Backoff) { + let mut failing: Option<(StepError, u32)> = None; + let mut delay = backoff.initial; + // When the worker next verifies the model and the collection whether or + // not anything has woken it: an absolute time, moved only by a + // verification, so a steady stream of searches cannot keep pushing it out. + // The first step is a verification (the startup reconciliation), so it is + // due now. + let mut recheck_at = Instant::now(); + loop { + if !absorb(&mut projector, &shared, failing.as_ref().map(|(error, attempts)| (error, *attempts))) { + return; + } + shared.1.notify_all(); + let due = Instant::now() >= recheck_at; + let verifies = due || projector.stale(); + let stepped = if due { projector.recheck() } else { projector.advance() }; + match stepped { + Ok(advance) => { + failing = None; + delay = backoff.initial; + if verifies { + recheck_at = Instant::now() + backoff.recheck; + } + publish(&shared, projector.progress_status()); + serve_search(&mut projector, &shared); + // A model or collection the search found changed has left the + // reconciliation to redo: do it now, not at the next wake. + if advance == Advance::Idle && !projector.stale() { + wait_for_work(&shared, Wait::Work { until: recheck_at }); + } + } + Err(error) => { + let attempts = failing.as_ref().map_or(0, |(_, attempts)| *attempts).saturating_add(1); + eprintln!( + "huginn: the index could not advance (attempt {attempts}, {} pending, retrying in {:?}): {error}", + projector.pending(), + delay + ); + publish(&shared, projector.failure_status(&error, attempts)); + refuse_searches(&shared, &unsearchable(&projector.failure_status(&error, attempts)).unwrap_or_default()); + failing = Some((error, attempts)); + wait_for_work(&shared, Wait::Backoff(delay)); + delay = backoff.after(delay); + } + } + } +} + +/// The daemon's index: a handle on the one worker thread that owns the ports. +/// `committed` builds entries and queues them; `status` reads the worker's +/// health. Dropping the sink tells the worker to stop and does not wait for +/// it: a call in flight ends with its own timeout or with the process. +pub struct WorkerSink { + shared: Lock, + /// Whether the worker has been told the mind's identity. + identified: bool, + /// How long after it was asked a search is worth answering. + deadline: Duration, +} + +impl WorkerSink { + /// Starts the worker. `mind` is the mind's genesis receipt id, `None` for + /// a mind not yet written. `startup` is every indexable document the mind + /// holds now; the worker's first act is to reconcile the collection + /// against it. + pub fn spawn( + embedder: E, + index: V, + instance: &Slug, + mind: Option, + startup: Vec, + backoff: Backoff, + ) -> Self + where + E: Embedder + Send + 'static, + V: VectorIndex + Send + 'static, + { + let identified = mind.is_some(); + let mut projector = Projector::new(embedder, index, instance, mind); + projector.want(startup); + let shared: Lock = Arc::new(( + Mutex::new(Shared { + inbox: Vec::new(), + mind: None, + status: projector.progress_status(), + closed: false, + searches: VecDeque::new(), + found: Vec::new(), + next_ticket: 0, + }), + Condvar::new(), + )); + let worker = Arc::clone(&shared); + std::thread::spawn(move || work(projector, worker, backoff)); + Self { shared, identified, deadline: backoff.search_deadline } + } + + /// Blocks until the health satisfies `predicate`, and returns it. For + /// tests and probes that must observe the worker without a clock; it gives + /// up after `WAIT_STATUS_MAX` in all, however often the health changes, + /// rather than hang a run. + pub fn wait_status(&self, predicate: impl Fn(&IndexStatus) -> bool) -> IndexStatus { + self.wait_status_for(WAIT_STATUS_MAX, predicate) + } + + fn wait_status_for(&self, limit: Duration, predicate: impl Fn(&IndexStatus) -> bool) -> IndexStatus { + let deadline = Instant::now() + limit; + let mut guard = lock(&self.shared); + loop { + let seen = including(&guard.status, u32::try_from(guard.inbox.len()).unwrap_or(u32::MAX)); + if predicate(&seen) { + return seen; + } + let left = deadline.saturating_duration_since(Instant::now()); + assert!(!left.is_zero(), "the index never reached the awaited state; last seen {:?}", guard.status); + guard = self.shared.1.wait_timeout(guard, left).unwrap_or_else(|poisoned| poisoned.into_inner()).0; + } + } +} + +/// The longest `WorkerSink::wait_status` waits, in all. +const WAIT_STATUS_MAX: Duration = Duration::from_secs(10); + +impl Drop for WorkerSink { + fn drop(&mut self) { + lock(&self.shared).closed = true; + self.shared.1.notify_all(); + } +} + +impl IndexSink for WorkerSink { + fn committed(&mut self, mind: &Mind, writes: &[PipelineRef]) -> Result<()> { + let entries = mind.index_entries(Some(writes))?; + let revealed = if self.identified { None } else { mind.genesis_receipt_id()? }; + if entries.is_empty() && revealed.is_none() { + return Ok(()); + } + let mut guard = lock(&self.shared); + guard.inbox.extend(entries); + if let Some(revealed) = revealed { + guard.mind = Some(revealed); + self.identified = true; + } + drop(guard); + self.shared.1.notify_all(); + Ok(()) + } + + fn status(&self) -> IndexStatus { + let guard = lock(&self.shared); + including(&guard.status, u32::try_from(guard.inbox.len()).unwrap_or(u32::MAX)) + } + + /// Queues the search for the worker and returns its ticket. An index that + /// is being rebuilt or cannot be asked is not asked, and neither is a full + /// queue: the refusal is the answer, naming why and how many documents are + /// pending. Documents admitted and not yet written are not a refusal: the + /// search waits behind them. + fn search(&mut self, text: &str, top_k: u32) -> Result { + let mut guard = lock(&self.shared); + let status = including(&guard.status, u32::try_from(guard.inbox.len()).unwrap_or(u32::MAX)); + if let Some(why) = unsearchable(&status) { + return Err(why); + } + if guard.searches.len() >= SEARCHES_QUEUED_MAX { + return Err(format!( + "the semantic index has {} searches waiting and takes no more until it has answered some", + guard.searches.len() + )); + } + let ticket = SearchTicket(guard.next_ticket); + guard.next_ticket += 1; + guard.searches.push_back(Queued { ticket, text: text.to_owned(), top_k, due: Instant::now() + self.deadline }); + drop(guard); + self.shared.1.notify_all(); + Ok(ticket) + } + + fn search_deadline(&self) -> Duration { + self.deadline + } + + fn searched(&mut self) -> Vec<(SearchTicket, Result)> { + std::mem::take(&mut lock(&self.shared).found) + } + + /// A search still queued is taken out of the queue and never embedded. One + /// the worker already holds cannot be recalled; its answer is collected + /// and dropped by the loop, which no longer waits for it. + fn abandon(&mut self, ticket: SearchTicket) { + lock(&self.shared).searches.retain(|queued| queued.ticket != ticket); + } +} + +/// Plain HTTP to one base address, in text: the adapters own what the text +/// means. There is no TLS in this build; the addresses it serves are the +/// private mesh's. +pub(crate) struct Http { + agent: ureq::Agent, + base: String, +} + +#[derive(Clone, Copy)] +pub(crate) enum Method { + Get, + Put, + Post, + Delete, +} + +impl Http { + pub(crate) fn new(base_url: &str, timeout: Duration) -> Self { + let agent: ureq::Agent = + ureq::Agent::config_builder().timeout_global(Some(timeout)).http_status_as_error(false).build().into(); + Self { agent, base: base_url.trim_end_matches('/').to_owned() } + } + + /// The same address under a bound of its own. + pub(crate) fn bounded(&self, timeout: Duration) -> Self { + Self::new(&self.base, timeout) + } + + /// The status and the body, whatever the status is. + pub(crate) fn request(&self, method: Method, path: &str, body: Option<&str>) -> Result<(u16, String)> { + let url = format!("{}{path}", self.base); + let json = "application/json"; + let sent = match method { + Method::Get => self.agent.get(&url).call(), + Method::Delete => self.agent.delete(&url).call(), + Method::Post => self.agent.post(&url).header("content-type", json).send(body.unwrap_or_default()), + Method::Put => self.agent.put(&url).header("content-type", json).send(body.unwrap_or_default()), + }; + let mut response = sent.with_context(|| format!("no answer from {url}"))?; + let status = response.status().as_u16(); + let text = response.body_mut().read_to_string().with_context(|| format!("reading the answer from {url}"))?; + Ok((status, text)) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/huginn-daemon/src/index/ollama.rs b/crates/huginn-daemon/src/index/ollama.rs new file mode 100644 index 0000000..0461729 --- /dev/null +++ b/crates/huginn-daemon/src/index/ollama.rs @@ -0,0 +1,75 @@ +//! The embedder port over Ollama's HTTP API. This file and `qdrant.rs` are the +//! xenos boundary: JSON exists here and nowhere else in the organ. + +use std::time::Duration; + +use anyhow::{Context, Result, bail, ensure}; +use serde_json::{Value, json}; + +use super::{Embedder, Http, Method, ModelIdentity}; + +/// The instruction Qwen3 embedding models take in front of a query (never in +/// front of a document). Cut 11b's semantic read prepends it as +/// `Instruct: \nQuery: `. +pub const QUERY_INSTRUCTION: &str = + "Given a question about a project's pipeline state, retrieve the documents that answer it"; + +/// One model on one Ollama. Every call carries its own bound. +pub struct OllamaEmbedder { + base_url: String, + model: String, +} + +impl OllamaEmbedder { + pub fn new(base_url: &str, model: &str) -> Self { + Self { base_url: base_url.to_owned(), model: model.to_owned() } + } + + fn ask(&self, http: &Http, method: Method, path: &str, body: Option<&Value>) -> Result { + let text = body.map(Value::to_string); + let (status, answer) = http.request(method, path, text.as_deref())?; + ensure!(status == 200, "Ollama answered {status} to {path}: {}", answer.chars().take(300).collect::()); + serde_json::from_str(&answer).with_context(|| format!("Ollama's answer to {path} is not JSON")) + } +} + +impl Embedder for OllamaEmbedder { + /// The configured name, the digest Ollama lists for it, and the model's + /// embedding length. The address is not part of the identity. + fn model_identity(&mut self, within: Duration) -> Result { + let http = Http::new(&self.base_url, within); + let tags = self.ask(&http, Method::Get, "/api/tags", None)?; + let listed = tags["models"].as_array().context("Ollama's tag list has no models")?; + let digest = listed + .iter() + .find(|entry| entry["name"] == self.model.as_str() || entry["model"] == self.model.as_str()) + .and_then(|entry| entry["digest"].as_str()) + .with_context(|| format!("Ollama does not list the model {}", self.model))? + .to_owned(); + let shown = self.ask(&http, Method::Post, "/api/show", Some(&json!({ "model": self.model })))?; + let info = shown["model_info"].as_object().context("Ollama's model description has no model_info")?; + let Some(length) = info.iter().find(|(key, _)| key.ends_with(".embedding_length")).and_then(|(_, value)| value.as_u64()) + else { + bail!("Ollama does not say how long {}'s embeddings are", self.model); + }; + let dimensions = u32::try_from(length).context("an embedding length beyond u32")?; + Ok(ModelIdentity { name: self.model.clone(), digest, dimensions }) + } + + fn embed(&mut self, texts: &[String], within: Duration) -> Result>> { + let bounded = Http::new(&self.base_url, within); + let answer = self.ask(&bounded, Method::Post, "/api/embed", Some(&json!({ "model": self.model, "input": texts })))?; + let embeddings = answer["embeddings"].as_array().context("Ollama's answer has no embeddings")?; + embeddings + .iter() + .map(|vector| { + vector + .as_array() + .context("an embedding is not a list")? + .iter() + .map(|number| number.as_f64().map(|value| value as f32).context("an embedding holds a non-number")) + .collect() + }) + .collect() + } +} diff --git a/crates/huginn-daemon/src/index/qdrant.rs b/crates/huginn-daemon/src/index/qdrant.rs new file mode 100644 index 0000000..f4a1c85 --- /dev/null +++ b/crates/huginn-daemon/src/index/qdrant.rs @@ -0,0 +1,158 @@ +//! The vector-store port over Qdrant's HTTP API. With `ollama.rs`, the xenos +//! boundary: JSON exists here and nowhere else in the organ. + +use std::collections::BTreeSet; +use std::time::Duration; + +use anyhow::{Context, Result, ensure}; +use serde_json::{Value, json}; + +use super::{CollectionMeta, INDEX_CALL_TIMEOUT, Described, Hit, Http, Method, Point, VectorIndex}; + +/// One Qdrant. Every path names a collection this organ was told to own; it +/// never lists or touches another. +pub struct QdrantIndex { + http: Http, +} + +/// The ids fetched per scroll page. +const SCROLL_PAGE: u64 = 1000; + +impl QdrantIndex { + pub fn new(base_url: &str) -> Self { + Self { http: Http::new(base_url, INDEX_CALL_TIMEOUT) } + } + + fn ask(&self, method: Method, path: &str, body: Option<&Value>, ok: &[u16]) -> Result<(u16, Value)> { + self.ask_with(&self.http, method, path, body, ok) + } + + fn ask_with(&self, http: &Http, method: Method, path: &str, body: Option<&Value>, ok: &[u16]) -> Result<(u16, Value)> { + let text = body.map(Value::to_string); + let (status, answer) = http.request(method, path, text.as_deref())?; + ensure!(ok.contains(&status), "Qdrant answered {status} to {path}: {}", answer.chars().take(300).collect::()); + let parsed = if answer.is_empty() { Value::Null } else { serde_json::from_str(&answer).unwrap_or(Value::Null) }; + Ok((status, parsed)) + } +} + +fn metadata_json(meta: &CollectionMeta) -> Value { + json!({ + "managed_by": meta.managed_by, + "instance": meta.instance, + "mind": meta.mind, + "model": meta.model, + "model_digest": meta.model_digest, + "dimensions": meta.dimensions, + "index_text_version": meta.index_text_version, + }) +} + +/// What a collection's `config.metadata` says. A collection whose metadata +/// does not name a writer and an instance is unlabelled; one that does but is +/// missing the rest reads as an empty mind or model, which will not match: an +/// empty mind is refused as another's, an empty model is rebuilt when the +/// writer, the instance and the mind are ours. +fn described(metadata: &Value) -> Described { + let text = |key: &str| metadata[key].as_str().map(str::to_owned); + let (Some(managed_by), Some(instance)) = (text("managed_by"), text("instance")) else { + return Described::Unlabelled; + }; + let number = |key: &str| metadata[key].as_u64().and_then(|value| u32::try_from(value).ok()).unwrap_or(0); + Described::Labelled(CollectionMeta { + managed_by, + instance, + mind: text("mind").unwrap_or_default(), + model: text("model").unwrap_or_default(), + model_digest: text("model_digest").unwrap_or_default(), + dimensions: number("dimensions"), + index_text_version: number("index_text_version"), + }) +} + +impl VectorIndex for QdrantIndex { + fn describe(&mut self, collection: &str, within: Duration) -> Result { + let http = self.http.bounded(within); + let (status, answer) = self.ask_with(&http, Method::Get, &format!("/collections/{collection}"), None, &[200, 404])?; + if status == 404 { + return Ok(Described::Absent); + } + Ok(described(&answer["result"]["config"]["metadata"])) + } + + fn recreate(&mut self, collection: &str, meta: &CollectionMeta) -> Result<()> { + let path = format!("/collections/{collection}"); + self.ask(Method::Delete, &format!("{path}?wait=true"), None, &[200, 404])?; + let body = json!({ + "vectors": { "size": meta.dimensions, "distance": "Cosine", "on_disk": true }, + "metadata": metadata_json(meta), + }); + self.ask(Method::Put, &format!("{path}?wait=true"), Some(&body), &[200])?; + Ok(()) + } + + fn ids(&mut self, collection: &str) -> Result> { + let mut ids = BTreeSet::new(); + let mut offset = Value::Null; + loop { + let mut body = json!({ "limit": SCROLL_PAGE, "with_payload": false, "with_vector": false }); + if !offset.is_null() { + body["offset"] = offset; + } + let (_, answer) = + self.ask(Method::Post, &format!("/collections/{collection}/points/scroll"), Some(&body), &[200])?; + let points = answer["result"]["points"].as_array().context("Qdrant's scroll has no points")?; + for point in points { + ids.insert(point["id"].as_str().context("a point id is not a string")?.to_owned()); + } + offset = answer["result"]["next_page_offset"].clone(); + if offset.is_null() { + return Ok(ids); + } + } + } + + fn upsert(&mut self, collection: &str, points: &[Point]) -> Result<()> { + let points: Vec = points + .iter() + .map(|point| { + json!({ + "id": point.id, + "vector": point.vector, + "payload": { + "doc_id": point.payload.doc_id, + "kind": point.payload.kind, + "root": point.payload.root, + "ordinal": point.payload.ordinal, + "text_sha256": point.payload.text_sha256, + }, + }) + }) + .collect(); + self.ask( + Method::Put, + &format!("/collections/{collection}/points?wait=true"), + Some(&json!({ "points": points })), + &[200], + )?; + Ok(()) + } + + fn search(&mut self, collection: &str, vector: &[f32], limit: u32, within: Duration) -> Result> { + let http = self.http.bounded(within); + let body = json!({ "vector": vector, "limit": limit, "with_payload": ["doc_id", "kind"], "with_vector": false }); + let (_, answer) = + self.ask_with(&http, Method::Post, &format!("/collections/{collection}/points/search"), Some(&body), &[200])?; + let found = answer["result"].as_array().context("Qdrant's search has no result")?; + found + .iter() + .map(|point| { + Ok(Hit { + doc_id: point["payload"]["doc_id"].as_str().context("a hit carries no doc_id")?.to_owned(), + kind: point["payload"]["kind"].as_str().context("a hit carries no kind")?.to_owned(), + score: point["score"].as_f64().context("a hit carries no score")? as f32, + }) + }) + .collect() + } +} diff --git a/crates/huginn-daemon/src/index/tests.rs b/crates/huginn-daemon/src/index/tests.rs new file mode 100644 index 0000000..d18f25b --- /dev/null +++ b/crates/huginn-daemon/src/index/tests.rs @@ -0,0 +1,1948 @@ +//! The projection's rules, against in-memory ports; the adapters' request and +//! answer shapes, against a stub that speaks just enough HTTP. No test needs a +//! live Qdrant or Ollama. + +use std::collections::BTreeSet; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use huginn_mind::wire::{HuginnMindRequest, HuginnMindResponse, IndexStatus}; +use huginn_mind::{Mind, OwnedRedbMessagePackBackingStore, PipelineAdmissionOutcome}; +use tempfile::TempDir; + +use super::fakes::{FakeEmbedder, FakeIndex, Gate, Stored, pair}; +use super::ollama::OllamaEmbedder; +use super::qdrant::QdrantIndex; +use super::*; +use crate::daemon::Daemon; +use crate::daemon::tests::{INSTANCE, OTHER, batch, campaign_seed, now, question_and_ruling, slug}; + +thread_local! { + /// Runs once, in the worker's own turn, between the inbox being absorbed + /// and a search being taken, holding the guard `serve_search` holds. + static AFTER_ABSORB: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +/// The hook `serve_search` calls at the point where a second lock once left a +/// gap. It is the test's only way in: nothing else can reach that point. +pub(super) fn after_absorb(guard: &mut Shared) { + if let Some(hook) = AFTER_ABSORB.with(|hook| hook.borrow_mut().take()) { + hook(guard); + } +} + +fn collection() -> String { + collection_name(&slug(INSTANCE)) +} + +/// The bound the adapter tests give a call. +const TIME: Duration = Duration::from_secs(30); + +/// A mind identity for tests that project entries without a mind behind them. +const MIND: &str = "mind-commit-first"; + +/// A mind of the same instance name whose first batch differs, so its genesis +/// receipt id does too. +fn other_mind_of_the_same_name() -> (TempDir, Mind) { + let root = tempfile::tempdir().unwrap(); + let mut mind = Mind::open(root.path(), &slug(INSTANCE)).unwrap(); + let (question, _) = question_and_ruling(); + let mut first = campaign_seed(); + first.push(question); + let outcome = mind.admit(batch(INSTANCE, first), now()); + assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}"); + (root, mind) +} + +/// A real mind holding a campaign, a question, the ruling that answers it and +/// the resolution admission derived: four indexable documents, beside the +/// identity and the stewardship, which carry no text. +fn mind_with_documents() -> (TempDir, Mind) { + let root = tempfile::tempdir().unwrap(); + let mut mind = Mind::open(root.path(), &slug(INSTANCE)).unwrap(); + let (question, ruling) = question_and_ruling(); + for documents in [campaign_seed(), vec![question, ruling]] { + let outcome = mind.admit(batch(INSTANCE, documents), now()); + assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}"); + } + (root, mind) +} + +/// How many times the collection was created and how many upserts it took. +fn counts(index: &FakeIndex) -> (usize, usize) { + let state = index.state.lock().unwrap(); + (state.recreated.len(), state.upserts.len()) +} + +/// Steps until the projector has nothing to do. A step that never settles is a +/// failure of the test, not a hang: it is bounded. +fn drain(projector: &mut Projector) { + for _ in 0..1000 { + if projector.advance().unwrap() == Advance::Idle { + return; + } + } + panic!("the projector was still stepping after 1000 steps"); +} + +fn point_ids(entries: &[IndexEntry]) -> BTreeSet { + entries.iter().map(|entry| point_id(&entry.id.id.0)).collect() +} + +fn projected(embedder: &FakeEmbedder, index: &FakeIndex, entries: &[IndexEntry]) -> Projector { + let mut projector = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into())); + projector.want(entries.iter().cloned()); + drain(&mut projector); + projector +} + +/// A projector whose collection was lost while it ran and has been made again: +/// it owes every entry, and is rebuilding. +fn rebuilding(embedder: &FakeEmbedder, index: &FakeIndex, entries: &[IndexEntry]) -> Projector { + let mut projector = projected(embedder, index, &entries[..1]); + index.state.lock().unwrap().collections.remove(&collection()); + projector.want(entries[1..].iter().cloned()); + projector.advance().expect_err("the write meets no collection"); + assert_eq!(projector.advance().unwrap(), Advance::Progressed, "the reconciliation makes the collection again"); + projector +} + +/// P4's function, as voidbot's `toQdrantPointId` computes it: the first sixteen +/// bytes of the SHA-256, version and variant bits set. The fixed vectors were +/// computed independently of this crate. +#[test] +fn point_ids_are_a_pure_function_of_the_document_id() { + assert_eq!(point_id("eureka-state:question:Q1"), "1e8a661b-4b7e-5245-a9ad-b15a3fa27031"); + assert_eq!(point_id("eureka-state:campaign:self"), "d4135998-051d-57fb-9360-82964874fbc9"); + assert_eq!(point_id("eureka-state:question:Q1"), point_id("eureka-state:question:Q1")); + assert_ne!(point_id("eureka-state:question:Q1"), point_id("eureka-state:question:Q2")); +} + +/// The projection converges by set difference: after a crash that lost two +/// points, a restart embeds and writes those two, and nothing else, and the +/// collection is not rebuilt. +#[test] +fn reconcile_indexes_exactly_the_indexable_documents_the_collection_lacks() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + assert_eq!(entries.len(), 4); + + let (embedder, index) = pair(); + projected(&embedder, &index, &entries); + assert_eq!( + index.holding(&collection()), + point_ids(&entries), + "one point per indexable document, none for the identity or the stewardship" + ); + + let lost: Vec = entries.iter().take(2).cloned().collect(); + { + let mut state = index.state.lock().unwrap(); + let stored = state.collections.get_mut(&collection()).unwrap(); + for entry in &lost { + stored.points.remove(&point_id(&entry.id.id.0)); + } + state.upserts.clear(); + state.recreated.clear(); + } + + let restarted = FakeEmbedder::new("d1"); + projected(&restarted, &index, &entries); + let state = index.state.lock().unwrap(); + assert!(state.recreated.is_empty(), "a collection that agrees is not rebuilt"); + let written: BTreeSet = state.upserts.concat().into_iter().collect(); + assert_eq!(written, point_ids(&lost), "exactly the missing points were written"); + assert_eq!(state.upserts.concat().len(), 2, "each once"); + assert_eq!(restarted.texts().len(), 2, "and only their text was embedded"); + drop(state); + assert_eq!(index.holding(&collection()), point_ids(&entries)); +} + +/// Compatibility is the model by name and digest, its dimensions and the text +/// version. Any of them differing rebuilds the collection whole, and no vector +/// of one model survives beside another's. +#[test] +fn a_model_digest_change_rebuilds_the_collection() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let changes: [(&str, Box); 3] = [ + ("digest", Box::new(|identity| identity.digest = "d2".into())), + ("name", Box::new(|identity| identity.name = "other-model".into())), + ("dimensions", Box::new(|identity| identity.dimensions = 8)), + ]; + for (what, change) in changes { + let (embedder, index) = pair(); + projected(&embedder, &index, &entries); + assert_eq!(index.state.lock().unwrap().recreated.len(), 1, "the first run creates the collection"); + + let second = FakeEmbedder::new("d1"); + change(&mut second.state.lock().unwrap().identity); + let expected = second.state.lock().unwrap().identity.clone(); + projected(&second, &index, &entries); + let state = index.state.lock().unwrap(); + assert_eq!(state.recreated.len(), 2, "a changed {what} rebuilds"); + let (_, meta) = state.recreated.last().unwrap(); + assert_eq!( + (&meta.model, &meta.model_digest, meta.dimensions), + (&expected.name, &expected.digest, expected.dimensions), + "{what}" + ); + assert_eq!(second.texts().len(), 4, "every document is embedded again under {what}"); + assert_eq!(state.collections[&collection()].points.len(), 4); + } + + // The same model, digest and dimensions: nothing is rebuilt or rewritten. + let (embedder, index) = pair(); + projected(&embedder, &index, &entries); + let same = FakeEmbedder::new("d1"); + projected(&same, &index, &entries); + assert_eq!(index.state.lock().unwrap().recreated.len(), 1); + assert!(same.texts().is_empty()); + + // A text version the collection was not built with is a rebuild too. + { + let mut state = index.state.lock().unwrap(); + let Described::Labelled(meta) = &mut state.collections.get_mut(&collection()).unwrap().label else { panic!() }; + meta.index_text_version += 1; + } + projected(&FakeEmbedder::new("d1"), &index, &entries); + assert_eq!(index.state.lock().unwrap().recreated.len(), 2); +} + +/// No cross-mind writes: a collection that another writer or another instance +/// made, or that carries no label, is left exactly as it was. +#[test] +fn a_collection_owned_by_another_instance_is_never_recreated() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let ours = |managed_by: &str, instance: &str, mind: &str| CollectionMeta { + managed_by: managed_by.into(), + instance: instance.into(), + mind: mind.into(), + model: "model".into(), + model_digest: "d1".into(), + dimensions: 4, + index_text_version: INDEX_TEXT_VERSION, + }; + for foreign in [ + Described::Unlabelled, + Described::Labelled(ours(MANAGED_BY, OTHER, MIND)), + Described::Labelled(ours("voidbot", INSTANCE, MIND)), + Described::Labelled(ours(MANAGED_BY, INSTANCE, "mind-commit-another")), + Described::Labelled(ours(MANAGED_BY, INSTANCE, "")), + ] { + let (embedder, index) = pair(); + index.plant(&collection(), foreign.clone()); + let sentinel = point_id("someone-elses-document"); + index.state.lock().unwrap().collections.get_mut(&collection()).unwrap().points.insert( + sentinel.clone(), + Point { + id: sentinel.clone(), + vector: vec![0.0; 4], + payload: PointPayload { + doc_id: "someone-elses-document".into(), + kind: "x".into(), + root: "x".into(), + ordinal: 1, + text_sha256: "x".into(), + }, + }, + ); + let mut projector = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into())); + projector.want(entries.iter().cloned()); + let error = projector.reconcile().expect_err("a foreign collection is refused"); + assert!(matches!(error, StepError::Refused(_)), "{foreign:?}: {error}"); + assert!(matches!(projector.failure_status(&error, 1), IndexStatus::Refused { pending: 4, .. })); + let state = index.state.lock().unwrap(); + assert!(state.recreated.is_empty() && state.upserts.is_empty(), "{foreign:?} was written to"); + assert_eq!(state.collections[&collection()].label, foreign); + assert!(state.collections[&collection()].points.contains_key(&sentinel), "{foreign:?} lost a point"); + assert!(embedder.texts().is_empty()); + } +} + +/// The vector's length is checked against the collection's, so a flush that +/// the model did not change is still refused when the embedder answers wrongly. +#[test] +fn a_vector_of_the_wrong_length_is_refused_and_nothing_is_dropped() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let mut projector = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into())); + projector.want(entries.iter().cloned()); + projector.reconcile().unwrap(); + embedder.state.lock().unwrap().vector_length = Some(8); + let error = projector.flush_batch().expect_err("a longer vector is refused"); + assert!(matches!(error, StepError::Unavailable(_)), "{error}"); + assert_eq!(projector.pending(), 4); + assert!(index.state.lock().unwrap().upserts.is_empty()); +} + +/// A flush embeds through the same check as a search: a model that changed +/// before the embed is never asked, a model that changed during it has its +/// vectors dropped, and a collection that is no longer this mind's is refused. +/// In each case nothing is written, the documents stay wanted, and the +/// reconciliation is forgotten. +#[test] +fn a_flush_is_refused_as_a_search_is_when_the_model_or_the_collection_changed() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let reconciled = || { + let (embedder, index) = pair(); + let mut projector = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into())); + projector.want(entries.iter().cloned()); + projector.reconcile().unwrap(); + (embedder, index, projector) + }; + + let (embedder, index, mut projector) = reconciled(); + embedder.state.lock().unwrap().identity.digest = "d2".into(); + projector.flush_batch().expect("a changed model is not a failure"); + assert!(embedder.texts().is_empty(), "before: the texts were not embedded under the new model"); + assert!(index.state.lock().unwrap().upserts.is_empty()); + assert_eq!((projector.pending(), projector.stale()), (4, true), "before"); + + let (embedder, index, mut projector) = reconciled(); + change_label(&index, |meta| meta.mind = "mind-commit-another".into()); + let error = projector.flush_batch().expect_err("another mind's label is refused"); + assert!(error.to_string().contains("no longer the one this mind's index was built as"), "{error}"); + assert!(embedder.texts().is_empty(), "label: nothing was embedded"); + assert!(index.state.lock().unwrap().upserts.is_empty()); + assert_eq!((projector.pending(), projector.stale()), (4, true), "label"); + + let gate = Gate::shut(); + let embedder = FakeEmbedder::new("d1").behind(&gate); + let index = FakeIndex::default(); + let mut projector = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into())); + projector.want(entries.iter().cloned()); + projector.reconcile().unwrap(); + let flushing = std::thread::spawn(move || { + let flushed = projector.flush_batch(); + (projector, flushed) + }); + gate.wait_arrived(1); + embedder.state.lock().unwrap().identity.digest = "d2".into(); + gate.open(); + let (projector, flushed) = flushing.join().unwrap(); + flushed.expect("a changed model is not a failure"); + assert!(index.state.lock().unwrap().upserts.is_empty(), "during: the vectors were not written"); + assert_eq!((projector.pending(), projector.stale()), (4, true), "during"); +} + +fn change_label(index: &FakeIndex, change: impl FnOnce(&mut CollectionMeta)) { + let mut state = index.state.lock().unwrap(); + let Described::Labelled(meta) = &mut state.collections.get_mut(&collection()).unwrap().label else { panic!() }; + change(meta); +} +/// Every call on the search path is bounded by what remains of the search's own +/// time: the identity reads, the label read, the embed and the query. A search +/// whose embed cannot be recalled therefore cannot hold the writes off past the +/// moment nobody is waiting for it. A bulk flush keeps its own, longer, bound. +#[test] +fn every_call_on_the_search_path_is_bounded_by_the_searchs_time_and_the_flush_by_its_own() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let mut projector = projected(&embedder, &index, &entries); + { + let state = embedder.state.lock().unwrap(); + assert!(!state.bounds.is_empty()); + let near = |bound: &Duration| *bound <= BULK_EMBED_TIMEOUT && *bound > BULK_EMBED_TIMEOUT - Duration::from_secs(60); + assert!(state.bounds.iter().all(near), "the flushes' embeds: {:?}", state.bounds); + assert!(state.identity_bounds.iter().all(near), "the reconciliation's and the flushes' identity reads: {:?}", state.identity_bounds); + } + embedder.state.lock().unwrap().bounds.clear(); + embedder.state.lock().unwrap().identity_bounds.clear(); + index.state.lock().unwrap().bounds.clear(); + + let within = Duration::from_secs(20); + projector.search("anything", 3, within).unwrap(); + let embedder = embedder.state.lock().unwrap(); + let index = index.state.lock().unwrap(); + assert_eq!(embedder.identity_bounds.len(), 2, "the model is read before and after the embed"); + assert_eq!(embedder.bounds.len(), 1); + assert_eq!(index.bounds.iter().map(|(call, _)| *call).collect::>(), ["describe", "search"]); + let bounds: Vec = embedder + .identity_bounds + .iter() + .chain(&embedder.bounds) + .copied() + .chain(index.bounds.iter().map(|(_, bound)| *bound)) + .collect(); + assert!(bounds.iter().all(|bound| !bound.is_zero() && *bound <= within), "{bounds:?}"); + assert_eq!(Backoff::default().search_deadline, SEARCH_DEADLINE); + assert!(SEARCH_DEADLINE < BULK_EMBED_TIMEOUT); +} + +/// A hung Ollama does not hold the worker past the search that asked: the +/// identity read never answers, and the search is answered, and the worker +/// free, when the search's own time has passed. A search that waited past its +/// time in the queue is answered without asking the embedder anything. A +/// ticket's time is stamped by the sink (see `the_sink_owns_the_searchs_deadline_and_tells_the_loop`). +#[test] +fn a_hung_embedder_holds_the_worker_no_longer_than_the_search_that_asked() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let mut sink = WorkerSink::spawn(embedder.clone(), index, &slug(INSTANCE), Some(MIND.into()), entries, quick()); + sink.wait_status(|status| *status == IndexStatus::Current); + + ask_sink(&mut sink, "the ticket's time", 3).unwrap(); + answers(&mut sink, 1); + + embedder.state.lock().unwrap().hang_identity = true; + let started = Instant::now(); + let due = started + Duration::from_millis(400); + lock(&sink.shared).searches.push_back(Queued { ticket: SearchTicket(70), text: "hung".into(), top_k: 3, due }); + sink.shared.1.notify_all(); + let answered = answers(&mut sink, 1); + assert_eq!(answered[0].0, SearchTicket(70)); + assert!(answered[0].1.is_err(), "{answered:?}"); + assert!(started.elapsed() < Duration::from_secs(5), "the worker was held for {:?}", started.elapsed()); + + embedder.state.lock().unwrap().hang_identity = false; + let reads = embedder.state.lock().unwrap().identity_bounds.len(); + lock(&sink.shared).searches.push_back(Queued { + ticket: SearchTicket(71), + text: "late".into(), + top_k: 3, + due: Instant::now().checked_sub(Duration::from_secs(1)).unwrap_or_else(Instant::now), + }); + sink.shared.1.notify_all(); + let late = answers(&mut sink, 1); + let Err(why) = &late[0].1 else { panic!("{late:?}") }; + assert!(why.contains("has run out"), "{why}"); + assert_eq!(embedder.state.lock().unwrap().identity_bounds.len(), reads, "nothing was asked for a search nobody waits for"); + + let ticket = ask_sink(&mut sink, "the worker is free", 3).expect("the worker is still current"); + assert_eq!(answers(&mut sink, 1)[0].0, ticket); +} + +/// The Ollama adapter enforces the bound it is given: an embed that is never +/// answered fails at the bound, not at the adapter's default. +#[test] +fn an_ollama_embed_that_is_never_answered_fails_at_its_bound() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let held = std::thread::spawn(move || { + let mut held = Vec::new(); + for stream in listener.incoming().take(1) { + held.push(stream.unwrap()); + std::thread::sleep(Duration::from_secs(8)); + } + }); + let (sent, received) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut embedder = OllamaEmbedder::new(&url, "m:1"); + sent.send(embedder.embed(&["a".into()], Duration::from_millis(300)).is_err()).ok(); + }); + assert_eq!(received.recv_timeout(Duration::from_secs(5)), Ok(true), "the embed did not fail at its bound"); + drop(held); +} +fn whoami_index(daemon: &mut Daemon) -> IndexStatus { + match daemon.handle(HuginnMindRequest::Whoami, now()).answered() { + HuginnMindResponse::Whoami(status) => status.index, + other => panic!("expected a status, got {other:?}"), + } +} + +fn committed(response: HuginnMindResponse) { + assert!( + matches!(response, HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { .. })), + "{response:?}" + ); +} + +fn quick() -> Backoff { + Backoff { initial: Duration::from_millis(1), max: Duration::from_millis(2), recheck: Duration::from_secs(3600), search_deadline: Duration::from_secs(30) } +} + +/// The index never delays or refuses admission: the embedder is shut, the +/// worker is inside it, and two more admissions are answered at once. The gate +/// counts arrivals, so this observes where the work is and not how long it +/// took. +#[test] +fn an_admission_replies_while_the_embedder_is_blocked() { + let root = tempfile::tempdir().unwrap(); + let mind = Mind::open(root.path(), &slug(INSTANCE)).unwrap(); + let gate = Gate::shut(); + let embedder = FakeEmbedder::new("d1").behind(&gate); + let index = FakeIndex::default(); + let sink = WorkerSink::spawn(embedder, index.clone(), &slug(INSTANCE), mind.genesis_receipt_id().unwrap(), mind.index_entries(None).unwrap(), quick()); + let mut daemon = Daemon::new(mind, sink); + daemon.index().wait_status(|status| *status == IndexStatus::Current); + + committed(daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, campaign_seed())), now()).answered()); + gate.wait_arrived(1); + let (question, ruling) = question_and_ruling(); + committed(daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![question, ruling])), now()).answered()); + assert_eq!( + whoami_index(&mut daemon), + IndexStatus::Behind { pending: 4 }, + "the campaign is in the embedder and three more are queued" + ); + assert!(index.holding(&collection()).is_empty(), "nothing has been written yet"); + + gate.open(); + daemon.index().wait_status(|status| *status == IndexStatus::Current); + assert_eq!(index.holding(&collection()).len(), 4); + assert_eq!(whoami_index(&mut daemon), IndexStatus::Current); +} + +/// A failing embedder is reported by `whoami`, retried on the backoff, and +/// nothing is dropped: when it returns, every document is written. +#[test] +fn a_failing_embedder_is_visible_in_whoami_and_retried_never_dropped() { + let root = tempfile::tempdir().unwrap(); + let mind = Mind::open(root.path(), &slug(INSTANCE)).unwrap(); + let (embedder, index) = pair(); + let sink = WorkerSink::spawn(embedder.clone(), index.clone(), &slug(INSTANCE), None, Vec::new(), quick()); + let mut daemon = Daemon::new(mind, sink); + daemon.index().wait_status(|status| *status == IndexStatus::Current); + + embedder.state.lock().unwrap().down = true; + let (question, ruling) = question_and_ruling(); + for documents in [campaign_seed(), vec![question, ruling]] { + committed(daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, documents)), now()).answered()); + } + let seen = daemon.index().wait_status(|status| matches!(status, IndexStatus::Failing { attempts, .. } if *attempts >= 2)); + let IndexStatus::Failing { pending, error, .. } = seen else { panic!() }; + assert_eq!(pending, 4); + assert!(error.contains("the embedder is down"), "{error}"); + assert!(matches!(whoami_index(&mut daemon), IndexStatus::Failing { .. })); + assert!(index.holding(&collection()).is_empty()); + + embedder.state.lock().unwrap().down = false; + daemon.index().wait_status(|status| *status == IndexStatus::Current); + assert_eq!(index.holding(&collection()).len(), 4, "every admitted document was kept and written"); +} + +/// A collection that is not this mind's is reported as refused, never +/// recreated, and re-read on the backoff: once it is gone the projection +/// completes. +#[test] +fn a_refused_collection_is_reported_and_reread() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + index.plant(&collection(), Described::Unlabelled); + let sink = WorkerSink::spawn(embedder, index.clone(), &slug(INSTANCE), mind.genesis_receipt_id().unwrap(), entries, quick()); + let mut daemon = Daemon::new(mind, sink); + daemon.index().wait_status(|status| matches!(status, IndexStatus::Refused { attempts, .. } if *attempts >= 2)); + assert!(matches!(whoami_index(&mut daemon), IndexStatus::Refused { pending: 4, .. })); + assert!(index.state.lock().unwrap().recreated.is_empty()); + + index.state.lock().unwrap().collections.remove(&collection()); + daemon.index().wait_status(|status| *status == IndexStatus::Current); + assert_eq!(index.holding(&collection()).len(), 4); +} + +// The adapters, against a stub that answers the routes it is given. + +struct Stub { + url: String, + seen: Arc>>, +} + +type Route = (&'static str, u16, &'static str); + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|window| window == needle) +} + +/// Serves each route once, in order, then repeats the last route that matched. +/// A route matches `" "` by prefix. +fn stub(routes: &[Route]) -> Stub { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let seen = Arc::new(Mutex::new(Vec::new())); + let routes: Arc>> = Arc::new(Mutex::new(routes.iter().map(|route| (*route, false)).collect())); + let log = Arc::clone(&seen); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { break }; + let (routes, log) = (Arc::clone(&routes), Arc::clone(&log)); + std::thread::spawn(move || answer_connection(stream, &routes, &log)); + } + }); + Stub { url, seen } +} + +fn answer_connection( + mut stream: TcpStream, + routes: &Mutex>, + log: &Mutex>, +) { + let mut buffer: Vec = Vec::new(); + loop { + let header_end = loop { + if let Some(at) = find(&buffer, b"\r\n\r\n") { + break at + 4; + } + let mut chunk = [0_u8; 4096]; + match stream.read(&mut chunk) { + Ok(0) | Err(_) => return, + Ok(read) => buffer.extend_from_slice(&chunk[..read]), + } + }; + let head = String::from_utf8_lossy(&buffer[..header_end]).into_owned(); + let length = head + .lines() + .find_map(|line| line.to_ascii_lowercase().strip_prefix("content-length:").map(|value| value.trim().parse::().unwrap())) + .unwrap_or(0); + while buffer.len() < header_end + length { + let mut chunk = [0_u8; 4096]; + match stream.read(&mut chunk) { + Ok(0) | Err(_) => return, + Ok(read) => buffer.extend_from_slice(&chunk[..read]), + } + } + let body = String::from_utf8_lossy(&buffer[header_end..header_end + length]).into_owned(); + buffer.drain(..header_end + length); + let mut first = head.lines().next().unwrap().split(' '); + let (method, path) = (first.next().unwrap().to_owned(), first.next().unwrap().to_owned()); + log.lock().unwrap().push((method.clone(), path.clone(), body)); + let key = format!("{method} {path}"); + let (status, answer) = { + let mut routes = routes.lock().unwrap(); + let matching: Vec = + routes.iter().enumerate().filter(|(_, ((prefix, ..), _))| key.starts_with(prefix)).map(|(at, _)| at).collect(); + let chosen = matching.iter().copied().find(|at| !routes[*at].1).or(matching.last().copied()); + match chosen { + Some(at) => { + routes[at].1 = true; + (routes[at].0.1, routes[at].0.2) + } + None => (404, "{}"), + } + }; + let response = + format!("HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{answer}", answer.len()); + if stream.write_all(response.as_bytes()).is_err() { + return; + } + } +} + +const TAGS: &str = r#"{"models":[{"name":"other:1","model":"other:1","digest":"zzz"},{"name":"m:1","model":"m:1","digest":"ac6d"}]}"#; +const SHOW: &str = r#"{"model_info":{"general.architecture":"qwen3","qwen3.embedding_length":4}}"#; +const EMBED: &str = r#"{"embeddings":[[0.5,1.0,2.0,3.0],[0.5,1.0,2.0,3.0],[0.5,1.0,2.0,3.0],[0.5,1.0,2.0,3.0]]}"#; + +#[test] +fn ollama_identity_is_the_listed_digest_and_the_models_embedding_length() { + let ollama = stub(&[("GET /api/tags", 200, TAGS), ("POST /api/show", 200, SHOW), ("POST /api/embed", 200, EMBED)]); + let mut embedder = OllamaEmbedder::new(&ollama.url, "m:1"); + assert_eq!(embedder.model_identity(TIME).unwrap(), ModelIdentity { name: "m:1".into(), digest: "ac6d".into(), dimensions: 4 }); + let vectors = embedder.embed(&["a".into(), "b".into(), "c".into(), "d".into()], Duration::from_secs(5)).unwrap(); + assert_eq!(vectors.len(), 4); + assert_eq!(vectors[0], vec![0.5, 1.0, 2.0, 3.0]); + let seen = ollama.seen.lock().unwrap(); + let (_, _, body) = seen.iter().find(|(_, path, _)| path == "/api/embed").unwrap(); + assert!(body.contains("\"model\":\"m:1\"") && body.contains("\"input\":[\"a\",\"b\",\"c\",\"d\"]"), "{body}"); + drop(seen); + + // A listing entry that carries only `name` is still the model. + let bare = stub(&[ + ("GET /api/tags", 200, r#"{"models":[{"name":"m:1","digest":"ac6d"}]}"#), + ("POST /api/show", 200, SHOW), + ]); + assert_eq!(OllamaEmbedder::new(&bare.url, "m:1").model_identity(TIME).unwrap().digest, "ac6d"); + + let mut absent = OllamaEmbedder::new(&ollama.url, "not-pulled:1"); + assert!(format!("{:#}", absent.model_identity(TIME).unwrap_err()).contains("does not list the model")); + let refusing = stub(&[("POST /api/embed", 500, r#"{"error":"boom"}"#)]); + assert!(OllamaEmbedder::new(&refusing.url, "m:1").embed(&["a".into()], Duration::from_secs(5)).is_err()); +} + +/// Compatibility never reads the address: the same model at another address +/// leaves the collection alone, and the same address serving another digest +/// rebuilds it. +#[test] +fn the_embedders_address_is_not_part_of_the_collections_compatibility() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let routes = [("GET /api/tags", 200, TAGS), ("POST /api/show", 200, SHOW), ("POST /api/embed", 200, EMBED)]; + let repulled = TAGS.replace("ac6d", "beef"); + let index = FakeIndex::default(); + + let mut first = Projector::new(OllamaEmbedder::new(&stub(&routes).url, "m:1"), index.clone(), &slug(INSTANCE), Some(MIND.into())); + first.want(entries.iter().cloned()); + drain(&mut first); + assert_eq!(counts(&index), (1, 1)); + + // Another address, the same model and digest. + let mut moved = Projector::new(OllamaEmbedder::new(&stub(&routes).url, "m:1"), index.clone(), &slug(INSTANCE), Some(MIND.into())); + moved.want(entries.iter().cloned()); + drain(&mut moved); + assert_eq!(counts(&index), (1, 1)); + + // The same model name re-pulled: another digest. + let leaked: &'static str = Box::leak(repulled.into_boxed_str()); + let other = [("GET /api/tags", 200, leaked), routes[1], routes[2]]; + let mut rebuilt = Projector::new(OllamaEmbedder::new(&stub(&other).url, "m:1"), index.clone(), &slug(INSTANCE), Some(MIND.into())); + rebuilt.want(entries.iter().cloned()); + drain(&mut rebuilt); + assert_eq!(counts(&index), (2, 2)); +} + +#[test] +fn qdrant_describes_creates_lists_and_writes_what_the_projector_asks() { + let name = collection(); + let absent = stub(&[("GET /collections/", 404, r#"{"status":{"error":"not found"}}"#)]); + assert_eq!(QdrantIndex::new(&absent.url).describe(&name, TIME).unwrap(), Described::Absent); + + let unlabelled = stub(&[("GET /collections/", 200, r#"{"result":{"config":{"metadata":null}}}"#)]); + assert_eq!(QdrantIndex::new(&unlabelled.url).describe(&name, TIME).unwrap(), Described::Unlabelled); + let voidbot = stub(&[("GET /collections/", 200, r#"{"result":{"config":{"metadata":{"managedBy":"voidbot"}}}}"#)]); + assert_eq!(QdrantIndex::new(&voidbot.url).describe(&name, TIME).unwrap(), Described::Unlabelled); + + let labelled = stub(&[( + "GET /collections/", + 200, + r#"{"result":{"config":{"metadata":{"managed_by":"huginn","instance":"yggdrasil","mind":"mind-commit-first","model":"m:1","model_digest":"ac6d","dimensions":4,"index_text_version":1}}}}"#, + )]); + assert_eq!( + QdrantIndex::new(&labelled.url).describe(&name, TIME).unwrap(), + Described::Labelled(CollectionMeta { + managed_by: "huginn".into(), + instance: "yggdrasil".into(), + mind: "mind-commit-first".into(), + model: "m:1".into(), + model_digest: "ac6d".into(), + dimensions: 4, + index_text_version: 1, + }) + ); + + // A scroll is followed to its last page. + let paged = stub(&[ + ("POST /collections/", 200, r#"{"result":{"points":[{"id":"a"},{"id":"b"}],"next_page_offset":"b"}}"#), + ("POST /collections/", 200, r#"{"result":{"points":[{"id":"c"}],"next_page_offset":null}}"#), + ]); + let ids = QdrantIndex::new(&paged.url).ids(&name).unwrap(); + let expected: BTreeSet = ["a", "b", "c"].map(String::from).into_iter().collect(); + assert_eq!(ids, expected); + let seen = paged.seen.lock().unwrap(); + assert!(!seen[0].2.contains("offset") && seen[1].2.contains("\"offset\":\"b\""), "{seen:?}"); + drop(seen); + + // A rebuild drops, then creates with the metadata and the vector shape. + let writing = stub(&[("DELETE /collections/", 200, "{}"), ("PUT /collections/", 200, "{}")]); + let meta = CollectionMeta { + managed_by: MANAGED_BY.into(), + instance: INSTANCE.into(), + mind: MIND.into(), + model: "m:1".into(), + model_digest: "ac6d".into(), + dimensions: 4, + index_text_version: INDEX_TEXT_VERSION, + }; + let mut store = QdrantIndex::new(&writing.url); + store.recreate(&name, &meta).unwrap(); + let point = Point { + id: point_id("d"), + vector: vec![1.0, 2.0, 3.0, 4.0], + payload: PointPayload { doc_id: "d".into(), kind: "question".into(), root: "eureka-state".into(), ordinal: 7, text_sha256: "h".into() }, + }; + store.upsert(&name, &[point]).unwrap(); + let seen = writing.seen.lock().unwrap(); + let paths: Vec = seen.iter().map(|(method, path, _)| format!("{method} {path}")).collect(); + assert_eq!( + paths, + [ + format!("DELETE /collections/{name}?wait=true"), + format!("PUT /collections/{name}?wait=true"), + format!("PUT /collections/{name}/points?wait=true"), + ] + ); + for expected in ["\"size\":4", "\"distance\":\"Cosine\"", "\"on_disk\":true", "\"managed_by\":\"huginn\"", "\"index_text_version\":1"] { + assert!(seen[1].2.contains(expected), "{expected} in {}", seen[1].2); + } + for expected in ["\"doc_id\":\"d\"", "\"kind\":\"question\"", "\"root\":\"eureka-state\"", "\"ordinal\":7", "\"text_sha256\":\"h\""] { + assert!(seen[2].2.contains(expected), "{expected} in {}", seen[2].2); + } + + let failing = stub(&[("PUT /collections/", 500, r#"{"status":{"error":"disk full"}}"#)]); + assert!(QdrantIndex::new(&failing.url).upsert(&name, &[]).is_err()); +} + +/// The collection's name is the contract with the store: one per instance, +/// `huginn_mind_`. +#[test] +fn the_collection_is_named_for_the_instance() { + assert_eq!(collection_name(&slug("yggdrasil")), "huginn_mind_yggdrasil"); + assert_eq!(collection_name(&slug("a.b")), "huginn_mind_a.b"); +} + +#[test] +fn a_step_error_says_what_stopped_it() { + assert_eq!(StepError::Refused("not ours".into()).to_string(), "not ours"); + let unavailable = StepError::Unavailable(anyhow::anyhow!("inner").context("outer")); + assert_eq!(unavailable.to_string(), "outer: inner"); +} + +/// What the sink reports is the worker's health with the inbox added to the +/// pending count, and a current index with something queued is behind. +#[test] +fn queued_entries_count_toward_every_status_that_has_a_pending_count() { + use IndexStatus as S; + assert_eq!(including(&S::Current, 0), S::Current); + assert_eq!(including(&S::Current, 3), S::Behind { pending: 3 }); + assert_eq!(including(&S::Reconciling { pending: 2 }, 3), S::Reconciling { pending: 5 }); + assert_eq!(including(&S::Behind { pending: 2 }, 3), S::Behind { pending: 5 }); + assert_eq!( + including(&S::Failing { pending: 2, attempts: 4, error: "e".into() }, 3), + S::Failing { pending: 5, attempts: 4, error: "e".into() } + ); + assert_eq!( + including(&S::Refused { pending: 2, attempts: 4, reason: "r".into() }, 3), + S::Refused { pending: 5, attempts: 4, reason: "r".into() } + ); +} + +/// Dropping the sink stops the worker: the worker's hold on the shared state +/// is released. A worker that never stopped would hold it forever. +#[test] +fn dropping_the_sink_stops_the_worker() { + let (embedder, index) = pair(); + let sink = WorkerSink::spawn(embedder, index, &slug(INSTANCE), Some(MIND.into()), Vec::new(), quick()); + sink.wait_status(|status| *status == IndexStatus::Current); + let shared = Arc::downgrade(&sink.shared); + drop(sink); + eventually("the worker letting go of the shared state", || shared.upgrade().is_none()); +} + +/// F1. The promised curve, pure: 30 s doubling to a 10 min cap, never below +/// the first delay and never past the cap. A retry that shrank would hot-loop +/// against a dead Ollama. +#[test] +fn the_backoff_doubles_from_thirty_seconds_to_a_ten_minute_cap() { + let backoff = Backoff::default(); + assert_eq!((backoff.initial, backoff.max), (Duration::from_secs(30), Duration::from_secs(600))); + assert_eq!(backoff.recheck, Duration::from_secs(60), "an idle worker verifies the model and the collection each minute"); + let mut delay = backoff.initial; + let mut seen = vec![delay.as_secs()]; + for _ in 0..7 { + let next = backoff.after(delay); + assert!(next >= delay && next >= backoff.initial, "the delay never shrinks: {delay:?} -> {next:?}"); + assert!(next <= backoff.max, "the delay never passes the cap: {next:?}"); + delay = next; + seen.push(delay.as_secs()); + } + assert_eq!(seen, [30, 60, 120, 240, 480, 600, 600, 600]); + let small = Backoff { initial: Duration::from_millis(1), max: Duration::from_millis(3), recheck: Duration::from_secs(3600), search_deadline: Duration::from_secs(30) }; + assert_eq!(small.after(Duration::from_millis(1)), Duration::from_millis(2)); + assert_eq!(small.after(Duration::from_millis(2)), Duration::from_millis(3)); +} + +/// F2. A collection lost while the daemon runs is found by the next failed +/// write, recreated and refilled from what the projector knows, without a +/// restart. +#[test] +fn a_collection_lost_after_startup_is_recreated_and_refilled() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let mut projector = projected(&embedder, &index, &entries[..3]); + assert_eq!(projector.progress_status(), IndexStatus::Current); + assert_eq!(index.holding(&collection()).len(), 3); + + index.state.lock().unwrap().collections.remove(&collection()); + projector.want(entries[3..].iter().cloned()); + let error = projector.advance().expect_err("the write meets no collection"); + assert!(matches!(error, StepError::Unavailable(_)), "{error}"); + assert!(matches!(projector.failure_status(&error, 1), IndexStatus::Failing { pending: 1, .. })); + + drain(&mut projector); + assert_eq!(index.state.lock().unwrap().recreated.len(), 2, "the collection was made again"); + assert_eq!(index.holding(&collection()), point_ids(&entries), "and holds every document, not only the new one"); + assert_eq!(projector.progress_status(), IndexStatus::Current); +} + +/// F2. A model re-pulled with other dimensions is seen before anything is +/// written: no vector of the new length is offered to the old collection, and +/// the next step rebuilds the collection to match. +#[test] +fn a_model_that_changes_dimensions_after_startup_rebuilds_the_collection() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let mut projector = projected(&embedder, &index, &entries[..3]); + + embedder.state.lock().unwrap().identity.dimensions = 8; + projector.want(entries[3..].iter().cloned()); + let writes = index.state.lock().unwrap().upserts.len(); + assert_eq!(projector.advance().unwrap(), Advance::Progressed, "the change is seen, not written through"); + assert_eq!(index.state.lock().unwrap().upserts.len(), writes, "no vector of 8 was offered to a collection of 4"); + drain(&mut projector); + + let state = index.state.lock().unwrap(); + assert_eq!(state.recreated.len(), 2); + assert_eq!(state.recreated.last().unwrap().1.dimensions, 8); + let stored = &state.collections[&collection()]; + assert_eq!(stored.points.len(), 4); + assert!(stored.points.values().all(|point| point.vector.len() == 8)); +} + +/// F3. Two minds that share an instance name never share a collection: the +/// second is refused, and the first's points, label and history are untouched. +#[test] +fn a_second_mind_of_the_same_name_is_refused_and_leaves_the_first_alone() { + let (_first_root, first) = mind_with_documents(); + let (_second_root, second) = other_mind_of_the_same_name(); + let (first_id, second_id) = (first.genesis_receipt_id().unwrap().unwrap(), second.genesis_receipt_id().unwrap().unwrap()); + assert_ne!(first_id, second_id); + + let (embedder, index) = pair(); + let mut ours = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(first_id.clone())); + ours.want(first.index_entries(None).unwrap()); + drain(&mut ours); + let held = index.holding(&collection()); + assert_eq!(held.len(), 4); + + let intruder = FakeEmbedder::new("d1"); + let mut theirs = Projector::new(intruder.clone(), index.clone(), &slug(INSTANCE), Some(second_id)); + theirs.want(second.index_entries(None).unwrap()); + let error = theirs.advance().expect_err("another mind's collection is refused"); + assert!(matches!(error, StepError::Refused(_)), "{error}"); + assert!(matches!(theirs.failure_status(&error, 1), IndexStatus::Refused { .. })); + + let state = index.state.lock().unwrap(); + assert_eq!(state.recreated.len(), 1, "never rebuilt by the second mind"); + assert_eq!(state.upserts.len(), 1, "never written by the second mind"); + assert!(intruder.texts().is_empty()); + let Described::Labelled(label) = &state.collections[&collection()].label else { panic!() }; + assert_eq!(label.mind, first_id); + drop(state); + assert_eq!(index.holding(&collection()), held); +} + +/// A mind with no receipt has no identity: nothing is reconciled and no +/// collection is made, until the first admission reveals one. +#[test] +fn a_mind_without_an_identity_makes_no_collection_until_its_first_admission() { + let root = tempfile::tempdir().unwrap(); + let mind = Mind::open(root.path(), &slug(INSTANCE)).unwrap(); + assert_eq!(mind.genesis_receipt_id().unwrap(), None); + let (embedder, index) = pair(); + let mut projector = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), None); + assert_eq!(projector.progress_status(), IndexStatus::Current); + assert_eq!(projector.advance().unwrap(), Advance::Idle); + let identified = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into())); + assert_eq!(identified.progress_status(), IndexStatus::Behind { pending: 0 }, "an identified mind has yet to build its first index: lag, not a rebuild"); + assert!(index.state.lock().unwrap().collections.is_empty()); + + let sink = WorkerSink::spawn(embedder, index.clone(), &slug(INSTANCE), None, Vec::new(), quick()); + let mut daemon = Daemon::new(mind, sink); + let seed = campaign_seed(); + let without_text = vec![seed[0].clone(), seed[1].clone()]; + committed(daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, without_text)), now()).answered()); + for _ in 0..500 { + if index.state.lock().unwrap().collections.contains_key(&collection()) { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + let state = index.state.lock().unwrap(); + let Described::Labelled(label) = &state.collections[&collection()].label else { panic!("no collection was made") }; + assert_eq!(Some(&label.mind), daemon.mind().genesis_receipt_id().unwrap().as_ref()); +} + +/// Waits for a condition the worker will make true, without a clock in the +/// assertion: it polls for at most ten seconds and then says what never +/// happened. +fn eventually(what: &str, mut condition: impl FnMut() -> bool) { + for _ in 0..1000 { + if condition() { + return; + } + std::thread::sleep(Duration::from_millis(5)); + } + panic!("{what} never happened"); +} + +/// A model re-pulled under the same name and dimensions but a new digest must +/// not mix its vectors with the old model's. The check is before the write, +/// not after a failure: nothing is offered to the old collection, the +/// collection is rebuilt under the new digest, and every document is embedded +/// again. +#[test] +fn a_model_re_pulled_with_the_same_dimensions_never_writes_beside_the_old_vectors() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let mut projector = projected(&embedder, &index, &entries[..3]); + + embedder.state.lock().unwrap().identity.digest = "d2".into(); + projector.want(entries[3..].iter().cloned()); + let writes = index.state.lock().unwrap().upserts.len(); + assert_eq!(projector.advance().unwrap(), Advance::Progressed); + assert_eq!(index.state.lock().unwrap().upserts.len(), writes, "the new vector was not written into the old collection"); + assert_eq!(projector.progress_status(), IndexStatus::Reconciling { pending: 1 }, "the reconciliation was forgotten, not the entry"); + + drain(&mut projector); + let state = index.state.lock().unwrap(); + assert_eq!(state.recreated.len(), 2); + assert_eq!(state.recreated.last().unwrap().1.model_digest, "d2"); + let stored = &state.collections[&collection()]; + assert_eq!(stored.points.len(), 4, "every document is in the rebuilt collection"); + assert_eq!(embedder.texts().len(), 3 + 4, "and was embedded again under the new digest"); +} + +/// Nothing is written while idle, so no write can fail to announce that the +/// collection is gone or the model has changed. The idle worker looks again on +/// its own clock: a lost collection is refilled and a re-pulled model rebuilds +/// it, with no admission to prompt either. +#[test] +fn an_idle_worker_finds_a_lost_collection_and_a_changed_model_without_a_write() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let recheck = Backoff { recheck: Duration::from_millis(20), ..quick() }; + let sink = WorkerSink::spawn(embedder.clone(), index.clone(), &slug(INSTANCE), mind.genesis_receipt_id().unwrap(), entries.clone(), recheck); + sink.wait_status(|status| *status == IndexStatus::Current); + assert_eq!(index.holding(&collection()), point_ids(&entries)); + + index.state.lock().unwrap().collections.remove(&collection()); + eventually("the lost collection being made again and refilled", || { + index.state.lock().unwrap().recreated.len() == 2 && index.holding(&collection()) == point_ids(&entries) + }); + + embedder.state.lock().unwrap().identity.digest = "d2".into(); + eventually("the collection being rebuilt under the new digest", || { + let state = index.state.lock().unwrap(); + state.recreated.len() == 3 + && state.recreated.last().is_some_and(|(_, meta)| meta.model_digest == "d2") + && state.collections[&collection()].points.len() == 4 + }); + sink.wait_status(|status| *status == IndexStatus::Current); +} + +/// A search embeds the query behind the model's instruction, asks the index +/// with no filter for `min(top_k * 4, 200)`, and hands back candidates by id +/// and kind; a hit of a kind this organ has no name for is left out. +#[test] +fn a_search_embeds_the_query_with_its_instruction_and_oversamples() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let mut projector = projected(&embedder, &index, &entries); + index.state.lock().unwrap().hits = vec![ + Hit { doc_id: "eureka-state:question:Q1".into(), kind: "question".into(), score: 0.9 }, + Hit { doc_id: "eureka-state:mystery:X".into(), kind: "mystery".into(), score: 0.8 }, + ]; + + let hits = projector.search("who owns the state", 3, SEARCH_DEADLINE).unwrap(); + assert_eq!(hits.len(), 1, "the unknown kind is left out"); + assert_eq!(hits[0].0.id.0, "eureka-state:question:Q1"); + assert_eq!(hits[0].0.kind, PipelineKind::Question); + assert_eq!(hits[0].1, 0.9); + let embedded = embedder.texts(); + assert_eq!(embedded.last().unwrap(), &format!("Instruct: {QUERY_INSTRUCTION}\nQuery: who owns the state")); + projector.search("again", 100, SEARCH_DEADLINE).unwrap(); + let searches = index.state.lock().unwrap().searches.clone(); + assert_eq!(searches.iter().map(|(name, length, limit)| (name.as_str(), *length, *limit)).collect::>(), [ + (collection().as_str(), 4, 12), + (collection().as_str(), 4, 200), + ]); +} + +/// A search is never run against a collection that is not known to fit the +/// model: before reconciliation, and after the model has changed, it refuses +/// by saying so rather than comparing vectors of two models. +#[test] +fn a_search_refuses_when_the_index_is_not_reconciled_with_the_model() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let mut fresh = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into())); + assert!(format!("{:#}", fresh.search("anything", 3, SEARCH_DEADLINE).unwrap_err()).contains("not been built")); + assert!(embedder.texts().is_empty() && index.state.lock().unwrap().searches.is_empty()); + + let mut projector = projected(&embedder, &index, &entries); + embedder.state.lock().unwrap().identity.digest = "d2".into(); + let before = embedder.texts().len(); + assert!(format!("{:#}", projector.search("anything", 3, SEARCH_DEADLINE).unwrap_err()).contains("model changed")); + assert_eq!(embedder.texts().len(), before, "the query was not embedded under the new model"); + assert!(index.state.lock().unwrap().searches.is_empty()); + assert!(projector.stale(), "and the reconciliation is to be redone"); +} + +/// The search asks for the nearest points to a vector with no filter of any +/// kind (which documents may be shown is the mind's), and reads back the +/// document id, the kind and the score, in the order Qdrant gave them. A hit +/// missing what the join needs is an error, not a guess. +#[test] +fn qdrant_search_sends_no_filter_and_reads_ids_kinds_and_scores() { + let name = collection(); + let answering = stub(&[( + "POST /collections/", + 200, + r#"{"result":[{"id":"p1","score":0.75,"payload":{"doc_id":"eureka-state:question:Q1","kind":"question"}},{"id":"p2","score":0.5,"payload":{"doc_id":"eureka-state:ruling:R1","kind":"ruling"}}]}"#, + )]); + let hits = QdrantIndex::new(&answering.url).search(&name, &[0.5, 1.0, 2.0, 3.0], 12, TIME).unwrap(); + assert_eq!( + hits, + [ + Hit { doc_id: "eureka-state:question:Q1".into(), kind: "question".into(), score: 0.75 }, + Hit { doc_id: "eureka-state:ruling:R1".into(), kind: "ruling".into(), score: 0.5 }, + ] + ); + let seen = answering.seen.lock().unwrap(); + assert_eq!((seen[0].0.as_str(), seen[0].1.as_str()), ("POST", format!("/collections/{name}/points/search").as_str())); + for expected in ["\"vector\":[0.5,1.0,2.0,3.0]", "\"limit\":12", "\"with_vector\":false"] { + assert!(seen[0].2.contains(expected), "{expected} in {}", seen[0].2); + } + assert!(!seen[0].2.contains("filter"), "no predicate is lowered into the index: {}", seen[0].2); + drop(seen); + + let nameless = stub(&[("POST /collections/", 200, r#"{"result":[{"id":"p","score":0.5,"payload":{"kind":"question"}}]}"#)]); + assert!(QdrantIndex::new(&nameless.url).search(&name, &[1.0], 1, TIME).is_err()); + let failing = stub(&[("POST /collections/", 404, r#"{"status":{"error":"not found"}}"#)]); + assert!(QdrantIndex::new(&failing.url).search(&name, &[1.0], 1, TIME).is_err()); +} + +fn queued(ticket: u64, text: &str) -> Queued { + Queued { ticket: SearchTicket(ticket), text: text.into(), top_k: 3, due: Instant::now() + SEARCH_DEADLINE } +} + +fn shared_with( + inbox: Vec, + mind: Option, + searches: Vec, + closed: bool, +) -> Lock { + Arc::new(( + Mutex::new(Shared { + inbox, + mind, + status: IndexStatus::Current, + closed, + searches: searches.into(), + found: Vec::new(), + next_ticket: 0, + }), + Condvar::new(), + )) +} + +/// The idle wait is for work: an entry, the mind's identity, a search or the +/// sink closing ends it at once, and only a quiet worker waits out its +/// deadline. The deadline is the caller's: a deadline already past ends the +/// wait at once too, and waking for work does not extend it. +#[test] +fn an_idle_wait_ends_for_work_and_otherwise_at_its_deadline() { + let (_root, mind) = mind_with_documents(); + let entry = mind.index_entries(None).unwrap().remove(0); + let waits = |shared: &Lock| { + let started = Instant::now(); + wait_for_work(shared, Wait::Work { until: Instant::now() + Duration::from_secs(30) }); + started.elapsed() < Duration::from_secs(10) + }; + assert!(waits(&shared_with(vec![entry], None, Vec::new(), false)), "an entry"); + assert!(waits(&shared_with(Vec::new(), Some(MIND.into()), Vec::new(), false)), "the mind's identity"); + assert!(waits(&shared_with(Vec::new(), None, vec![queued(0, "q")], false)), "a search"); + assert!(waits(&shared_with(Vec::new(), None, Vec::new(), true)), "the sink closing"); + + let quiet = shared_with(Vec::new(), None, Vec::new(), false); + let started = Instant::now(); + wait_for_work(&quiet, Wait::Work { until: started + Duration::from_millis(60) }); + assert!(started.elapsed() >= Duration::from_millis(60), "a quiet worker waits for its deadline"); + let started = Instant::now(); + wait_for_work(&quiet, Wait::Work { until: started.checked_sub(Duration::from_secs(1)).unwrap_or(started) }); + assert!(started.elapsed() < Duration::from_secs(10), "a deadline already past is not waited for"); +} + +/// A backoff is the whole delay, whatever arrives: a failing index is retried +/// on its schedule. +#[test] +fn a_backoff_wait_is_the_whole_delay_whatever_arrives() { + let (_root, mind) = mind_with_documents(); + let entry = mind.index_entries(None).unwrap().remove(0); + let busy = shared_with(vec![entry], Some(MIND.into()), vec![queued(0, "q")], false); + let started = Instant::now(); + wait_for_work(&busy, Wait::Backoff(Duration::from_millis(80))); + assert!(started.elapsed() >= Duration::from_millis(80), "the delay was waited out although work was queued"); + let closed = shared_with(Vec::new(), None, Vec::new(), true); + let started = Instant::now(); + wait_for_work(&closed, Wait::Backoff(Duration::from_secs(30))); + assert!(started.elapsed() < Duration::from_secs(10), "a closed sink ends even a backoff"); +} + +/// Searches accepted while the index was healthy are answered, with the +/// reason, when the worker fails: nothing is left to wait for its deadline. +#[test] +fn queued_searches_are_answered_with_why_when_the_worker_gives_up() { + let shared = shared_with(Vec::new(), None, vec![queued(3, "a"), queued(4, "b")], false); + refuse_searches(&shared, "the index could not advance: down"); + let guard = lock(&shared); + assert!(guard.searches.is_empty()); + assert_eq!( + guard.found, + [ + (SearchTicket(3), Err("the index could not advance: down".to_string())), + (SearchTicket(4), Err("the index could not advance: down".to_string())), + ] + ); +} + +// The semantic read's index side: when a search is answered, how many wait, +// and what the worker does between them. + +type Answers = Vec<(SearchTicket, Result)>; + +/// The sink's own calls, named: the trait is generic over the store and the +/// worker does not care which. +fn ask_sink(sink: &mut WorkerSink, text: &str, top_k: u32) -> Result { + >::search(sink, text, top_k) +} + +fn collected(sink: &mut WorkerSink) -> Answers { + >::searched(sink) +} + +fn abandon(sink: &mut WorkerSink, ticket: SearchTicket) { + >::abandon(sink, ticket); +} + +/// Waits for `count` answers and returns them, in the order they were given. +fn answers(sink: &mut WorkerSink, count: usize) -> Answers { + let mut all = Vec::new(); + eventually("the searches being answered", || { + all.extend(collected(sink)); + all.len() >= count + }); + all +} + +/// `count` documents that share one document's text and differ in id, for the +/// tests whose subject is how many there are. +fn entries_of(count: usize) -> Vec { + let (_root, mind) = mind_with_documents(); + let template = mind.index_entries(None).unwrap().remove(0); + (0..count) + .map(|n| { + let mut entry = template.clone(); + entry.id.id.0 = format!("{}#{n}", entry.id.id.0); + entry + }) + .collect() +} + +fn queries(embedder: &FakeEmbedder) -> Vec { + embedder.texts().into_iter().filter(|text| text.starts_with("Instruct: ")).collect() +} + +/// A search is refused only while the index is being rebuilt or cannot be +/// asked, and the words name the state and how many documents are pending. Lag +/// is not a rebuild: the sink and the projector ask the one predicate and both +/// let it through. +#[test] +fn a_search_is_refused_only_for_a_rebuild_or_a_failure() { + use IndexStatus as S; + assert_eq!(unsearchable(&S::Current), None); + assert_eq!(unsearchable(&S::Behind { pending: 5 }), None, "lag is waited out, not refused"); + let says = |status: S, wanted: &[&str]| { + let why = unsearchable(&status).unwrap_or_else(|| panic!("{status:?} answered")); + for word in wanted { + assert!(why.contains(word), "{status:?}: {word} in {why}"); + } + }; + says(S::Reconciling { pending: 7 }, &["being rebuilt", "reconciling", "pending: 7"]); + says(S::Failing { pending: 3, attempts: 2, error: "boom".into() }, &["failing", "boom", "pending: 3"]); + says(S::Refused { pending: 4, attempts: 2, reason: "not ours".into() }, &["refused", "not ours", "pending: 4"]); + + // The sink: what the worker last published, and the documents admitted + // since, which are lag whatever the published state says of a current index. + let sink_in = |status: S, inbox: usize| { + let shared = shared_with(entries_of(inbox), None, Vec::new(), false); + lock(&shared).status = status; + WorkerSink { shared, identified: true, deadline: SEARCH_DEADLINE } + }; + assert!(ask_sink(&mut sink_in(S::Current, 2), "anything", 3).is_ok(), "admitted a moment ago and not yet absorbed"); + assert!(ask_sink(&mut sink_in(S::Behind { pending: 4 }, 0), "anything", 3).is_ok(), "the worker is writing"); + let refusal = ask_sink(&mut sink_in(S::Reconciling { pending: 4 }, 2), "anything", 3).unwrap_err(); + assert!(refusal.contains("being rebuilt") && refusal.contains("pending: 6"), "{refusal}"); + let refusal = ask_sink(&mut sink_in(S::Failing { pending: 1, attempts: 1, error: "boom".into() }, 0), "anything", 3).unwrap_err(); + assert!(refusal.contains("failing") && refusal.contains("boom"), "{refusal}"); + + // The projector: a rebuild refuses without touching the ports to find out. + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let (lost_embedder, lost_index) = pair(); + let mut rebuilding_now = rebuilding(&lost_embedder, &lost_index, &entries); + let searches = lost_index.state.lock().unwrap().searches.len(); + let refusal = format!("{:#}", rebuilding_now.search("anything", 3, SEARCH_DEADLINE).unwrap_err()); + assert!(refusal.contains("being rebuilt") && refusal.contains("pending: 4"), "{refusal}"); + assert_eq!(lost_index.state.lock().unwrap().searches.len(), searches, "the rebuilt collection was not asked"); + assert!(embedder.texts().is_empty() && index.state.lock().unwrap().searches.is_empty()); + + // Lag it lets through: catching up is the worker's, before the search is taken. + let mut lagging = projected(&embedder, &index, &entries[..3]); + lagging.want(entries[3..].iter().cloned()); + assert_eq!(lagging.progress_status(), IndexStatus::Behind { pending: 1 }); + assert!(lagging.search("anything", 3, SEARCH_DEADLINE).is_ok()); +} + +/// A search that fails for its own reasons (the vector store errs, the +/// embedder is down, an identity read times out) fails that search alone: the +/// reconciliation stands, the status stays `Current`, the collection is not +/// listed again, and the next search is asked and answered. +#[test] +fn a_failed_search_fails_that_search_alone() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + type Fault = Box; + let faults: [(&str, Fault); 3] = [ + ("the vector store fails the search", Box::new(|_, index, on| index.state.lock().unwrap().fail_search = on)), + ("the embedder is down", Box::new(|embedder, _, on| embedder.state.lock().unwrap().down = on)), + ("the identity read never answers", Box::new(|embedder, _, on| embedder.state.lock().unwrap().hang_identity = on)), + ]; + for (what, fault) in faults { + let (embedder, index) = pair(); + let mut projector = projected(&embedder, &index, &entries); + let listed = index.state.lock().unwrap().listed; + fault(&embedder, &index, true); + assert!(projector.search("anything", 3, Duration::from_millis(50)).is_err(), "{what}"); + assert_eq!(projector.progress_status(), IndexStatus::Current, "{what}"); + assert!(!projector.stale(), "{what}"); + fault(&embedder, &index, false); + assert!(projector.search("anything", 3, SEARCH_DEADLINE).is_ok(), "{what}: the next search is answered"); + assert_eq!(index.state.lock().unwrap().listed, listed, "{what}: the collection was not listed again"); + } + + // In the worker the failed search is answered and nothing else moves. + let (embedder, index) = pair(); + let mut sink = WorkerSink::spawn(embedder, index.clone(), &slug(INSTANCE), Some(MIND.into()), entries, quick()); + sink.wait_status(|status| *status == IndexStatus::Current); + let listed = index.state.lock().unwrap().listed; + index.state.lock().unwrap().fail_search = true; + let ticket = ask_sink(&mut sink, "anything", 3).unwrap(); + let failed = answers(&mut sink, 1); + assert_eq!(failed[0].0, ticket); + assert!(failed[0].1.is_err()); + index.state.lock().unwrap().fail_search = false; + let next = ask_sink(&mut sink, "anything", 3).expect("the index is still current, not being rebuilt"); + let answered = answers(&mut sink, 1); + assert_eq!((answered[0].0, answered[0].1.is_ok()), (next, true)); + assert_eq!(index.state.lock().unwrap().listed, listed, "the worker did not list the collection again"); +} + +/// A model the search finds changed is a rebuild, and its refusal names the +/// state and how many documents are pending, as every other refusal does. +#[test] +fn a_model_changed_refusal_names_the_state_and_the_pending_count() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let mut projector = projected(&embedder, &index, &entries[..3]); + projector.want(entries[3..].iter().cloned()); + embedder.state.lock().unwrap().identity.digest = "d2".into(); + let refusal = format!("{:#}", projector.search("anything", 3, SEARCH_DEADLINE).unwrap_err()); + assert!(refusal.contains("model changed") && refusal.contains("being rebuilt") && refusal.contains("pending: 1"), "{refusal}"); +} + +/// A search asks the store what the collection says about itself: a label for +/// another mind, no label, or no collection is refused before the query is +/// embedded, and the reconciliation is to be redone. +#[test] +fn a_search_re_reads_the_collections_label() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let cases: [(&str, Box); 3] = [ + ( + "another mind's label", + Box::new(|stored| { + let Described::Labelled(meta) = &mut stored.label else { panic!() }; + meta.mind = "mind-commit-another".into(); + }), + ), + ("no label", Box::new(|stored| stored.label = Described::Unlabelled)), + ("another text version", Box::new(|stored| { + let Described::Labelled(meta) = &mut stored.label else { panic!() }; + meta.index_text_version += 1; + })), + ]; + for (what, change) in cases { + let (embedder, index) = pair(); + let mut projector = projected(&embedder, &index, &entries); + change(index.state.lock().unwrap().collections.get_mut(&collection()).unwrap()); + let embedded = embedder.texts().len(); + let refusal = format!("{:#}", projector.search("anything", 3, SEARCH_DEADLINE).unwrap_err()); + assert!(refusal.contains("no longer the one this mind's index was built as"), "{what}: {refusal}"); + assert_eq!(embedder.texts().len(), embedded, "{what}: nothing was embedded"); + assert!(index.state.lock().unwrap().searches.is_empty(), "{what}"); + assert!(projector.stale(), "{what}"); + } + let (embedder, index) = pair(); + let mut projector = projected(&embedder, &index, &entries); + index.state.lock().unwrap().collections.clear(); + assert!(projector.search("anything", 3, SEARCH_DEADLINE).is_err(), "no collection"); + assert!(projector.stale()); +} + +/// The model is read again after the query is embedded: a vector made while the +/// model changed is never compared with the collection. +#[test] +fn a_model_that_changes_while_the_query_is_embedded_is_refused() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let gate = Gate::shut(); + let embedder = FakeEmbedder::new("d1").behind(&gate); + let index = FakeIndex::default(); + gate.open(); + let mut projector = projected(&embedder, &index, &entries); + gate.shut_again(); + let searching = std::thread::spawn(move || { + let found = projector.search("anything", 3, SEARCH_DEADLINE); + (projector, found) + }); + gate.wait_arrived(1); + embedder.state.lock().unwrap().identity.digest = "d2".into(); + gate.open(); + let (projector, found) = searching.join().unwrap(); + let refusal = format!("{:#}", found.unwrap_err()); + assert!(refusal.contains("while the texts were embedded"), "{refusal}"); + assert!(index.state.lock().unwrap().searches.is_empty(), "the vector was not used"); + assert!(projector.stale()); +} + +/// Each search has a ticket of its own, and its answer comes back once, as the +/// worker gave it: nothing is added to it and nothing is left to be +/// collected twice. +#[test] +fn every_search_has_its_own_ticket_and_its_answer_comes_back_exactly_once() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + index.state.lock().unwrap().hits = + vec![Hit { doc_id: "eureka-state:question:Q1".into(), kind: "question".into(), score: 0.9 }]; + let mut sink = WorkerSink::spawn(embedder, index, &slug(INSTANCE), Some(MIND.into()), entries, quick()); + sink.wait_status(|status| *status == IndexStatus::Current); + let expected: Hits = vec![( + PipelineRef { kind: PipelineKind::Question, id: Short("eureka-state:question:Q1".into()) }, + 0.9, + )]; + let mut tickets = Vec::new(); + for text in ["a", "b", "c"] { + let ticket = ask_sink(&mut sink, text, 3).unwrap(); + assert_eq!(answers(&mut sink, 1), vec![(ticket, Ok(expected.clone()))]); + assert!(collected(&mut sink).is_empty(), "an answer is handed over once"); + tickets.push(ticket); + } + assert_eq!(tickets, [SearchTicket(0), SearchTicket(1), SearchTicket(2)]); +} + +/// The queue of searches is bounded and the asker who does not fit is told. +/// One search is inside the embedder and the queue is full; the next is +/// refused by name, and when the worker has answered some it takes more. +#[test] +fn the_search_queue_is_bounded_and_the_asker_is_told_when_it_is_full() { + let gate = Gate::shut(); + let (_, index) = pair(); + let embedder = FakeEmbedder::new("d1").behind(&gate); + let mut sink = WorkerSink::spawn(embedder, index, &slug(INSTANCE), Some(MIND.into()), Vec::new(), quick()); + sink.wait_status(|status| *status == IndexStatus::Current); + ask_sink(&mut sink, "in flight", 3).unwrap(); + gate.wait_arrived(1); + for n in 0..SEARCHES_QUEUED_MAX { + ask_sink(&mut sink, &format!("waiting {n}"), 3).unwrap_or_else(|why| panic!("{n} of {SEARCHES_QUEUED_MAX}: {why}")); + } + let refusal = ask_sink(&mut sink, "one too many", 3).unwrap_err(); + assert!(refusal.contains(&format!("{SEARCHES_QUEUED_MAX} searches waiting")), "{refusal}"); + + gate.open(); + let answered = answers(&mut sink, SEARCHES_QUEUED_MAX + 1); + assert_eq!(answered.len(), SEARCHES_QUEUED_MAX + 1); + assert!(ask_sink(&mut sink, "room again", 3).is_ok()); +} + +/// A search the asker has stopped waiting for is taken out of the queue and +/// never embedded; the ones on either side of it are. +#[test] +fn an_abandoned_search_is_never_embedded() { + let gate = Gate::shut(); + let (_, index) = pair(); + let embedder = FakeEmbedder::new("d1").behind(&gate); + let mut sink = WorkerSink::spawn(embedder.clone(), index, &slug(INSTANCE), Some(MIND.into()), Vec::new(), quick()); + sink.wait_status(|status| *status == IndexStatus::Current); + let first = ask_sink(&mut sink, "first", 3).unwrap(); + gate.wait_arrived(1); + let second = ask_sink(&mut sink, "second", 3).unwrap(); + let third = ask_sink(&mut sink, "third", 3).unwrap(); + abandon(&mut sink, second); + gate.open(); + let answered = answers(&mut sink, 2); + let tickets: Vec = answered.iter().map(|(ticket, _)| *ticket).collect(); + assert_eq!(tickets, [first, third]); + let texts = queries(&embedder); + assert_eq!(texts.len(), 2, "{texts:?}"); + assert!(texts[0].ends_with("Query: first") && texts[1].ends_with("Query: third"), "{texts:?}"); +} + +/// The worker serves one queued search and returns to its writes: three +/// waiting searches leave two after one turn. +#[test] +fn one_queued_search_is_served_per_turn() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let mut projector = projected(&embedder, &index, &entries); + let shared = shared_with(Vec::new(), None, vec![queued(1, "a"), queued(2, "b"), queued(3, "c")], false); + serve_search(&mut projector, &shared); + let guard = lock(&shared); + assert_eq!(guard.searches.iter().map(|queued| queued.ticket).collect::>(), [SearchTicket(2), SearchTicket(3)]); + assert_eq!(guard.found.iter().map(|(ticket, _)| *ticket).collect::>(), [SearchTicket(1)]); +} + +/// A search reads its own writes. Asked while a batch is in the embedder and +/// eight more documents wait, it is accepted, waits behind them, and is +/// answered only once every one is in the collection. +#[test] +fn a_search_waits_for_the_writes_ahead_of_it_and_reads_them() { + let gate = Gate::shut(); + let (_, index) = pair(); + let embedder = FakeEmbedder::new("d1").behind(&gate); + let mut sink = WorkerSink::spawn(embedder, index.clone(), &slug(INSTANCE), Some(MIND.into()), entries_of(BATCH + 8), quick()); + gate.wait_arrived(1); + let ticket = ask_sink(&mut sink, "anything", 3).expect("lag is not a rebuild"); + gate.open(); + let answered = answers(&mut sink, 1); + assert_eq!(answered[0].0, ticket); + assert!(answered[0].1.is_ok(), "{answered:?}"); + assert_eq!(index.state.lock().unwrap().held_when_searched, [BATCH + 8], "the search saw every document admitted before it"); + sink.wait_status(|status| *status == IndexStatus::Current); +} + +/// The worker takes admissions before the search, and leaves the search queued +/// while the writes ahead of it are unfinished: a search asked with a document +/// still in the inbox absorbs it and waits, and once the index has caught up +/// it is answered. An index being rebuilt is not waited on: the search is taken +/// and refused, naming the state and the pending count. +#[test] +fn the_worker_absorbs_admissions_before_a_search_and_waits_for_lag_but_not_for_a_rebuild() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let mut projector = projected(&embedder, &index, &entries[..3]); + let shared = shared_with(vec![entries[3].clone()], None, vec![queued(1, "a")], false); + + serve_search(&mut projector, &shared); + { + let guard = lock(&shared); + assert!(guard.inbox.is_empty(), "the admission ahead of the search was absorbed"); + assert_eq!(guard.searches.len(), 1, "the search waits for it to be written"); + assert!(guard.found.is_empty()); + } + assert_eq!(projector.pending(), 1); + + drain(&mut projector); + serve_search(&mut projector, &shared); + let guard = lock(&shared); + assert!(guard.searches.is_empty()); + assert!(matches!(guard.found.as_slice(), [(SearchTicket(1), Ok(_))]), "{:?}", guard.found); + assert_eq!(index.state.lock().unwrap().held_when_searched, [4]); + drop(guard); + + // A rebuild: the collection was lost and made again, and every entry is owed. + let (rebuild_embedder, rebuild_index) = pair(); + let mut rebuilding = rebuilding(&rebuild_embedder, &rebuild_index, &entries); + let shared = shared_with(Vec::new(), None, vec![queued(2, "b")], false); + serve_search(&mut rebuilding, &shared); + let guard = lock(&shared); + let [(SearchTicket(2), Err(refusal))] = guard.found.as_slice() else { panic!("{:?}", guard.found) }; + assert!(refusal.contains("being rebuilt") && refusal.contains("pending: 4"), "{refusal}"); +} + +/// A collection made again with nothing to refill owes nothing, so the rebuild +/// ends inside `reconcile`: the next entry is lag, and a search waits for it +/// rather than being refused as a rebuild. +#[test] +fn a_rebuild_that_owes_nothing_settles_when_it_is_made() { + let (embedder, index) = pair(); + let mut projector = Projector::new(embedder.clone(), index, &slug(INSTANCE), Some(MIND.into())); + drain(&mut projector); + embedder.state.lock().unwrap().identity.digest = "d2".into(); + assert_eq!(projector.recheck().unwrap(), Advance::Progressed, "the collection is made again under the new digest"); + assert_eq!(projector.pending(), 0); + + projector.want(entries_of(1)); + assert_eq!(projector.progress_status(), IndexStatus::Behind { pending: 1 }, "nothing was owed, so nothing is being rebuilt"); + let shared = shared_with(Vec::new(), None, vec![queued(1, "a")], false); + serve_search(&mut projector, &shared); + let guard = lock(&shared); + assert_eq!(guard.searches.len(), 1, "the search waits for the write"); + assert!(guard.found.is_empty(), "and is not refused: {:?}", guard.found); +} + +/// An identified mind with nothing owed has still to reconcile once: a search +/// waits for that, and is not taken to fail against an index not yet built. +#[test] +fn a_search_waits_for_the_first_reconcile_of_an_identified_mind_owing_nothing() { + let (embedder, index) = pair(); + let mut projector = Projector::new(embedder, index, &slug(INSTANCE), Some(MIND.into())); + assert_eq!(projector.pending(), 0); + assert!(projector.stale()); + let shared = shared_with(Vec::new(), None, vec![queued(1, "a")], false); + serve_search(&mut projector, &shared); + { + let guard = lock(&shared); + assert_eq!(guard.searches.len(), 1, "the search waits for the reconcile"); + assert!(guard.found.is_empty(), "{:?}", guard.found); + } + drain(&mut projector); + serve_search(&mut projector, &shared); + assert!(matches!(lock(&shared).found.as_slice(), [(SearchTicket(1), Ok(_))])); +} + +/// A search that was queued when the worker failed is answered with why, by +/// the worker's own failure path. +#[test] +fn a_search_queued_before_the_worker_fails_is_answered_with_why() { + let gate = Gate::shut(); + let (_, index) = pair(); + let embedder = FakeEmbedder::new("d1").behind(&gate); + let mut sink = WorkerSink::spawn(embedder.clone(), index, &slug(INSTANCE), Some(MIND.into()), entries_of(1), quick()); + gate.wait_arrived(1); + lock(&sink.shared).searches.push_back(queued(5, "anything")); + embedder.state.lock().unwrap().down = true; + gate.open(); + let answered = answers(&mut sink, 1); + assert_eq!(answered[0].0, SearchTicket(5)); + let Err(why) = &answered[0].1 else { panic!("{answered:?}") }; + assert!(why.contains("the semantic index is failing") && why.contains("pending: 1") && why.contains("the embedder is down"), "{why}"); +} + +/// While a failure is being reported, the report's pending count is the one +/// the worker holds now, published under the lock that took the entries out of +/// the inbox: never the count from before they moved. +#[test] +fn a_failure_report_carries_the_pending_count_it_has_now() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let mut projector = Projector::new(embedder, index, &slug(INSTANCE), Some(MIND.into())); + projector.want(entries[..2].iter().cloned()); + let shared = shared_with(entries[2..].to_vec(), None, Vec::new(), false); + let error = StepError::Unavailable(anyhow::anyhow!("down")); + assert!(absorb(&mut projector, &shared, Some((&error, 3)))); + { + let guard = lock(&shared); + assert!(guard.inbox.is_empty()); + assert_eq!(guard.status, IndexStatus::Failing { pending: 4, attempts: 3, error: "down".into() }); + } + assert!(absorb(&mut projector, &shared, None)); + assert_eq!(lock(&shared).status, IndexStatus::Behind { pending: 4 }, "a first build is lag"); + lock(&shared).closed = true; + assert!(!absorb(&mut projector, &shared, None), "a closed sink ends the worker"); +} + +/// The worker's recheck is on its own clock: searches arriving faster than the +/// interval do not keep pushing it out. +#[test] +fn steady_searches_do_not_postpone_the_workers_recheck() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let backoff = Backoff { recheck: Duration::from_millis(200), ..quick() }; + let mut sink = WorkerSink::spawn(embedder, index.clone(), &slug(INSTANCE), Some(MIND.into()), entries, backoff); + sink.wait_status(|status| *status == IndexStatus::Current); + let listed = index.state.lock().unwrap().listed; + let started = Instant::now(); + while index.state.lock().unwrap().listed < listed + 2 { + assert!(started.elapsed() < Duration::from_secs(5), "the worker never rechecked while searches kept arriving"); + let _ = ask_sink(&mut sink, "anything", 3); + collected(&mut sink); + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// `wait_status` gives up at one deadline, not one per wake: a health that +/// keeps changing does not keep it waiting. +#[test] +fn wait_status_gives_up_at_its_overall_deadline_however_often_the_health_changes() { + let (embedder, index) = pair(); + let sink = WorkerSink::spawn(embedder, index, &slug(INSTANCE), Some(MIND.into()), Vec::new(), quick()); + sink.wait_status(|status| *status == IndexStatus::Current); + let shared = Arc::clone(&sink.shared); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let ticking = Arc::clone(&stop); + let ticker = std::thread::spawn(move || { + while !ticking.load(std::sync::atomic::Ordering::Relaxed) { + shared.1.notify_all(); + std::thread::sleep(Duration::from_millis(5)); + } + }); + let (tell, told) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let gave_up = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + sink.wait_status_for(Duration::from_millis(300), |status| *status == IndexStatus::Reconciling { pending: 99 }) + })) + .is_err(); + let _ = tell.send(gave_up); + }); + let gave_up = told.recv_timeout(Duration::from_secs(10)); + stop.store(true, std::sync::atomic::Ordering::Relaxed); + ticker.join().unwrap(); + assert_eq!(gave_up, Ok(true), "the wait did not end at its deadline"); +} + +/// An idle worker verifies once per interval, not in a loop: the recheck moves +/// its own deadline when it runs. +#[test] +fn an_idle_worker_rechecks_once_per_interval() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let backoff = Backoff { recheck: Duration::from_millis(100), ..quick() }; + let sink = WorkerSink::spawn(embedder, index.clone(), &slug(INSTANCE), Some(MIND.into()), entries, backoff); + sink.wait_status(|status| *status == IndexStatus::Current); + std::thread::sleep(Duration::from_millis(600)); + let listed = index.state.lock().unwrap().listed; + assert!((2..=40).contains(&listed), "the startup reconciliation and about five rechecks listed the collection {listed} times"); +} + +// The search's rulings: what may be waiting ahead of it, when a rebuild +// refuses, what a first build is, how time is spent, and who owns the deadline. + +/// A search is never taken while a document sits in the inbox. An admission +/// that lands after the inbox was absorbed and before the search was taken (the +/// hook puts one there, where a second lock once left the gap) leaves the +/// search queued; the next turn absorbs it, and the search is answered only +/// once it is written. +#[test] +fn a_search_is_never_taken_while_an_admission_sits_in_the_inbox() { + let (_root, mind) = mind_with_documents(); + let entries = mind.index_entries(None).unwrap(); + let (embedder, index) = pair(); + let mut projector = projected(&embedder, &index, &entries[..3]); + let shared = shared_with(Vec::new(), None, vec![queued(1, "a")], false); + let late = entries[3].clone(); + AFTER_ABSORB.with(|hook| *hook.borrow_mut() = Some(Box::new(move |guard| guard.inbox.push(late)))); + + serve_search(&mut projector, &shared); + { + let guard = lock(&shared); + assert_eq!(guard.searches.len(), 1, "the search was not taken past the admission"); + assert!(guard.found.is_empty()); + assert_eq!(guard.inbox.len(), 1); + } + serve_search(&mut projector, &shared); + assert_eq!(lock(&shared).searches.len(), 1, "absorbed, but not yet written"); + drain(&mut projector); + serve_search(&mut projector, &shared); + let guard = lock(&shared); + assert!(matches!(guard.found.as_slice(), [(SearchTicket(1), Ok(_))]), "{:?}", guard.found); + assert_eq!(index.state.lock().unwrap().held_when_searched, [4], "the search saw the admission"); +} + +/// Through the sink, an admission acknowledged before the search was asked is +/// in the inbox while the worker is inside a batch; the search reads it. +#[test] +fn a_search_reads_an_admission_acknowledged_before_it_was_asked() { + let gate = Gate::shut(); + let (_, index) = pair(); + let embedder = FakeEmbedder::new("d1").behind(&gate); + let mut sink = WorkerSink::spawn(embedder, index.clone(), &slug(INSTANCE), Some(MIND.into()), entries_of(2), quick()); + gate.wait_arrived(1); + let admitted = entries_of(5).split_off(2); + lock(&sink.shared).inbox.extend(admitted); + let ticket = ask_sink(&mut sink, "anything", 3).expect("lag is not a rebuild"); + gate.open(); + let answered = answers(&mut sink, 1); + assert_eq!(answered[0].0, ticket); + assert!(answered[0].1.is_ok(), "{answered:?}"); + assert_eq!(index.state.lock().unwrap().held_when_searched, [5]); +} + +/// A collection made again is a rebuild until every document it owes is +/// written again: searches are refused as rebuilding, however the collection +/// came to be made again, and however many steps or failures the refill takes. +#[test] +fn a_collection_made_again_refuses_searches_until_it_is_refilled() { + let refused = |projector: &mut Projector, pending: u32| { + let why = format!("{:#}", projector.search("anything", 3, SEARCH_DEADLINE).unwrap_err()); + assert!(why.contains("being rebuilt") && why.contains(&format!("pending: {pending}")), "{why}"); + }; + let entries = entries_of(BATCH + 8); + + // Lost while the worker ran. + let (embedder, index) = pair(); + let mut projector = rebuilding(&embedder, &index, &entries); + assert_eq!(projector.progress_status(), IndexStatus::Reconciling { pending: 40 }); + refused(&mut projector, 40); + index.state.lock().unwrap().down = true; + projector.advance().expect_err("the store is down mid-refill"); + index.state.lock().unwrap().down = false; + assert_eq!(projector.advance().unwrap(), Advance::Progressed, "reconciled again, under a label that already fits"); + assert_eq!(projector.progress_status(), IndexStatus::Reconciling { pending: 40 }, "a failure does not end the rebuild"); + assert_eq!(projector.advance().unwrap(), Advance::Progressed); + assert_eq!(projector.progress_status(), IndexStatus::Reconciling { pending: 8 }, "half refilled is still a rebuild"); + refused(&mut projector, 8); + assert_eq!(projector.advance().unwrap(), Advance::Progressed); + assert_eq!(projector.progress_status(), IndexStatus::Current); + assert!(projector.search("anything", 3, SEARCH_DEADLINE).is_ok(), "answered once refilled"); + assert_eq!(index.state.lock().unwrap().held_when_searched, [40]); + projector.want(entries_of(1).into_iter().map(|mut entry| { + entry.id.id.0.push_str("-later"); + entry + })); + assert_eq!(projector.progress_status(), IndexStatus::Behind { pending: 1 }, "a later admission is lag, not a rebuild"); + + // Made again because the model changed. + let (embedder, index) = pair(); + let mut projector = projected(&embedder, &index, &entries); + embedder.state.lock().unwrap().identity.digest = "d2".into(); + assert_eq!(projector.recheck().unwrap(), Advance::Progressed, "the change is seen and the collection made again"); + assert_eq!(projector.progress_status(), IndexStatus::Reconciling { pending: 40 }); + projector.advance().unwrap(); + assert_eq!(projector.progress_status(), IndexStatus::Reconciling { pending: 8 }); + refused(&mut projector, 8); + drain(&mut projector); + assert!(projector.search("anything", 3, SEARCH_DEADLINE).is_ok()); +} + +/// Through the worker: a collection lost while the worker runs is made again, +/// and a search asked during the refill is refused as rebuilding, naming how +/// many documents are pending; one asked after it is answered. +#[test] +fn a_search_asked_during_a_refill_is_refused_and_one_after_it_is_answered() { + let gate = Gate::shut(); + gate.open(); + let (_, index) = pair(); + let embedder = FakeEmbedder::new("d1").behind(&gate); + let backoff = Backoff { recheck: Duration::from_millis(50), ..quick() }; + let mut sink = WorkerSink::spawn(embedder, index.clone(), &slug(INSTANCE), Some(MIND.into()), entries_of(BATCH + 8), backoff); + sink.wait_status(|status| *status == IndexStatus::Current); + gate.shut_again(); + index.state.lock().unwrap().collections.remove(&collection()); + gate.wait_arrived(1); + let refusal = ask_sink(&mut sink, "anything", 3).unwrap_err(); + assert!(refusal.contains("being rebuilt") && refusal.contains("pending: 40"), "{refusal}"); + gate.open(); + sink.wait_status(|status| *status == IndexStatus::Current); + let ticket = ask_sink(&mut sink, "anything", 3).expect("refilled"); + let answered = answers(&mut sink, 1); + assert_eq!((answered[0].0, answered[0].1.is_ok()), (ticket, true)); + assert_eq!(index.state.lock().unwrap().held_when_searched.last(), Some(&40)); +} + +/// A mind whose index has never been built has nothing stale to protect: its +/// first build is lag, whether the search was asked before the worker had +/// looked at the first admission or after. Only a rebuild of an index that +/// exists refuses. +#[test] +fn a_minds_first_build_is_lag_whoever_looks_first() { + for asked_before_the_worker_looked in [true, false] { + let what = if asked_before_the_worker_looked { "asked first" } else { "asked after the worker looked" }; + let (embedder, index) = pair(); + let entries = entries_of(3); + let (mut projector, shared) = if asked_before_the_worker_looked { + let projector = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), None); + (projector, shared_with(entries, Some(MIND.into()), Vec::new(), false)) + } else { + let mut projector = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into())); + projector.want(entries); + let shared = shared_with(Vec::new(), None, Vec::new(), false); + lock(&shared).status = projector.progress_status(); + (projector, shared) + }; + let mut sink = WorkerSink { shared: Arc::clone(&shared), identified: true, deadline: SEARCH_DEADLINE }; + let ticket = ask_sink(&mut sink, "anything", 3).unwrap_or_else(|why| panic!("{what}: {why}")); + + serve_search(&mut projector, &shared); + assert_eq!(projector.progress_status(), IndexStatus::Behind { pending: 3 }, "{what}: not yet reconciled, and lag"); + assert_eq!(lock(&shared).searches.len(), 1, "{what}: the search waits for the first reconciliation"); + assert_eq!(projector.advance().unwrap(), Advance::Progressed); + serve_search(&mut projector, &shared); + assert_eq!(lock(&shared).searches.len(), 1, "{what}: and for the first writes"); + drain(&mut projector); + serve_search(&mut projector, &shared); + let guard = lock(&shared); + assert!(matches!(guard.found.as_slice(), [(found, Ok(_))] if *found == ticket), "{what}: {:?}", guard.found); + assert_eq!(index.state.lock().unwrap().held_when_searched, [3], "{what}"); + } + + let entries = entries_of(4); + let (embedder, index) = pair(); + projected(&embedder, &index, &entries[..3]); + let mut partial = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), Some(MIND.into())); + partial.want(entries.iter().cloned()); + partial.advance().unwrap(); + assert_eq!(partial.progress_status(), IndexStatus::Behind { pending: 1 }, "an index found whole at start-up only lags"); + drain(&mut partial); + index.state.lock().unwrap().collections.remove(&collection()); + assert_eq!(partial.recheck().unwrap(), Advance::Progressed); + assert_eq!(partial.progress_status(), IndexStatus::Reconciling { pending: 4 }, "an index found at start-up was built: losing it is a rebuild"); + + embedder.state.lock().unwrap().identity.digest = "d2".into(); + let mut replaced = Projector::new(embedder, index, &slug(INSTANCE), Some(MIND.into())); + replaced.want(entries); + replaced.advance().unwrap(); + assert_eq!(replaced.progress_status(), IndexStatus::Reconciling { pending: 4 }, "an index that exists and is made again is a rebuild"); +} + +/// A mind with no identity and documents owed has nothing to wait for: the +/// search is taken and refused, not left queued for a worker that would spin +/// on it. +#[test] +fn a_search_is_refused_when_documents_are_owed_to_a_mind_with_no_identity() { + let (embedder, index) = pair(); + let mut projector = Projector::new(embedder.clone(), index.clone(), &slug(INSTANCE), None); + projector.want(entries_of(2)); + let shared = shared_with(Vec::new(), None, vec![queued(1, "a")], false); + serve_search(&mut projector, &shared); + let guard = lock(&shared); + assert!(guard.searches.is_empty()); + let [(SearchTicket(1), Err(why))] = guard.found.as_slice() else { panic!("{:?}", guard.found) }; + assert!(why.contains("being rebuilt") && why.contains("pending: 2"), "{why}"); + assert!(embedder.texts().is_empty() && index.state.lock().unwrap().searches.is_empty()); +} + +/// Every call a search makes is bounded by what its time has left, not by the +/// time it was given: after a slow identity read the embed's bound is smaller +/// by what the read took, and the query's by both reads. +#[test] +fn a_later_calls_bound_shrinks_as_the_search_spends_its_time() { + let entries = entries_of(3); + let (embedder, index) = pair(); + let mut projector = projected(&embedder, &index, &entries); + let delay = Duration::from_millis(150); + let within = Duration::from_secs(10); + { + let mut state = embedder.state.lock().unwrap(); + state.identity_delay = delay; + state.bounds.clear(); + state.identity_bounds.clear(); + } + index.state.lock().unwrap().bounds.clear(); + projector.search("anything", 3, within).unwrap(); + + let embedder = embedder.state.lock().unwrap(); + let index = index.state.lock().unwrap(); + assert!(embedder.identity_bounds[0] <= within); + assert!(embedder.bounds[0] <= within - delay, "the embed came after one slow read: {:?}", embedder.bounds); + assert!(embedder.identity_bounds[1] <= within - delay, "{:?}", embedder.identity_bounds); + let queried = index.bounds.iter().find(|(call, _)| *call == "search").unwrap().1; + assert!(queried <= within - delay * 2, "the query came after two slow reads: {queried:?}"); +} + +/// The deadline has one owner: the sink. It stamps every search with it, and +/// it is what the serve loop is told (`Daemon::search_deadline`), so the loop's +/// wait and the worker's bound are one value. +#[test] +fn the_sink_owns_the_searchs_deadline_and_tells_the_loop() { + let (_root, mind) = mind_with_documents(); + let deadline = Duration::from_secs(7); + let shared = shared_with(Vec::new(), None, Vec::new(), false); + let mut sink = WorkerSink { shared: Arc::clone(&shared), identified: true, deadline }; + let asked = Instant::now(); + ask_sink(&mut sink, "anything", 3).unwrap(); + let due = lock(&shared).searches.back().expect("no worker took it").due; + assert!(due >= asked + deadline && due <= Instant::now() + deadline, "stamped with the sink's deadline"); + assert_eq!(>::search_deadline(&sink), deadline); + assert_eq!(Daemon::new(mind, sink).search_deadline(), deadline); +} diff --git a/crates/huginn-daemon/src/lib.rs b/crates/huginn-daemon/src/lib.rs new file mode 100644 index 0000000..90ae5e8 --- /dev/null +++ b/crates/huginn-daemon/src/lib.rs @@ -0,0 +1,25 @@ +//! `huginn-daemon`: one process that serves one mind over CultNet. +//! +//! The crate owns four things and no rule. `startup` owns "may this process +//! serve this mind", by delegating to `Mind::open` and refusing before +//! anything listens. `index` owns when the mind's documents are embedded and +//! written to the vector store, off the serving path. `Daemon::handle` owns "which `Mind` method answers this +//! request": it dispatches, it asks `Mind::require_instance` for every read +//! that names an instance, and it compares nothing itself. `serve` owns "which +//! session gets which reply" and whether an answer is delivered whole, deferred or refused; `bodies` +//! owns how long and how much of a deferred answer is held. +//! +//! Every admission rule, the instance check, the receipt, the derived status +//! and the wire vocabulary are `huginn-mind`'s. The leaf and the store type +//! are reached through `huginn_mind`, so one crate pins one revision of each. + +pub mod bodies; +pub mod daemon; +pub mod index; +pub mod serve; + +#[cfg(test)] +mod semantic_tests; + +pub use daemon::{Daemon, Handled, Hits, IndexSink, Search, SearchTicket, runtime_id}; +pub use serve::{Options, ServeOptions, parse_options, run, startup}; diff --git a/crates/huginn-daemon/src/main.rs b/crates/huginn-daemon/src/main.rs new file mode 100644 index 0000000..ce28fb3 --- /dev/null +++ b/crates/huginn-daemon/src/main.rs @@ -0,0 +1,37 @@ +//! The process: open one instance's mind, bind one socket, serve until a stop +//! signal. A mind that will not open ends the process with the refusal on +//! stderr and no socket ever bound. + +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use anyhow::{Context, Result}; +use huginn_daemon::index::Backoff; +use huginn_daemon::index::ollama::OllamaEmbedder; +use huginn_daemon::index::qdrant::QdrantIndex; +use huginn_daemon::serve::{ServeOptions, parse_options, run, startup}; + +fn main() -> Result<()> { + let options = parse_options(std::env::args().skip(1))?; + let embedder = OllamaEmbedder::new(&options.ollama_url, &options.embedding_model); + let index = QdrantIndex::new(&options.qdrant_url); + let (mut daemon, mut hub, registry) = match startup(&options, embedder, index, Backoff::default()) { + Ok(started) => started, + Err(error) => { + eprintln!("huginn-daemon refuses to serve {}: {error:#}", options.instance.0); + std::process::exit(1); + } + }; + + // Idunn runs this process as PID 1 of its own PID namespace, and a + // namespace init ignores every signal it has not caught. Without this, + // a stop is a ninety-second wait for SIGKILL. + let stopping = Arc::new(AtomicBool::new(false)); + for signal in [signal_hook::consts::SIGTERM, signal_hook::consts::SIGINT] { + signal_hook::flag::register(signal, Arc::clone(&stopping)) + .context("registering Huginn's stop signal handler")?; + } + + eprintln!("huginn-daemon serving {} on {}", options.instance.0, hub.local_addr()?); + run(&mut daemon, &mut hub, ®istry, &stopping, &ServeOptions::default()) +} diff --git a/crates/huginn-daemon/src/semantic_tests.rs b/crates/huginn-daemon/src/semantic_tests.rs new file mode 100644 index 0000000..f72c5b4 --- /dev/null +++ b/crates/huginn-daemon/src/semantic_tests.rs @@ -0,0 +1,491 @@ +//! The semantic read through the real serve loop: a UDP hub, real sessions, the +//! worker thread, and fake embedder and vector-store ports whose behaviour the +//! test decides. Nothing here calls `Daemon::handle` directly: the rules under +//! test are about what the loop does while an answer is outstanding. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::JoinHandle; +use std::time::Duration; + +use anyhow::Result; +use cultnet_rs::{CULTNET_OPERATION_CONNECTION_ID, CultMesh, CultMeshRudpSocketOptions, FieldPredicate, Selection}; +use huginn_mind::epiphany_pipeline::{ + Date, PipelineDocument, PipelineKind, PipelineQuestion, PipelineResolution, PipelineRef, PipelineRuling, + QuestionOption, ResolutionOutcome, RulingAuthority, Short, Title, +}; +use huginn_mind::wire::{HuginnMindRequest, HuginnMindResponse, IndexStatus}; +use huginn_mind::{Mind, MindRefusal, OwnedRedbMessagePackBackingStore, PipelineAdmissionOutcome, PipelinePageItems, SemanticQuery}; +use tempfile::TempDir; + +use crate::daemon::Daemon; +use crate::daemon::tests::{CAMPAIGN, INSTANCE, batch, campaign_seed, now, slug}; +use huginn_mind::envelope::{decode_response, encode_request}; +use crate::index::fakes::{FakeEmbedder, FakeIndex, Gate}; +use crate::index::{Backoff, Described, Hit, WorkerSink, collection_name}; +use crate::serve::{ServeOptions, bind, run, schema_registry}; + +type Client = cultnet_rs::CultNetRudpSocketTransportConnection; + +fn question(label: &str) -> PipelineDocument { + PipelineDocument::Question(PipelineQuestion { + campaign: slug(CAMPAIGN), + label: label.into(), + title: Title(format!("Question {label}")), + question: format!("Question {label}?").as_str().into(), + options: vec![ + QuestionOption { label: "A".into(), text: "the organ".into() }, + QuestionOption { label: "B".into(), text: "the client".into() }, + ], + recommended: "A".into(), + depends: vec![], + raised_in: None, + asked_on: Date("2026-09-16".into()), + }) +} + +/// A ruling, answering a question when it names one. +fn ruling(label: &str, answers: Option<&str>) -> PipelineDocument { + PipelineDocument::Ruling(PipelineRuling { + campaign: slug(CAMPAIGN), + label: label.into(), + title: Title(format!("Ruling {label}")), + answers: answers.map(|question| Short(id("question", question))), + choice: answers.map(|_| "A".into()), + ruling: format!("Ruling {label}.").as_str().into(), + operator_quote: None, + ruled_on: Date("2026-09-16".into()), + precedents: vec![], + authority: RulingAuthority::Operator, + }) +} + +fn resolution(subject: PipelineKind, key: &str, outcome: ResolutionOutcome) -> PipelineDocument { + PipelineDocument::Resolution(PipelineResolution { + subject: PipelineRef { kind: subject, id: Short(key.into()) }, + sequence: 1, + outcome, + rationale: "Resolved.".into(), + resolved_on: Date("2026-09-16".into()), + }) +} + +fn id(kind: &str, local: &str) -> String { + format!("{CAMPAIGN}:{kind}:{local}") +} + +fn hit(kind: &str, key: String, score: f32) -> Hit { + Hit { doc_id: key, kind: kind.into(), score } +} + +/// Every way a document stops standing, and two that do: Q1 is answered (so +/// resolved), Q2 was answered and the answer withdrawn (its resolution is the +/// resolved one and the question stands again), R3 is superseded by R4. +fn shelf() -> Vec> { + vec![ + campaign_seed(), + vec![question("Q1"), question("Q2")], + vec![ruling("R1", Some("Q1"))], + vec![ruling("R2", Some("Q2"))], + vec![resolution(PipelineKind::Resolution, &id("resolution", "question.Q2.n1"), ResolutionOutcome::Withdrawn { reason: "moot".into() })], + vec![ruling("R3", None), ruling("R4", None)], + vec![resolution( + PipelineKind::Ruling, + &id("ruling", "R3"), + ResolutionOutcome::Superseded { by: vec![PipelineRef { kind: PipelineKind::Ruling, id: Short(id("ruling", "R4")) }] }, + )], + ] +} + +fn standing() -> Selection { + Selection { + fields: Some(vec![FieldPredicate { + index: "in_force".into(), + op: "any_of".into(), + values: Some(vec!["true".into()]), + number: None, + }]), + ..Selection::default() + } +} + +fn semantic(selection: Selection, top_k: u32) -> HuginnMindRequest { + semantic_of("who owns the state", selection, top_k) +} + +fn semantic_of(text: &str, selection: Selection, top_k: u32) -> HuginnMindRequest { + HuginnMindRequest::Query { + instance: slug(INSTANCE), + selection, + semantic: Some(SemanticQuery { text: text.into(), top_k }), + } +} + +/// How many documents a plain query finds. +fn documents(client: &mut Client, message_id: &str) -> usize { + ids(&ask(client, message_id, &plain())).len() +} + +fn plain() -> HuginnMindRequest { + HuginnMindRequest::Query { instance: slug(INSTANCE), selection: Selection::default(), semantic: None } +} + +/// The ids on a page, in its order. +fn ids(response: &HuginnMindResponse) -> Vec { + let HuginnMindResponse::Query(page) = response else { panic!("expected a page, got {response:?}") }; + match &page.items { + PipelinePageItems::Headers(headers) => headers.iter().map(|header| header.id.id.0.clone()).collect(), + PipelinePageItems::Documents(views) => views.iter().map(|view| view.id.id.0.clone()).collect(), + } +} + +fn quick() -> Backoff { + Backoff { initial: Duration::from_millis(1), max: Duration::from_millis(2), recheck: Duration::from_secs(3600), search_deadline: Duration::from_secs(30) } +} + +/// A daemon serving a mind over a real socket, with the ports the test gave it. +struct Harness { + endpoint: String, + stopping: Arc, + serving: Option>>, + embedder: FakeEmbedder, + index: FakeIndex, + _root: TempDir, +} + +impl Harness { + fn start(seed: Vec>, embedder: FakeEmbedder, index: FakeIndex, search_timeout: Duration) -> Self { + let root = tempfile::tempdir().unwrap(); + let mut mind = Mind::open(root.path(), &slug(INSTANCE)).unwrap(); + for documents in seed { + let outcome = mind.admit(batch(INSTANCE, documents), now()); + assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}"); + } + let sink = WorkerSink::spawn( + embedder.clone(), + index.clone(), + &slug(INSTANCE), + mind.genesis_receipt_id().unwrap(), + mind.index_entries(None).unwrap(), + Backoff { search_deadline: search_timeout, ..quick() }, + ); + let mut daemon: Daemon = Daemon::new(mind, sink); + let mut hub = bind("127.0.0.1:0".parse().unwrap(), &daemon.runtime_id()).unwrap(); + let endpoint = format!("rudp://{}", hub.local_addr().unwrap()); + let registry = schema_registry().unwrap(); + let stopping = Arc::new(AtomicBool::new(false)); + let loop_stopping = Arc::clone(&stopping); + let options = ServeOptions::default(); + let serving = std::thread::spawn(move || run(&mut daemon, &mut hub, ®istry, &loop_stopping, &options)); + Self { endpoint, stopping, serving: Some(serving), embedder, index, _root: root } + } + + fn client(&self) -> Client { + let mut client = CultMesh::create_rudp_client_for_endpoint( + "semantic-test".to_string(), + CULTNET_OPERATION_CONNECTION_ID, + &self.endpoint, + CultMeshRudpSocketOptions::default(), + ) + .unwrap(); + client.connect(Vec::new()).unwrap(); + for _ in 0..1000 { + let _ = client.receive_once(); + if client.connected() { + return client; + } + std::thread::sleep(Duration::from_millis(2)); + } + panic!("the client never connected"); + } +} + +impl Drop for Harness { + fn drop(&mut self) { + self.stopping.store(true, Ordering::Relaxed); + // A loop that is stuck waiting on the index is a failure of the test, + // not a reason to hang the run: give it a moment and leave it behind. + if let Some(serving) = self.serving.take() { + for _ in 0..1500 { + if serving.is_finished() { + let _ = serving.join(); + return; + } + std::thread::sleep(Duration::from_millis(2)); + } + } + } +} + +fn send(client: &mut Client, message_id: &str, request: &HuginnMindRequest) { + client.send_schema_message(&encode_request(message_id, request, None).unwrap()).unwrap(); +} + +/// The reply to `message_id` on this client's session, or `None` if none came +/// in `patience` polls of two milliseconds. +fn receive(client: &mut Client, message_id: &str, patience: u32) -> Option { + for _ in 0..patience { + client.poll_resends().unwrap(); + if let Some(reply) = client.receive_schema_message_once().unwrap() { + let (correlated, response) = decode_response(&reply).unwrap(); + assert_eq!(correlated, message_id); + return Some(response.expect("an answer, not an envelope failure")); + } + std::thread::sleep(Duration::from_millis(2)); + } + None +} + +fn ask(client: &mut Client, message_id: &str, request: &HuginnMindRequest) -> HuginnMindResponse { + send(client, message_id, request); + receive(client, message_id, 3000).unwrap_or_else(|| panic!("no reply to {message_id}")) +} + +fn index_status(client: &mut Client, message_id: &str) -> IndexStatus { + match ask(client, message_id, &HuginnMindRequest::Whoami) { + HuginnMindResponse::Whoami(status) => status.index, + other => panic!("expected a status, got {other:?}"), + } +} + +fn until_index(client: &mut Client, what: &str, predicate: impl Fn(&IndexStatus) -> bool) { + for at in 0..2000 { + if predicate(&index_status(client, &format!("status-{at}"))) { + return; + } + std::thread::sleep(Duration::from_millis(5)); + } + panic!("the index never reported {what}"); +} + +fn healthy() -> Harness { + let (embedder, index) = (FakeEmbedder::new("d1"), FakeIndex::default()); + Harness::start(shelf(), embedder, index, Duration::from_secs(30)) +} + +/// A semantic answer is the mind's: the index names a resolved question, a +/// withdrawn resolution, a superseded ruling and a document the mind does not +/// hold, all scoring above the two that stand, and only the two that stand come +/// back, in score order. The selection's other predicates still cut the +/// candidates, and the embedder saw the query behind its instruction while the +/// index was asked for an oversample. +#[test] +fn a_semantic_query_returns_only_documents_the_mind_holds_and_the_selection_admits() { + let harness = healthy(); + let mut client = harness.client(); + until_index(&mut client, "Current", |status| *status == IndexStatus::Current); + harness.index.state.lock().unwrap().hits = vec![ + hit("question", id("question", "Q1"), 0.95), + hit("resolution", id("resolution", "question.Q2.n1"), 0.9), + hit("ruling", id("ruling", "R3"), 0.85), + hit("question", id("question", "Q99"), 0.8), + hit("ruling", id("ruling", "R4"), 0.5), + hit("question", id("question", "Q2"), 0.4), + ]; + + let standing_only = ask(&mut client, "m-1", &semantic(standing(), 5)); + assert_eq!(ids(&standing_only), vec![id("ruling", "R4"), id("question", "Q2")]); + let HuginnMindResponse::Query(page) = &standing_only else { unreachable!() }; + assert_eq!((page.matched, page.next.as_ref()), (2, None)); + + let rulings = Selection { schemas: Some(vec![PipelineKind::Ruling.type_id().into()]), ..standing() }; + assert_eq!(ids(&ask(&mut client, "m-2", &semantic(rulings, 5))), vec![id("ruling", "R4")], "the selection's schemas still apply"); + + let everything = ids(&ask(&mut client, "m-3", &semantic(Selection::default(), 5))); + assert!(everything.contains(&id("question", "Q1")) && !everything.contains(&id("question", "Q99")), + "a hit the selection does not exclude is shown, and one the mind does not hold never is: {everything:?}"); + + let embedded = harness.embedder.texts(); + assert!(embedded.iter().any(|text| text.starts_with("Instruct: ") && text.ends_with("\nQuery: who owns the state")), "{embedded:?}"); + let searches = harness.index.state.lock().unwrap().searches.clone(); + assert!(searches.iter().all(|(name, _, limit)| *name == collection_name(&slug(INSTANCE)) && *limit == 20), "{searches:?}"); +} + +/// A cursor cannot resume a ranked answer and a ranked answer has no order to +/// reverse: each is refused by name, and nothing is embedded or searched for it. +#[test] +fn a_cursor_or_a_reversal_with_a_semantic_query_is_refused_and_asks_the_index_nothing() { + let harness = healthy(); + let mut client = harness.client(); + until_index(&mut client, "Current", |status| *status == IndexStatus::Current); + let embedded = harness.embedder.texts().len(); + + let with_cursor = Selection { cursor: Some("anything".into()), ..standing() }; + let refusal = ask(&mut client, "m-1", &semantic(with_cursor, 5)); + assert!( + matches!(&refusal, HuginnMindResponse::Refused(MindRefusal::SelectionInvalid { field, .. }) if field == "cursor"), + "{refusal:?}" + ); + assert_eq!(harness.embedder.texts().len(), embedded); + assert!(harness.index.state.lock().unwrap().searches.is_empty()); + + let reversed = Selection { descending: true, ..standing() }; + let refusal = ask(&mut client, "m-2", &semantic(reversed, 5)); + assert!( + matches!(&refusal, HuginnMindResponse::Refused(MindRefusal::SelectionInvalid { field, .. }) if field == "descending"), + "{refusal:?}" + ); + assert_eq!(harness.embedder.texts().len(), embedded); + assert!(harness.index.state.lock().unwrap().searches.is_empty()); +} + +/// The loop never waits on the network. The embedder is shut, the worker is +/// inside it with one client's semantic query, and a second client's `whoami` +/// and plain query are answered at once; the first is answered when the gate +/// opens. The gate counts arrivals, so this observes where the work is and not +/// how long it took. +#[test] +fn another_session_is_answered_while_a_semantic_search_is_outstanding() { + let gate = Gate::shut(); + gate.open(); + let embedder = FakeEmbedder::new("d1").behind(&gate); + let harness = Harness::start(shelf(), embedder, FakeIndex::default(), Duration::from_secs(30)); + let mut asker = harness.client(); + let mut second = harness.client(); + let mut other = harness.client(); + until_index(&mut other, "Current", |status| *status == IndexStatus::Current); + let held = documents(&mut other, "m-count"); + harness.index.state.lock().unwrap().hits = vec![hit("ruling", id("ruling", "R4"), 0.5)]; + + gate.shut_again(); + send(&mut asker, "m-ask", &semantic(standing(), 5)); + gate.wait_arrived(1); + assert!(receive(&mut asker, "m-ask", 20).is_none(), "the question is not answered while its embedding is outstanding"); + // A second question waits behind the first, and each is answered to its own asker. + send(&mut second, "m-ask-2", &semantic(Selection { schemas: Some(vec![PipelineKind::Question.type_id().into()]), ..Selection::default() }, 5)); + + assert!(matches!(ask(&mut other, "m-who", &HuginnMindRequest::Whoami), HuginnMindResponse::Whoami(_))); + assert_eq!(documents(&mut other, "m-plain"), held, "a plain query is answered too"); + + gate.open(); + let answered = receive(&mut asker, "m-ask", 3000).expect("the search is answered once the embedder returns"); + assert_eq!(ids(&answered), vec![id("ruling", "R4")]); + let second_answer = receive(&mut second, "m-ask-2", 3000).expect("the second search is answered too, on its own session"); + assert_eq!(ids(&second_answer), Vec::::new(), "the index offered a ruling, which its selection of questions does not admit"); +} + +/// A search the index never finishes is answered at the deadline the client +/// was promised, typed and naming the wait, never as an empty page. +#[test] +fn a_search_the_index_never_finishes_is_answered_unavailable_at_the_deadline() { + let gate = Gate::shut(); + gate.open(); + let embedder = FakeEmbedder::new("d1").behind(&gate); + let harness = Harness::start(shelf(), embedder, FakeIndex::default(), Duration::from_millis(150)); + let mut client = harness.client(); + until_index(&mut client, "Current", |status| *status == IndexStatus::Current); + + gate.shut_again(); + let answered = ask(&mut client, "m-late", &semantic(standing(), 5)); + let HuginnMindResponse::Refused(MindRefusal::Unavailable { detail }) = &answered else { panic!("{answered:?}") }; + assert!(detail.contains("did not finish"), "{detail}"); + gate.open(); + assert!(matches!(ask(&mut client, "m-after", &HuginnMindRequest::Whoami), HuginnMindResponse::Whoami(_)), "the late answer is dropped, not misdelivered"); +} + +/// An index that cannot answer is a typed `Unavailable` and never an empty +/// page, in each way it cannot: the embedder is down when the query arrives; +/// the worker is failing; the collection belongs to someone else. Plain +/// queries are unaffected throughout. +#[test] +fn an_index_that_cannot_answer_is_unavailable_and_plain_queries_still_work() { + let unavailable = |response: HuginnMindResponse| match response { + HuginnMindResponse::Refused(MindRefusal::Unavailable { detail }) => detail, + other => panic!("expected Unavailable, got {other:?}"), + }; + + // The embedder goes down after the index was Current: the query itself fails. + let harness = healthy(); + let mut client = harness.client(); + until_index(&mut client, "Current", |status| *status == IndexStatus::Current); + let held = documents(&mut client, "m-count"); + harness.embedder.state.lock().unwrap().down = true; + let detail = unavailable(ask(&mut client, "m-down", &semantic(standing(), 5))); + assert!(detail.contains("the embedder is down"), "{detail}"); + assert_eq!(documents(&mut client, "m-plain"), held); + + // A write while it is down makes the worker Failing, and the next query is + // refused without being asked of it. + let admit = HuginnMindRequest::Admit(batch(INSTANCE, vec![question("Q7")])); + assert!(matches!(ask(&mut client, "m-admit", &admit), HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { .. }))); + until_index(&mut client, "Failing", |status| matches!(status, IndexStatus::Failing { .. })); + let searches = harness.index.state.lock().unwrap().searches.len(); + let detail = unavailable(ask(&mut client, "m-failing", &semantic(standing(), 5))); + assert!(detail.contains("failing") && detail.contains("the embedder is down"), "{detail}"); + assert_eq!(harness.index.state.lock().unwrap().searches.len(), searches); + assert_eq!(documents(&mut client, "m-plain-2"), held + 1, "the admitted question is in the mind whatever the index says"); + + // A collection that is another writer's is Refused, and so are queries. + let (embedder, index) = (FakeEmbedder::new("d1"), FakeIndex::default()); + index.plant(&collection_name(&slug(INSTANCE)), Described::Unlabelled); + let refused = Harness::start(shelf(), embedder, index, Duration::from_secs(30)); + let mut client = refused.client(); + until_index(&mut client, "Refused", |status| matches!(status, IndexStatus::Refused { .. })); + let detail = unavailable(ask(&mut client, "m-refused", &semantic(standing(), 5))); + assert!(detail.contains("refused") && detail.contains("no Huginn metadata"), "{detail}"); + assert_eq!(documents(&mut client, "m-plain-3"), held); +} + +/// A search the client has stopped waiting for is not started. The embedder is +/// held with the first search inside it; a second search queues behind it and +/// times out, and when the embedder is released the worker embeds the first, +/// skips the second and embeds a third: the index is never asked for an answer +/// nobody is waiting for. +#[test] +fn a_search_that_timed_out_while_queued_is_never_embedded() { + let gate = Gate::shut(); + gate.open(); + let embedder = FakeEmbedder::new("d1").behind(&gate); + let harness = Harness::start(shelf(), embedder, FakeIndex::default(), Duration::from_millis(300)); + let mut first = harness.client(); + let mut second = harness.client(); + let mut third = harness.client(); + until_index(&mut third, "Current", |status| *status == IndexStatus::Current); + let queries = |harness: &Harness| -> Vec { + harness.embedder.texts().into_iter().filter(|text| text.starts_with("Instruct: ")).collect() + }; + + gate.shut_again(); + send(&mut first, "m-first", &semantic_of("first", standing(), 5)); + gate.wait_arrived(1); + send(&mut second, "m-second", &semantic_of("second", standing(), 5)); + let timed_out = receive(&mut second, "m-second", 3000).expect("the second search is answered at its deadline"); + assert!(matches!(&timed_out, HuginnMindResponse::Refused(MindRefusal::Unavailable { detail }) if detail.contains("did not finish")), "{timed_out:?}"); + + gate.open(); + send(&mut third, "m-third", &semantic_of("third", standing(), 5)); + let answered = receive(&mut third, "m-third", 3000).expect("the third search is answered"); + assert!(matches!(answered, HuginnMindResponse::Query(_)), "{answered:?}"); + let embedded = queries(&harness); + assert_eq!(embedded.len(), 2, "the first and the third were embedded; the second was taken back before it started: {embedded:?}"); + assert!(embedded[0].ends_with("Query: first") && embedded[1].ends_with("Query: third"), "{embedded:?}"); +} + +/// A search asked while the index lags the mind is not refused: it waits behind +/// the writes ahead of it, through the real serve loop, and reads them. The +/// worker is inside its first batch with the rest of the mind still to write; +/// the reply does not come until the gate opens, and it comes with every +/// document already in the collection. +#[test] +fn a_search_asked_while_the_index_lags_waits_for_the_writes_ahead_of_it_and_reads_them() { + let gate = Gate::shut(); + let embedder = FakeEmbedder::new("d1").behind(&gate); + let harness = Harness::start(shelf(), embedder, FakeIndex::default(), Duration::from_secs(30)); + gate.wait_arrived(1); + let mut client = harness.client(); + let IndexStatus::Behind { pending } = index_status(&mut client, "m-status") else { panic!("the worker is inside its first batch") }; + assert!(pending > 0); + harness.index.state.lock().unwrap().hits = vec![hit("ruling", id("ruling", "R4"), 0.5)]; + + send(&mut client, "m-behind", &semantic(standing(), 5)); + assert!(receive(&mut client, "m-behind", 100).is_none(), "the search waits: it is neither refused nor answered from a half-filled collection"); + + gate.open(); + let answered = receive(&mut client, "m-behind", 3000).expect("the search is answered once the index has caught up"); + assert_eq!(ids(&answered), vec![id("ruling", "R4")]); + until_index(&mut client, "Current", |status| *status == IndexStatus::Current); + let state = harness.index.state.lock().unwrap(); + let written = state.collections[&collection_name(&slug(INSTANCE))].points.len(); + assert_eq!(state.held_when_searched, [written], "the search saw every document"); + assert!(written >= pending as usize); +} diff --git a/crates/huginn-daemon/src/serve.rs b/crates/huginn-daemon/src/serve.rs new file mode 100644 index 0000000..bb9fb8e --- /dev/null +++ b/crates/huginn-daemon/src/serve.rs @@ -0,0 +1,1415 @@ +//! The loop: one UDP socket, one reliable session per peer, one reply per +//! frame on the frame's own session. The hub is CultLib's; what is here is the +//! routing, the schema catalog and the order the process starts in. +//! +//! Stated limits. A socket error and a hostile datagram are indistinguishable +//! at `receive_event_once`, so both are logged and served past: a dead socket +//! spins with logging rather than exiting, and Cut 14's health check is the +//! observer. +//! +//! One send carries `MAX_RESPONSE_BYTES` encoded, 1,228,800 today: the +//! reliable window one session holds, `MAX_PENDING_RELIABLE_PACKETS` packets +//! of `MAX_FRAGMENT_BYTES`. `deliver` decides every answer's plane: one that +//! fits goes whole; a larger one whose encoded payload is at most +//! `MAX_DEFERRED_BODY_BYTES` is held by `DeferredBodies` and answered with +//! `HuginnMindResponse::Deferred`, a `CultMeshCdnArtifactManifest`, and the +//! client fetches the payload with `cultnet_rs::fetch_content`, one +//! `cultmesh.content_chunk_request.v1` at a time on the session it already +//! holds, exactly as the C# reference's `CultMeshLegacyRudpContentServer` is +//! asked. The fetched bytes are the named MessagePack of the +//! `HuginnMindResponse` the mind gave. A body the daemon will not defer is +//! refused by name, `MindRefusal::ResponseTooLarge`, carrying the payload size +//! and `MAX_DEFERRED_BODY_BYTES`, and the caller narrows its own request. A +//! deferred body is memory only: it expires untouched after +//! `ServeOptions::deferred_ttl`, is evicted past `DEFERRED_BUDGET_BYTES`, and +//! is gone when the process exits, so a chunk request that finds it gone is +//! answered `found: false` and the client asks the operation again. +//! +//! Four things this cut does not do, which a client has nowhere else to learn: +//! +//! - A session idle for `ServeOptions::session_timeout`, 30 seconds, is +//! removed here while the client still believes it is connected; its next +//! request is answered by nothing. A client that intends to stay must speak +//! inside that window or reconnect. +//! - An envelope CultNet's own `validate_message` rejects is dropped by the +//! hub before this module sees it, so it is never answered. +//! - Shutdown neither drains what is in flight nor disconnects its peers: the +//! loop stops and the sockets close under whatever was queued. +//! - `response-not-encodable` is authored here and is unreachable in practice; +//! every response type encodes. +//! +//! Two reads pipelined on one session are not both answered: the window is +//! per session and counts what is unacknowledged, so a second large reply sent +//! before the first is acknowledged fails to send even when each passes the +//! size gate on its own. The gate measures one answer against the window, not +//! against what the session still has room for. +//! +//! The same counting makes the window one packet short until a session has +//! settled: the hub's own accept is unacknowledged reliable traffic, so a +//! session that has just connected holds 1023 packets rather than 1024 until +//! the client's acknowledgement arrives. On loopback that happens before the +//! first request, and a lost acknowledgement leaves the hole open one resend +//! longer. Nothing here depends on it; a test that measures the boundary +//! exactly does, and settles the session first. + +use std::collections::BTreeMap; +use std::net::{SocketAddr, UdpSocket}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, ensure}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use chrono::{DateTime, Utc}; +use cultnet_rs::{ + CULTNET_OPERATION_CONNECTION_ID, CultNetMessage, CultNetRudpServerEvent, CultNetRudpServerHub, + CultNetRudpServerHubOptions, CultNetRudpServerSessionContext, CultNetSchemaKind, CultNetSchemaRegistration, CultNetSchemaRegistry, + CultNetWireContract, answer_content_chunk_request, decode_cultnet_message_from_slice, + encode_cultnet_message_to_vec, pack_content, +}; +use huginn_mind::epiphany_pipeline::Slug; +use huginn_mind::wire::{ + DeferredAnswer, HuginnMindResponse, MIND_REQUEST_SCHEMA, MIND_REQUEST_SCHEMA_JSON, MIND_RESPONSE_SCHEMA, + MIND_RESPONSE_SCHEMA_JSON, MIND_SERVICE_ID, +}; +use huginn_mind::{Mind, MindRefusal, MindStore, OwnedRedbMessagePackBackingStore}; + +use crate::bodies::{ + DEFERRED_BUDGET_BYTES, DEFERRED_CHUNK_BYTES, DeferredBodies, +}; +use crate::daemon::{Daemon, Handled, IndexSink, Search, SearchTicket, runtime_id}; +use huginn_mind::envelope::{OperationFailure, decode_request, encode_failure, encode_response}; +use huginn_mind::MAX_DEFERRED_BODY_BYTES; +use crate::index::{Backoff, Embedder, VectorIndex, WorkerSink}; + +/// What the loop does when nothing is waiting. A deferred body untouched for +/// `deferred_ttl`, twice the session timeout, is dropped. +pub struct ServeOptions { + pub session_timeout: Duration, + pub idle_sleep: Duration, + pub deferred_ttl: Duration, +} + +impl Default for ServeOptions { + fn default() -> Self { + Self { + session_timeout: Duration::from_secs(30), + idle_sleep: Duration::from_millis(2), + deferred_ttl: Duration::from_secs(60), + } + } +} + +/// The six things the operator supplies. Nothing here reads the environment: +/// how Idunn supplies `--bind` is Cut 14's. The index has no off switch: an +/// unreachable Qdrant or Ollama is a degraded projection, reported by +/// `whoami`, not a configuration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Options { + pub state_root: PathBuf, + pub instance: Slug, + pub bind: SocketAddr, + pub qdrant_url: String, + pub ollama_url: String, + pub embedding_model: String, +} + +/// Exactly `--state-root`, `--instance`, `--bind`, `--qdrant-url`, +/// `--ollama-url` and `--embedding-model`, each required, each once. The two +/// URLs are plain `http://`: this build carries no TLS. +pub fn parse_options(args: impl Iterator) -> Result { + let mut args = args; + let mut values: BTreeMap = BTreeMap::new(); + while let Some(name) = args.next() { + let name = name.strip_prefix("--").with_context(|| format!("expected --option, got {name:?}"))?; + ensure!( + matches!(name, "state-root" | "instance" | "bind" | "qdrant-url" | "ollama-url" | "embedding-model"), + "unsupported Huginn option --{name}" + ); + let value = args.next().with_context(|| format!("missing value for --{name}"))?; + ensure!(values.insert(name.to_owned(), value).is_none(), "duplicate Huginn option --{name}"); + } + let take = |name: &str| -> Result { + values.get(name).cloned().with_context(|| format!("--{name} is required")) + }; + let state_root = PathBuf::from(take("state-root")?); + ensure!(state_root.is_absolute(), "--state-root must be an absolute path"); + let bind = take("bind")?; + let url = |name: &str| -> Result { + let value = take(name)?; + ensure!(value.starts_with("http://"), "--{name} must be a plain http:// address, got {value:?}"); + Ok(value) + }; + let embedding_model = take("embedding-model")?; + ensure!(!embedding_model.is_empty(), "--embedding-model must name a model"); + Ok(Options { + state_root, + instance: Slug(take("instance")?), + bind: bind.parse().with_context(|| format!("--bind must be an ip:port, got {bind:?}"))?, + qdrant_url: url("qdrant-url")?, + ollama_url: url("ollama-url")?, + embedding_model, + }) +} + +/// Ruling 15, as an order: the mind opens first, and only a mind that opened +/// gets a socket, and only a daemon that is going to serve gets an index +/// worker. This is the one place all three happen, so nothing can bind ahead +/// of the refusal and nothing reaches the vector store for a process that +/// never listens. The worker's first act is to reconcile the collection +/// against every indexable document the mind holds now. +pub fn startup( + options: &Options, + embedder: E, + index: V, + backoff: Backoff, +) -> Result<(Daemon, CultNetRudpServerHub, CultNetSchemaRegistry)> +where + E: Embedder + Send + 'static, + V: VectorIndex + Send + 'static, +{ + let mind = Mind::open(&options.state_root, &options.instance)?; + let hub = bind(options.bind, &runtime_id(&options.instance))?; + let startup_entries = mind.index_entries(None)?; + let sink = WorkerSink::spawn(embedder, index, &options.instance, mind.genesis_receipt_id()?, startup_entries, backoff); + Ok((Daemon::new(mind, sink), hub, schema_registry()?)) +} + +/// The bytes one reliable packet carries, and the packets one session may hold +/// unacknowledged. Their product is the largest answer a single send can +/// carry, and `answer` measures every reply against it rather than handing the +/// hub a send it can already see will fail. +pub const MAX_FRAGMENT_BYTES: u32 = 1200; +pub const MAX_PENDING_RELIABLE_PACKETS: u32 = 1024; +/// 1,228,800 bytes. +pub const MAX_RESPONSE_BYTES: u64 = MAX_FRAGMENT_BYTES as u64 * MAX_PENDING_RELIABLE_PACKETS as u64; + +/// One non-blocking socket serving the operation connection id. The hub drops +/// every datagram carrying another one. +pub fn bind(addr: SocketAddr, runtime_id: &str) -> Result { + let socket = UdpSocket::bind(addr).with_context(|| format!("binding {addr}"))?; + socket.set_nonblocking(true)?; + let mut options = CultNetRudpServerHubOptions::new(runtime_id, socket, CULTNET_OPERATION_CONNECTION_ID); + options.max_fragment_bytes = Some(MAX_FRAGMENT_BYTES); + options.max_pending_reliable_packets = Some(MAX_PENDING_RELIABLE_PACKETS); + options.max_peers = 256; + CultNetRudpServerHub::new(options) +} + +/// The organ's two contracts, as the catalog advertises them. +pub fn schema_registry() -> Result { + let mut registry = CultNetSchemaRegistry::new(); + for (schema_id, title, schema_json) in [ + (MIND_REQUEST_SCHEMA, "Huginn mind request", MIND_REQUEST_SCHEMA_JSON), + (MIND_RESPONSE_SCHEMA, "Huginn mind response", MIND_RESPONSE_SCHEMA_JSON), + ] { + registry.register(CultNetSchemaRegistration { + schema_id: schema_id.to_string(), + kind: CultNetSchemaKind::WireMessage, + wire_contracts: vec![CultNetWireContract::CultNetSchemaV0], + schema_version: Some(schema_id.to_string()), + document_type: None, + title: Some(title.to_string()), + schema_json: Some(schema_json.to_string()), + })?; + } + Ok(registry) +} + +/// The longest client-supplied string a reply may echo, in bytes. A reply +/// echoes the request's `message_id` (and, on a failure, its `service_id`, +/// `operation` and `payload_schema`; on a chunk answer, its `chunk_hash`), so an +/// unbounded field would make the reply unbounded, and the hub would refuse a +/// send the client then waits on forever. Every echoed field is refused past +/// this at the door, with a transport `Error` that carries none of them. With +/// it, every reply is within the window: the largest, a deferred answer or a +/// refusal, is one 256-byte id, a real operation name, the daemon's own runtime +/// id and, for a deferred answer, a manifest of at most +/// `MAX_DEFERRED_BODY_BYTES / DEFERRED_CHUNK_BYTES` chunk references. Real ids +/// are UUID-sized. +pub const MAX_ECHOED_FIELD_BYTES: usize = 256; + +/// The first field of a request that a reply would echo and that is over +/// `MAX_ECHOED_FIELD_BYTES`. +fn overlong_echo(message: &CultNetMessage) -> Option<&'static str> { + let echoed: Vec<(&'static str, &str)> = match message { + CultNetMessage::OperationRequest { message_id, service_id, operation, payload_schema, .. } => { + vec![ + ("message_id", message_id), + ("service_id", service_id), + ("operation", operation), + ("payload_schema", payload_schema), + ] + } + CultNetMessage::SchemaCatalogRequest { message_id, .. } => vec![("message_id", message_id)], + CultNetMessage::ContentChunkRequest { message_id, chunk_hash, record_key, .. } => { + vec![("message_id", message_id), ("chunk_hash", chunk_hash), ("record_key", record_key)] + } + _ => return None, + }; + echoed.into_iter().find(|(_, value)| value.len() > MAX_ECHOED_FIELD_BYTES).map(|(name, _)| name) +} + +/// What one message came to: a message to send back now, or a semantic query +/// the index has yet to answer, which the loop holds against the session it +/// came in on. +pub enum Routed { + Reply(CultNetMessage), + Search { message_id: String, operation: &'static str, search: Search }, +} + +impl Routed { + /// The reply, for a message that cannot be a search. + #[cfg(test)] + pub(crate) fn reply(self) -> CultNetMessage { + match self { + Self::Reply(reply) => reply, + Self::Search { .. } => panic!("expected a reply, got a search"), + } + } +} + +/// One message in, one message out, or a search to wait for. Nothing here +/// reaches a mind except through `Daemon::handle`, and an envelope that does +/// not decode never gets that far. A chunk request is answered from `bodies` +/// alone, on the session it came in on; every operation's reply leaves through +/// `respond`. +pub fn answer>( + daemon: &mut Daemon, + registry: &CultNetSchemaRegistry, + bodies: &mut DeferredBodies, + message: CultNetMessage, + now: DateTime, +) -> Routed { + let runtime_id = daemon.runtime_id(); + bodies.expire(now); + if let Some(field) = overlong_echo(&message) { + return Routed::Reply(CultNetMessage::Error { + error: format!("{MIND_SERVICE_ID} refuses a {field} over {MAX_ECHOED_FIELD_BYTES} bytes"), + code: None, + details: None, + }); + } + Routed::Reply(match &message { + CultNetMessage::OperationRequest { message_id, operation, .. } => match decode_request(&message) { + Ok((message_id, request)) => { + let operation = request.operation(); + match daemon.handle(request, now) { + Handled::Answered(response) => respond(&response, &message_id, operation, &runtime_id, bodies, now), + Handled::Searching(search) => return Routed::Search { message_id, operation, search }, + } + } + Err(failure) => encode_failure(message_id, operation, &failure, &runtime_id), + }, + CultNetMessage::SchemaCatalogRequest { .. } => match registry.create_catalog_response(&message) { + Ok(response) => response, + Err(error) => CultNetMessage::Error { error: format!("{error:#}"), code: None, details: None }, + }, + CultNetMessage::ContentChunkRequest { .. } => { + answer_content_chunk_request(&message, |hash| bodies.chunk(hash, now)) + } + _ => CultNetMessage::Error { + error: format!( + "{MIND_SERVICE_ID} answers cultnet.operation_request.v0, cultnet.schema_catalog_request.v0 \ + and cultmesh.content_chunk_request.v1" + ), + code: None, + details: None, + }, + }) +} + +/// One answer as the message that carries it: encoded, then delivered whole, +/// deferred or refused by `deliver`. +fn respond( + response: &HuginnMindResponse, + message_id: &str, + operation: &str, + runtime_id: &str, + bodies: &mut DeferredBodies, + now: DateTime, +) -> CultNetMessage { + let reply = encode_or_fail(message_id, operation, response, runtime_id); + deliver(reply, message_id, operation, runtime_id, bodies, now) +} + +/// One response in its envelope, or the failure that says it did not encode. +fn encode_or_fail( + message_id: &str, + operation: &str, + response: &HuginnMindResponse, + runtime_id: &str, +) -> CultNetMessage { + match encode_response(message_id, operation, response, runtime_id) { + Ok(message) => message, + Err(error) => encode_failure( + message_id, + operation, + &OperationFailure { + code: "response-not-encodable".into(), + message: format!("{error:#}"), + }, + runtime_id, + ), + } +} + +/// How one answer leaves: whole, deferred or refused, in that order, and +/// nothing else decides. The hub cuts a reply into `MAX_FRAGMENT_BYTES` packets +/// and refuses a send needing more than `MAX_PENDING_RELIABLE_PACKETS` of them; +/// that refusal is a transport error the client never sees, so it waits for an +/// answer nothing will send. The envelope is therefore measured here, against +/// the window this module configured, before a send that can already be seen to +/// fail is attempted. +/// +/// A reply that fits is returned as it is. A larger one whose payload, the +/// named MessagePack of the response, is at most `MAX_DEFERRED_BODY_BYTES` is +/// packed by CultNet's content plane, retained in `bodies`, and answered with +/// `Deferred` and the manifest. That answer is bounded, not fixed-shape: the id +/// it echoes is at most `MAX_ECHOED_FIELD_BYTES` and its manifest at most 256 +/// references, so it always fits one send and is never itself measured or +/// deferred. Its envelope status is the deferred answer's own, read from the +/// reply before deferral, so routing on status is the same whole or deferred. A +/// larger payload is refused by name +/// with its own size and the deferral bound: nothing is truncated or paginated, +/// and the caller narrows its own request. Both answers ride the response +/// schema like every other, so a client parses no second vocabulary. +fn deliver( + reply: CultNetMessage, + message_id: &str, + operation: &str, + runtime_id: &str, + bodies: &mut DeferredBodies, + now: DateTime, +) -> CultNetMessage { + let not_encodable = |message: String| { + encode_failure( + message_id, + operation, + &OperationFailure { code: "response-not-encodable".into(), message }, + runtime_id, + ) + }; + let encoded = match encode_cultnet_message_to_vec(&reply, CultNetWireContract::CultNetSchemaV0) { + Ok(bytes) => bytes.len() as u64, + Err(error) => return not_encodable(format!("{error:#}")), + }; + if encoded <= MAX_RESPONSE_BYTES { + return reply; + } + let (payload, answer_status) = match &reply { + CultNetMessage::OperationResponse { payload, status, .. } => (STANDARD.decode(payload), status.clone()), + _ => return not_encodable("only an operation response is deferred".into()), + }; + let payload = match payload { + Ok(bytes) => bytes, + Err(error) => return not_encodable(format!("the payload is not base64: {error}")), + }; + let size = payload.len() as u64; + if size > MAX_DEFERRED_BODY_BYTES { + let refusal = HuginnMindResponse::Refused(MindRefusal::ResponseTooLarge { + bytes: size, + limit: MAX_DEFERRED_BODY_BYTES, + }); + return encode_or_fail(message_id, operation, &refusal, runtime_id); + } + let (manifest, chunks) = pack_content( + "huginn.mind_response", + "package", + "", + "application/vnd.gamecult.huginn.mind-response+msgpack", + &now.to_rfc3339(), + &payload, + DEFERRED_CHUNK_BYTES, + ) + .expect("the deferral constants are a valid pack"); + bodies.retain(&manifest, chunks, now); + let mut deferred = + encode_or_fail(message_id, operation, &HuginnMindResponse::Deferred(DeferredAnswer { manifest }), runtime_id); + if let CultNetMessage::OperationResponse { status, .. } = &mut deferred { + *status = answer_status; + } + deferred +} + +/// A semantic query on its way through the index: who asked, what to call the +/// answer, the search to finish, and when the client stops being promised one. +struct Waiting { + session: CultNetRudpServerSessionContext, + message_id: String, + operation: &'static str, + search: Search, + until: Instant, +} + +/// Until `stopping`: expire what timed out, resend what was not acknowledged, +/// answer every frame waiting on the socket, then answer every semantic query +/// the index has finished or that has waited out the index's own +/// `search_deadline`. A semantic +/// query is never answered inline: `answer` hands back a `Search`, the loop +/// holds it beside its session and goes on serving, and the embedder and the +/// vector store are the worker's to wait on. A hostile datagram and a departed +/// session are logged and served past, never fatal. The clock is read once per +/// frame, and once per pass for the searches' deadlines. +pub fn run>( + daemon: &mut Daemon, + hub: &mut CultNetRudpServerHub, + registry: &CultNetSchemaRegistry, + stopping: &AtomicBool, + options: &ServeOptions, +) -> Result<()> { + let timeout_ms = options.session_timeout.as_millis() as u64; + let mut bodies = DeferredBodies::new(DEFERRED_BUDGET_BYTES, options.deferred_ttl); + let mut waiting: BTreeMap = BTreeMap::new(); + while !stopping.load(Ordering::Relaxed) { + hub.remove_timed_out_sessions(timeout_ms); + if let Err(error) = hub.poll_resends() { + eprintln!("huginn: resending to a peer failed: {error:#}"); + } + let mut served = 0_u32; + loop { + let event = match hub.receive_event_once() { + Ok(Some(event)) => event, + Ok(None) => break, + Err(error) => { + eprintln!("huginn: a datagram was not readable and was discarded: {error:#}"); + break; + } + }; + let CultNetRudpServerEvent::Frame { session, frame } = event else { + continue; + }; + if frame.channel_id != "schema" { + continue; + } + let message = match decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0) + { + Ok(message) => message, + Err(error) => { + eprintln!("huginn: a frame was not a CultNet message and was discarded: {error:#}"); + continue; + } + }; + match answer(daemon, registry, &mut bodies, message, Utc::now()) { + Routed::Reply(reply) => send(hub, &session, &reply), + Routed::Search { message_id, operation, search } => { + let until = Instant::now() + daemon.search_deadline(); + waiting.insert(search.ticket, Waiting { session, message_id, operation, search, until }); + } + } + served += 1; + } + let finished = collect_searches(daemon, &waiting); + let answered = !finished.is_empty(); + for (ticket, found) in finished { + let Some(asked) = waiting.remove(&ticket) else { continue }; + let response = daemon.finish(asked.search, found); + let reply = respond(&response, &asked.message_id, asked.operation, &daemon.runtime_id(), &mut bodies, Utc::now()); + send(hub, &asked.session, &reply); + } + if served == 0 && !answered { + std::thread::sleep(options.idle_sleep); + } + } + Ok(()) +} + +/// What the index has finished, then what has waited too long: the latter as +/// an `Unavailable` detail, and abandoned, so the index does not start a search +/// nobody is waiting for. A search that finishes after its deadline finds +/// nothing waiting for it and is dropped. +fn collect_searches>( + daemon: &mut Daemon, + waiting: &BTreeMap, +) -> Vec<(SearchTicket, Result)> { + let deadline = daemon.search_deadline(); + let mut collected = daemon.searched(); + let now = Instant::now(); + for (ticket, asked) in waiting { + if asked.until <= now { + daemon.abandon(*ticket); + collected.push((*ticket, Err(format!("the semantic search did not finish within {deadline:?}")))); + } + } + collected +} + +fn send(hub: &mut CultNetRudpServerHub, session: &CultNetRudpServerSessionContext, reply: &CultNetMessage) { + if let Err(error) = hub.send_schema_message(session, reply) { + eprintln!("huginn: {} did not receive its reply: {error:#}", session.remote_addr); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::daemon::tests::{ + FITTING_CHANGES, FITTING_CUT, INSTANCE, NoIndex, OTHER, WIDE_CHANGES, WIDE_CUT, batch, identity, now, + open_unindexed, seeded_wide, slug, + }; + use huginn_mind::epiphany_pipeline::{PipelineKind, PipelineRef}; + use huginn_mind::envelope::{FAILURE_SCHEMA, decode_response, encode_request}; + use crate::index::Backoff; + use base64::Engine; + use base64::engine::general_purpose::STANDARD; + use cultnet_rs::{CultMesh, CultMeshCdnArtifactManifest, CultMeshRudpSocketOptions, fetch_content}; + use huginn_mind::wire::{HuginnMindRequest, HuginnMindResponse, MindStatus}; + use cultnet_rs::{FieldPredicate, Selection}; + use huginn_mind::{MindRefusal, PipelineAdmissionOutcome, PipelinePageItems}; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + + fn documents(daemon: &mut Daemon) -> u32 { + match daemon.handle(HuginnMindRequest::Whoami, now()).answered() { + HuginnMindResponse::Whoami(MindStatus { documents, .. }) => documents, + other => panic!("expected a status, got {other:?}"), + } + } + + fn request( + message_id: &str, + service_id: &str, + operation: &str, + payload_schema: &str, + payload: String, + ) -> CultNetMessage { + CultNetMessage::OperationRequest { + message_id: message_id.into(), + service_id: service_id.into(), + operation: operation.into(), + payload_schema: payload_schema.into(), + payload_encoding: "messagepack-base64".into(), + payload, + source_runtime_id: None, + target_runtime_id: None, + } + } + + fn rejected(message: &CultNetMessage) -> (String, String, String) { + let CultNetMessage::OperationResponse { message_id, status, payload_schema, payload, .. } = message else { + panic!("expected an operation response, got {message:?}"); + }; + assert_eq!(status, "rejected"); + assert_eq!(payload_schema, FAILURE_SCHEMA); + let failure = decode_response(message).unwrap().1.unwrap_err(); + (message_id.clone(), failure.code, payload.clone()) + } + + /// Six malformed envelopes, each with its own code, none of which reaches + /// a mind; a message that is not an operation at all is answered rather + /// than dropped; and the catalog answers with both schemas. + #[test] + fn a_malformed_envelope_is_answered_with_a_failure_and_touches_no_mind() { + let root = tempfile::tempdir().unwrap(); + let mut daemon = open_unindexed(root.path(), &slug(INSTANCE)).unwrap(); + let registry = schema_registry().unwrap(); + let admit = STANDARD.encode( + rmp_serde::to_vec_named(&HuginnMindRequest::Admit(batch(INSTANCE, vec![identity(INSTANCE)]))).unwrap(), + ); + + let cases = [ + ("m-1", request("m-1", "odin.catalog", "admit", MIND_REQUEST_SCHEMA, admit.clone()), "wrong-service"), + ("m-2", request("m-2", MIND_SERVICE_ID, "admit", "odin.topology.v1", admit.clone()), "wrong-payload-schema"), + ( + "m-3", + request("m-3", MIND_SERVICE_ID, "admit", MIND_REQUEST_SCHEMA, "not base64!!".into()), + "payload-not-base64", + ), + ( + "m-4", + request("m-4", MIND_SERVICE_ID, "admit", MIND_REQUEST_SCHEMA, STANDARD.encode([0xc1_u8, 0xc1])), + "payload-not-a-request", + ), + ("m-5", request("m-5", MIND_SERVICE_ID, "query", MIND_REQUEST_SCHEMA, admit.clone()), "operation-mismatch"), + // The same operation under another casing is another name, not this + // one: the comparison is of bytes, so `Admit` is no more `admit` + // than `query` is. + ("m-5b", request("m-5b", MIND_SERVICE_ID, "Admit", MIND_REQUEST_SCHEMA, admit), "operation-mismatch"), + ]; + for (id, message, code) in cases { + let reply = answer(&mut daemon, ®istry, &mut fresh_bodies(), message, now()).reply(); + assert_eq!(rejected(&reply).0, id, "the client's own correlation key is echoed"); + assert_eq!(rejected(&reply).1, code); + } + // The sixth code, `not-an-operation-request`, is the codec's and is + // pinned in `huginn_mind::envelope::tests`: `answer` never produces it, because a + // message of another family is answered with `Error` instead. + let reply = answer(&mut daemon, ®istry, &mut fresh_bodies(), CultNetMessage::Error { error: "hello".into(), code: None, details: None }, now()).reply(); + let CultNetMessage::Error { error, .. } = &reply else { panic!("expected an error, got {reply:?}") }; + assert!( + !error.is_empty() && error.contains("cultnet.operation_request.v0") && error.contains("cultnet.schema_catalog_request.v0"), + "{error}" + ); + + assert_eq!(documents(&mut daemon), 0, "no malformed envelope reached the mind"); + + // A mind's refusal is an answer on the response schema, not a failure + // of the envelope: the client decodes it as the typed refusal it is. + let read = HuginnMindRequest::Query { instance: slug(OTHER), selection: Selection::default(), semantic: None }; + let reply = answer(&mut daemon, ®istry, &mut fresh_bodies(), encode_request("m-r", &read, None).unwrap(), now()).reply(); + let CultNetMessage::OperationResponse { status, payload_schema, .. } = &reply else { + panic!("expected an operation response, got {reply:?}"); + }; + assert_eq!((status.as_str(), payload_schema.as_str()), ("rejected", MIND_RESPONSE_SCHEMA)); + let (correlation, answered) = decode_response(&reply).unwrap(); + assert_eq!(correlation, "m-r"); + assert_eq!( + answered.unwrap(), + HuginnMindResponse::Refused(MindRefusal::ForeignInstance { declared: OTHER.into(), mind: INSTANCE.into() }) + ); + + let catalog = CultNetMessage::SchemaCatalogRequest { + message_id: "m-6".into(), + include_schema_json: Some(true), + schema_ids: None, + kinds: None, + }; + let reply = answer(&mut daemon, ®istry, &mut fresh_bodies(), catalog, now()).reply(); + let CultNetMessage::SchemaCatalogResponse { message_id, schemas } = reply else { + panic!("expected a catalog response"); + }; + assert_eq!(message_id, "m-6"); + let ids: Vec<&str> = schemas.iter().map(|schema| schema.schema_id.as_str()).collect(); + assert_eq!(ids, [MIND_REQUEST_SCHEMA, MIND_RESPONSE_SCHEMA]); + for schema in &schemas { + assert_eq!(schema.content_hash, registry.get(&schema.schema_id, true).unwrap().content_hash); + assert!(schema.schema_json.as_deref().unwrap().contains("HuginnMind")); + } + } + + /// Two clients over loopback, each answered on its own session; a client on + /// another connection id is not served at all; the stop flag ends the loop. + #[test] + fn two_clients_get_their_own_replies_over_loopback() { + let root = tempfile::tempdir().unwrap(); + let mut daemon = open_unindexed(root.path(), &slug(INSTANCE)).unwrap(); + let mut hub = bind("127.0.0.1:0".parse().unwrap(), &daemon.runtime_id()).unwrap(); + let endpoint = format!("rudp://{}", hub.local_addr().unwrap()); + let registry = schema_registry().unwrap(); + let stopping = Arc::new(AtomicBool::new(false)); + + let mut clients = Vec::new(); + for (name, connection_id) in + [("a", CULTNET_OPERATION_CONNECTION_ID), ("b", CULTNET_OPERATION_CONNECTION_ID), ("c", 0x4355_4c55)] + { + let mut client = CultMesh::create_rudp_client_for_endpoint( + format!("eureka-state-{name}"), + connection_id, + &endpoint, + CultMeshRudpSocketOptions::default(), + ) + .unwrap(); + client.connect(Vec::new()).unwrap(); + clients.push(client); + } + // The two admitted peers connect; the third's packets are dropped on + // their connection id and it never becomes a session. + for _ in 0..200 { + while hub.receive_event_once().unwrap().is_some() {} + for client in clients.iter_mut() { + let _ = client.receive_once(); + } + if clients[0].connected() && clients[1].connected() { + break; + } + std::thread::sleep(Duration::from_millis(2)); + } + assert!(clients[0].connected() && clients[1].connected()); + assert!(!clients[2].connected(), "another connection id is not served"); + assert_eq!(hub.sessions().len(), 2); + + let requests = [ + ("m-a", HuginnMindRequest::Admit(batch(INSTANCE, vec![identity(INSTANCE)]))), + ("m-b", HuginnMindRequest::Query { instance: slug(INSTANCE), selection: Selection::default(), semantic: None }), + ]; + for (client, (message_id, request)) in clients.iter_mut().zip(requests.iter()) { + client.send_schema_message(&encode_request(message_id, request, None).unwrap()).unwrap(); + } + // The third client cannot even send: the hub dropped every packet + // carrying its connection id, so it never became a session. + let whoami = encode_request("m-c", &HuginnMindRequest::Whoami, None).unwrap(); + assert!(clients[2].send_schema_message(&whoami).is_err(), "an unserved connection id has no session"); + + let loop_stopping = Arc::clone(&stopping); + let serving = std::thread::spawn(move || { + let result = run(&mut daemon, &mut hub, ®istry, &loop_stopping, &ServeOptions::default()); + (result, daemon) + }); + + for (index, message_id) in ["m-a", "m-b"].iter().enumerate() { + let mut reply = None; + for _ in 0..500 { + clients[index].poll_resends().unwrap(); + if let Some(message) = clients[index].receive_schema_message_once().unwrap() { + reply = Some(message); + break; + } + std::thread::sleep(Duration::from_millis(2)); + } + let reply = reply.unwrap_or_else(|| panic!("client {index} got no reply")); + let (correlation, answer) = decode_response(&reply).unwrap(); + assert_eq!(&correlation, message_id, "each session gets its own reply"); + match (index, answer.unwrap()) { + (0, HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { .. })) => {} + (1, HuginnMindResponse::Query(page)) => assert_eq!(page.matched, 1), + (index, other) => panic!("client {index} got {other:?}"), + } + } + // The third client, on its own connection id, is not answered. + for _ in 0..50 { + clients[2].poll_resends().unwrap(); + assert!(clients[2].receive_schema_message_once().unwrap().is_none()); + std::thread::sleep(Duration::from_millis(2)); + } + + stopping.store(true, Ordering::Relaxed); + let (result, mut daemon) = serving.join().unwrap(); + result.unwrap(); + assert_eq!(documents(&mut daemon), 1, "the admission that crossed the wire landed"); + } + + fn encoded_len(message: &CultNetMessage) -> u64 { + encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap().len() as u64 + } + + const TTL: Duration = Duration::from_secs(60); + + fn fresh_bodies() -> DeferredBodies { + DeferredBodies::new(DEFERRED_BUDGET_BYTES, TTL) + } + + type Client = cultnet_rs::CultNetRudpSocketTransportConnection; + type BodyDaemon = Daemon; + + /// One request on the client's own session and its reply, the way + /// `fetch_content` is asked for each chunk. + fn exchange(client: &mut Client, message: &CultNetMessage) -> Result { + client.send_schema_message(message)?; + for _ in 0..3000 { + client.poll_resends()?; + if let Some(reply) = client.receive_schema_message_once()? { + return Ok(reply); + } + std::thread::sleep(Duration::from_millis(2)); + } + anyhow::bail!("no reply") + } + + fn deferred_manifest(reply: &CultNetMessage) -> CultMeshCdnArtifactManifest { + match decode_response(reply).unwrap().1.expect("an answer, not an envelope failure") { + HuginnMindResponse::Deferred(DeferredAnswer { manifest }) => manifest, + other => panic!("expected a deferred answer, got {other:?}"), + } + } + + fn chunk_request(manifest: &CultMeshCdnArtifactManifest, index: usize) -> CultNetMessage { + let chunk = &manifest.chunks[index]; + CultNetMessage::ContentChunkRequest { + message_id: format!("c-{index}"), + chunk_hash: chunk.chunk_hash.clone(), + record_key: chunk.record_key.clone(), + expected_size_bytes: chunk.size_bytes, + } + } + + fn found(reply: &CultNetMessage) -> bool { + let CultNetMessage::ContentChunkResponse { found, .. } = reply else { + panic!("expected a content chunk response, got {reply:?}"); + }; + *found + } + + fn document_query() -> HuginnMindRequest { + HuginnMindRequest::Query { + instance: slug(INSTANCE), + selection: Selection { projection: "document".into(), ..Selection::default() }, + semantic: None, + } + } + + /// One operation asked of `answer` at an injected time, and the manifest of + /// the deferred answer it gives back. + fn ask_deferred( + daemon: &mut BodyDaemon, + bodies: &mut DeferredBodies, + message_id: &str, + read: &HuginnMindRequest, + at: DateTime, + ) -> CultMeshCdnArtifactManifest { + let registry = schema_registry().unwrap(); + deferred_manifest(&answer(daemon, ®istry, bodies, encode_request(message_id, read, None).unwrap(), at).reply()) + } + + /// Whether the first chunk of a manifest is still served at an injected time. + fn first_chunk_found( + daemon: &mut BodyDaemon, + bodies: &mut DeferredBodies, + manifest: &CultMeshCdnArtifactManifest, + at: DateTime, + ) -> bool { + let registry = schema_registry().unwrap(); + found(&answer(daemon, ®istry, bodies, chunk_request(manifest, 0), at).reply()) + } + + /// An answer larger than one send can carry is deferred to the body plane + /// and fetches, on the same session, to the answer the mind gave. The wide + /// document is one cut spec with every list the leaf bounds filled: about a + /// megabyte of field content, which base64 in the operation envelope + /// carries past the window this module configures, so a single read of it is + /// already over. The fitting one is the same document narrowed until its + /// envelope lands inside the window, and it is delivered whole. + /// + /// The client holds the `Deferred` answer before it fetches, so the size + /// the daemon decided on is observed at the decision and not only + /// downstream of the fetch. + #[test] + fn an_oversize_answer_arrives_deferred_and_fetches_to_the_same_answer() { + let (_root, mut daemon) = seeded_wide(); + let registry = schema_registry().unwrap(); + + let sized = |daemon: &mut BodyDaemon, id: PipelineRef| { + let view = daemon.handle(HuginnMindRequest::View { instance: slug(INSTANCE), id }, now()).answered(); + let message = encode_response("m-0", "view", &view, "huginn-yggdrasil").unwrap(); + encoded_len(&message) + }; + let wide = spec_ref(&mut daemon, WIDE_CUT); + let fitting = spec_ref(&mut daemon, FITTING_CUT); + let (over, under) = (sized(&mut daemon, wide.clone()), sized(&mut daemon, fitting.clone())); + eprintln!("a view of {WIDE_CHANGES} file changes encodes to {over} bytes, of {FITTING_CHANGES} to {under}"); + assert!(over > MAX_RESPONSE_BYTES, "a cut spec at the leaf's bounds is {over} bytes, over {MAX_RESPONSE_BYTES}"); + assert!(under <= MAX_RESPONSE_BYTES, "the narrowed spec is {under} bytes and must fit"); + assert!( + MAX_RESPONSE_BYTES - under < 64 * MAX_FRAGMENT_BYTES as u64, + "the delivered answer must sit close enough to the limit that the window's value is load-bearing" + ); + + let reads = [ + ("m-q", document_query()), + ("m-v", HuginnMindRequest::View { instance: slug(INSTANCE), id: wide }), + ]; + let expected: Vec = + reads.iter().map(|(_, read)| daemon.handle(read.clone(), now()).answered()).collect(); + + let mut hub = bind("127.0.0.1:0".parse().unwrap(), &daemon.runtime_id()).unwrap(); + let endpoint = format!("rudp://{}", hub.local_addr().unwrap()); + let mut client = CultMesh::create_rudp_client_for_endpoint( + "eureka-state-wide".to_string(), + CULTNET_OPERATION_CONNECTION_ID, + &endpoint, + CultMeshRudpSocketOptions::default(), + ) + .unwrap(); + client.connect(Vec::new()).unwrap(); + for _ in 0..200 { + while hub.receive_event_once().unwrap().is_some() {} + let _ = client.receive_once(); + if client.connected() { + break; + } + std::thread::sleep(Duration::from_millis(2)); + } + assert!(client.connected()); + + let stopping = Arc::new(AtomicBool::new(false)); + let loop_stopping = Arc::clone(&stopping); + let serving = std::thread::spawn(move || { + let result = run(&mut daemon, &mut hub, ®istry, &loop_stopping, &ServeOptions::default()); + (result, daemon) + }); + + // One read at a time: the window is per session and counts what is + // still unacknowledged, so the fetch is one chunk in flight. + for ((message_id, read), expected) in reads.into_iter().zip(expected) { + let reply = exchange(&mut client, &encode_request(message_id, &read, None).unwrap()).unwrap(); + assert!(encoded_len(&reply) <= MAX_RESPONSE_BYTES, "the deferred answer fits one send"); + assert_eq!(decode_response(&reply).unwrap().0, message_id); + let manifest = deferred_manifest(&reply); + let bytes_expected = rmp_serde::to_vec_named(&expected).unwrap(); + assert_eq!(manifest.size_bytes as usize, bytes_expected.len(), "{message_id}: the manifest is of the answer"); + + let bytes = fetch_content(&manifest, MAX_DEFERRED_BODY_BYTES, |request| exchange(&mut client, &request)) + .unwrap(); + assert_eq!(bytes, bytes_expected, "{message_id}: byte-identical to the answer the mind gave"); + assert_eq!(rmp_serde::from_slice::(&bytes).unwrap(), expected); + assert!(!matches!(expected, HuginnMindResponse::Deferred(_))); + } + + // A real-sized answer that does fit is delivered whole, over the same + // session that just fetched two bodies. + let delivered = [ + ("m-f", HuginnMindRequest::View { instance: slug(INSTANCE), id: fitting.clone() }), + ("m-w", HuginnMindRequest::Whoami), + ]; + for (message_id, request) in delivered { + let reply = exchange(&mut client, &encode_request(message_id, &request, None).unwrap()).unwrap(); + let (correlation, answered) = decode_response(&reply).unwrap(); + assert_eq!(&correlation, message_id); + match answered.unwrap() { + HuginnMindResponse::View(Some(view)) => assert_eq!(view.id, fitting), + HuginnMindResponse::Whoami(status) => assert_eq!(status.documents, 5), + other => panic!("{message_id} got {other:?}"), + } + } + + stopping.store(true, Ordering::Relaxed); + serving.join().unwrap().0.unwrap(); + } + + /// A connected session whose accept has been acknowledged, so its window + /// holds `MAX_PENDING_RELIABLE_PACKETS` and not one less. See the module + /// documentation: a test that measures the window exactly and does not + /// settle first measures 1023. + fn settled_session( + hub: &mut CultNetRudpServerHub, + ) -> (cultnet_rs::CultNetRudpSocketTransportConnection, cultnet_rs::CultNetRudpServerSessionContext) { + let endpoint = format!("rudp://{}", hub.local_addr().unwrap()); + let mut client = CultMesh::create_rudp_client_for_endpoint( + "eureka-state-boundary".to_string(), + CULTNET_OPERATION_CONNECTION_ID, + &endpoint, + CultMeshRudpSocketOptions::default(), + ) + .unwrap(); + client.connect(Vec::new()).unwrap(); + let mut session = None; + for _ in 0..500 { + while let Some(event) = hub.receive_event_once().unwrap() { + if let CultNetRudpServerEvent::Connected { session: connected } = event { + session = Some(connected); + } + } + let _ = client.receive_once(); + if client.connected() && session.is_some() { + break; + } + std::thread::sleep(Duration::from_millis(2)); + } + assert!(client.connected()); + for _ in 0..100 { + hub.poll_resends().unwrap(); + while hub.receive_event_once().unwrap().is_some() {} + client.poll_resends().unwrap(); + while client.receive_once().unwrap().is_some() {} + std::thread::sleep(Duration::from_millis(2)); + } + (client, session.unwrap()) + } + + /// A reply carrying a chosen payload string, with every field the envelope + /// echoes chosen too, so a size can be chosen instead of found. + fn reply_with_payload(message_id: &str, operation: &str, runtime_id: &str, payload: String) -> CultNetMessage { + CultNetMessage::OperationResponse { + message_id: message_id.into(), + service_id: MIND_SERVICE_ID.into(), + operation: operation.into(), + status: "accepted".into(), + payload_schema: MIND_RESPONSE_SCHEMA.into(), + payload_encoding: "messagepack-base64".into(), + payload, + diagnostics: vec![], + source_runtime_id: Some(runtime_id.into()), + } + } + + /// One whose encoded envelope is exactly `target` bytes and whose payload is + /// valid base64, under a `message_id` that is `stem` followed by as many + /// zeros as make the payload a whole number of base64 quanta. The envelope's + /// own overhead is measured, not assumed: the length prefix is the same + /// width on either side of a megabyte, so one probe fixes it. The id is + /// echoed, so it is part of the result. + fn sized_exactly(stem: &str, operation: &str, runtime_id: &str, target: u64) -> (String, CultNetMessage) { + let probe = 1_200_000_usize; + for zeros in 0..4 { + let message_id = format!("{stem}{}", "0".repeat(zeros)); + let at_probe = encoded_len(&reply_with_payload(&message_id, operation, runtime_id, "A".repeat(probe))); + let payload_len = (probe as i64 + (target as i64 - at_probe as i64)) as usize; + if payload_len % 4 == 0 { + let message = reply_with_payload(&message_id, operation, runtime_id, "A".repeat(payload_len)); + assert_eq!(encoded_len(&message), target); + return (message_id, message); + } + } + unreachable!("one of four consecutive id lengths aligns the payload") + } + + /// The boundary is the transport's, byte for byte, at every shape of + /// envelope. A reply whose encoded envelope is exactly `MAX_RESPONSE_BYTES` + /// is delivered unchanged and a hub `bind` configured accepts it; one byte + /// more is deferred, not refused, and a hub refuses it. + /// + /// So the threshold measures the encoded envelope, which is what the hub + /// fragments, and not the payload string inside it, which is a couple of + /// hundred bytes smaller; it admits the limit rather than stopping one + /// short of it; and it is the window itself and not a margin under it. The + /// echoed ids, operations and runtime ids vary independently and in length, + /// so a formula fitted to one envelope shape is measured where it was never + /// fitted. The accepted reply is shown to have filled the window it was + /// measured against, because a one-byte reply after it is refused. + #[test] + fn the_delivery_boundary_is_the_transports_boundary_byte_for_byte() { + let long = format!("m-{}", "1".repeat(38)); + for (stem, operation, runtime_id) in [ + ("m-", "view", "huginn-yggdrasil"), + (long.as_str(), "view", "huginn-yggdrasil"), + ("m-", "whoami", "huginn-thought-cage"), + ("m-", "whoami", "huginn-yggdrasil"), + ("m-", "view", "huginn-thought-cage"), + ] { + let (id_at, at) = sized_exactly(stem, operation, runtime_id, MAX_RESPONSE_BYTES); + let (id_over, over) = sized_exactly(stem, operation, runtime_id, MAX_RESPONSE_BYTES + 1); + let mut bodies = fresh_bodies(); + let case = format!("{operation}/{runtime_id}/{}", stem.len()); + + let delivered = deliver(at.clone(), &id_at, operation, runtime_id, &mut bodies, now()); + assert_eq!(delivered, at, "{case}: exactly the limit is delivered whole"); + assert_eq!(bodies.body_count(), 0, "{case}: nothing was retained for it"); + + let deferred = deliver(over.clone(), &id_over, operation, runtime_id, &mut bodies, now()); + let (correlation, answered) = decode_response(&deferred).unwrap(); + assert_eq!(correlation, id_over, "{case}"); + let Some(HuginnMindResponse::Deferred(DeferredAnswer { manifest })) = answered.ok() else { + panic!("{case}: one byte over the limit was not deferred: {deferred:?}"); + }; + assert_eq!(bodies.body_count(), 1, "{case}"); + assert!( + encoded_len(&deferred) <= MAX_FRAGMENT_BYTES as u64 * 4, + "{case}: the deferred answer is a few packets" + ); + let CultNetMessage::OperationResponse { payload, .. } = &over else { unreachable!() }; + assert_eq!(manifest.size_bytes as usize, STANDARD.decode(payload).unwrap().len(), "{case}"); + + if stem == "m-" && operation == "view" && runtime_id == "huginn-yggdrasil" { + let mut hub = bind("127.0.0.1:0".parse().unwrap(), "huginn-yggdrasil").unwrap(); + let (_client, session) = settled_session(&mut hub); + let error = hub.send_schema_message(&session, &over).unwrap_err(); + assert!(format!("{error:#}").contains("queue is full"), "limit + 1 was accepted: {error:#}"); + hub.send_schema_message(&session, &at).expect("exactly the limit is accepted on an empty window"); + hub.send_schema_message(&session, &reply_with_payload("m-0", "view", runtime_id, "A".into())) + .expect_err("the accepted answer filled the window, so nothing follows it"); + } + } + } + + /// The backstop stays, measured against the deferral bound and on the + /// payload: a body of exactly `MAX_DEFERRED_BODY_BYTES` is deferred, one + /// byte more is refused by name with its own size and that bound, and the + /// deferred answer at the bound still fits one send, so it is never itself + /// deferred. + #[test] + fn an_answer_over_the_deferral_bound_is_refused_by_name() { + let payload_of = |len: u64| STANDARD.encode(vec![0_u8; len as usize]); + let mut bodies = fresh_bodies(); + // The longest id a request may carry: the reply echoes it, so the + // largest deferred answer and the largest refusal are measured at it. + let id = "m".repeat(MAX_ECHOED_FIELD_BYTES); + + let at = reply_with_payload(&id, "view", "huginn-yggdrasil", payload_of(MAX_DEFERRED_BODY_BYTES)); + let deferred = deliver(at, &id, "view", "huginn-yggdrasil", &mut bodies, now()); + let manifest = deferred_manifest(&deferred); + assert_eq!(manifest.size_bytes as u64, MAX_DEFERRED_BODY_BYTES); + assert_eq!(manifest.chunks.len() as u64, MAX_DEFERRED_BODY_BYTES / DEFERRED_CHUNK_BYTES as u64); + assert!(encoded_len(&deferred) <= MAX_RESPONSE_BYTES, "at the bound the deferred answer still fits one send"); + assert_eq!(bodies.body_count(), 1); + drop(bodies); + + let over = reply_with_payload(&id, "view", "huginn-yggdrasil", payload_of(MAX_DEFERRED_BODY_BYTES + 1)); + let mut empty = fresh_bodies(); + let refused = deliver(over, &id, "view", "huginn-yggdrasil", &mut empty, now()); + let (correlation, answered) = decode_response(&refused).unwrap(); + assert_eq!(correlation, id); + let HuginnMindResponse::Refused(MindRefusal::ResponseTooLarge { bytes, limit }) = + answered.expect("a refusal is an answer, not an envelope failure") + else { + panic!("one byte over the deferral bound was not refused: {refused:?}"); + }; + assert_eq!((bytes, limit), (MAX_DEFERRED_BODY_BYTES + 1, MAX_DEFERRED_BODY_BYTES)); + assert!(encoded_len(&refused) <= MAX_RESPONSE_BYTES, "the refusal fits one send at the longest id"); + assert_eq!(empty.body_count(), 0, "a refused answer retains nothing"); + } + + /// An oversize reply whose payload cannot be read as the response's bytes + /// is a failure of the envelope, named, and never deferred. + #[test] + fn an_oversize_reply_that_is_not_a_response_is_not_deferred() { + let mut bodies = fresh_bodies(); + let not_base64 = reply_with_payload("m-x", "view", "huginn-yggdrasil", "A".repeat(MAX_RESPONSE_BYTES as usize) + "!"); + let not_a_response = + CultNetMessage::Error { error: "e".repeat(MAX_RESPONSE_BYTES as usize + 1), code: None, details: None }; + for reply in [not_base64, not_a_response] { + assert!(encoded_len(&reply) > MAX_RESPONSE_BYTES); + let answered = deliver(reply, "m-x", "view", "huginn-yggdrasil", &mut bodies, now()); + assert_eq!(rejected(&answered).1, "response-not-encodable"); + } + assert_eq!(bodies.body_count(), 0); + } + + /// Lifecycle end to end through the daemon's own `answer`, on an injected + /// clock: a chunk served inside the TTL slides the expiry, a body untouched + /// for the TTL is gone and its chunk is answered `found: false` by name, + /// and the client asks again and fetches the same answer. + #[test] + fn an_expired_body_answers_found_false_and_the_client_can_ask_again() { + let (_root, mut daemon) = seeded_wide(); + let registry = schema_registry().unwrap(); + let wide = spec_ref(&mut daemon, WIDE_CUT); + let read = HuginnMindRequest::View { instance: slug(INSTANCE), id: wide }; + let expected = rmp_serde::to_vec_named(&daemon.handle(read.clone(), now()).answered()).unwrap(); + let t0 = now(); + let after = |seconds: i64| t0 + chrono::Duration::seconds(seconds); + let mut bodies = fresh_bodies(); + + let manifest = ask_deferred(&mut daemon, &mut bodies, "m-v", &read, t0); + assert!(manifest.chunks.len() >= 2, "the fixture needs a second chunk to serve"); + let mut chunk = |index: usize, at: i64| { + answer(&mut daemon, ®istry, &mut bodies, chunk_request(&manifest, index), after(at)).reply() + }; + assert!(found(&chunk(0, 59)), "inside the TTL"); + assert!(found(&chunk(1, 118)), "serving a chunk slid the expiry past the first TTL"); + let gone = chunk(0, 178); + assert!(!found(&gone), "untouched for the TTL since t=118"); + let CultNetMessage::ContentChunkResponse { error, payload, .. } = &gone else { unreachable!() }; + assert!(error.starts_with("FileNotFoundException"), "{error}"); + assert!(payload.is_empty()); + + let again = ask_deferred(&mut daemon, &mut bodies, "m-v2", &read, after(179)); + assert_eq!(again.content_hash, manifest.content_hash, "the same answer is the same body"); + let bytes = fetch_content(&again, MAX_DEFERRED_BODY_BYTES, |request| { + Ok(answer(&mut daemon, ®istry, &mut bodies, request, after(180)).reply()) + }) + .unwrap(); + assert_eq!(bytes, expected); + } + + /// Past the budget the body touched longest ago is evicted, its chunks + /// answer `found: false`, and asking again evicts the other in turn. Two + /// identical answers are one body. + #[test] + fn eviction_and_sharing_through_the_daemons_answer() { + let (_root, mut daemon) = seeded_wide(); + let wide = spec_ref(&mut daemon, WIDE_CUT); + let view = HuginnMindRequest::View { instance: slug(INSTANCE), id: wide }; + let query = document_query(); + let size = |daemon: &mut BodyDaemon, read: &HuginnMindRequest| { + rmp_serde::to_vec_named(&daemon.handle(read.clone(), now()).answered()).unwrap().len() as u64 + }; + let (view_bytes, query_bytes) = (size(&mut daemon, &view), size(&mut daemon, &query)); + // Room for the larger body alone, and not for both. + let budget = view_bytes.max(query_bytes) + 1; + let t0 = now(); + let after = |seconds: i64| t0 + chrono::Duration::seconds(seconds); + let mut bodies = DeferredBodies::new(budget, TTL); + + let first = ask_deferred(&mut daemon, &mut bodies, "m-1", &view, after(0)); + let same = ask_deferred(&mut daemon, &mut bodies, "m-2", &view, after(1)); + assert_eq!(first.content_hash, same.content_hash, "two identical answers are one body"); + assert_eq!((bodies.body_count(), bodies.stored_bytes()), (1, view_bytes)); + + let second = ask_deferred(&mut daemon, &mut bodies, "m-3", &query, after(2)); + assert_eq!(bodies.body_count(), 1, "the least recently touched body was evicted"); + assert!(bodies.stored_bytes() <= budget); + assert!(!first_chunk_found(&mut daemon, &mut bodies, &first, after(3)), "the evicted body answers found: false"); + assert!(first_chunk_found(&mut daemon, &mut bodies, &second, after(3)), "the newer body stays"); + + let again = ask_deferred(&mut daemon, &mut bodies, "m-4", &view, after(4)); + assert!(first_chunk_found(&mut daemon, &mut bodies, &again, after(5))); + assert!( + !first_chunk_found(&mut daemon, &mut bodies, &second, after(5)), + "the client's re-ask evicted the one touched longest ago" + ); + } + + /// Every field a reply echoes is bounded at the door, so no reply can + /// outgrow the window: a request whose `message_id`, `service_id`, + /// `operation`, `payload_schema` or `chunk_hash` is over the bound is + /// answered with a transport `Error` that fits and carries none of them, + /// and touches no mind; a field of exactly the bound is served as usual. + /// The 1.3 MB id is the one that made a `Whoami` answer echo past the + /// window, deferred it, and left the client waiting on a send the hub + /// refused. + #[test] + fn an_overlong_echoed_field_is_refused_at_the_door_with_an_answer_that_fits() { + let root = tempfile::tempdir().unwrap(); + let mut daemon = open_unindexed(root.path(), &slug(INSTANCE)).unwrap(); + let registry = schema_registry().unwrap(); + let mut bodies = fresh_bodies(); + let huge = "x".repeat(1_300_000); + let over = "x".repeat(MAX_ECHOED_FIELD_BYTES + 1); + let at = "x".repeat(MAX_ECHOED_FIELD_BYTES); + let whoami = |message_id: &str, service_id: &str, operation: &str, payload_schema: &str| { + let payload = STANDARD.encode(rmp_serde::to_vec_named(&HuginnMindRequest::Whoami).unwrap()); + CultNetMessage::OperationRequest { + message_id: message_id.into(), + service_id: service_id.into(), + operation: operation.into(), + payload_schema: payload_schema.into(), + payload_encoding: "messagepack-base64".into(), + payload, + source_runtime_id: None, + target_runtime_id: None, + } + }; + let chunk = |message_id: &str, chunk_hash: &str, record_key: &str| CultNetMessage::ContentChunkRequest { + message_id: message_id.into(), + chunk_hash: chunk_hash.into(), + record_key: record_key.into(), + expected_size_bytes: 1, + }; + let refused = [ + ("huge message_id", whoami(&huge, MIND_SERVICE_ID, "whoami", MIND_REQUEST_SCHEMA)), + ("message_id", whoami(&over, MIND_SERVICE_ID, "whoami", MIND_REQUEST_SCHEMA)), + ("service_id", whoami("m", &huge, "whoami", MIND_REQUEST_SCHEMA)), + ("operation", whoami("m", MIND_SERVICE_ID, &huge, MIND_REQUEST_SCHEMA)), + ("payload_schema", whoami("m", MIND_SERVICE_ID, "whoami", &huge)), + ( + "catalog message_id", + CultNetMessage::SchemaCatalogRequest { + message_id: huge.clone(), + include_schema_json: Some(true), + schema_ids: None, + kinds: None, + }, + ), + ("chunk message_id", chunk(&huge, "abc", "")), + ("chunk_hash", chunk("m", &huge, "")), + ("record_key", chunk("m", "abc", &huge)), + ]; + for (case, message) in refused { + let reply = answer(&mut daemon, ®istry, &mut bodies, message, now()).reply(); + let CultNetMessage::Error { error, .. } = &reply else { + panic!("{case}: expected a transport error, got {reply:?}"); + }; + assert!(error.contains("over 256 bytes"), "{case}: {error}"); + assert!(encoded_len(&reply) <= MAX_FRAGMENT_BYTES as u64, "{case}: the refusal is one packet"); + } + assert_eq!(documents(&mut daemon), 0); + + let reply = answer(&mut daemon, ®istry, &mut bodies, whoami(&at, MIND_SERVICE_ID, "whoami", MIND_REQUEST_SCHEMA), now()).reply(); + assert_eq!(decode_response(&reply).unwrap().0, at, "a field of exactly the bound is served"); + let reply = answer(&mut daemon, ®istry, &mut bodies, chunk(&at, &at, &at), now()).reply(); + assert!(!found(&reply), "an unknown chunk is answered found: false, not refused at the door"); + } + + /// The envelope's status is the answer's own whether it is sent whole or + /// deferred, so a client that routes on status routes the same way. + #[test] + fn a_deferred_answer_keeps_the_status_of_the_answer_it_stands_for() { + for status in ["rejected", "accepted"] { + let payload = STANDARD.encode(vec![0_u8; MAX_RESPONSE_BYTES as usize]); + let mut reply = reply_with_payload("m-s", "view", "huginn-yggdrasil", payload); + let CultNetMessage::OperationResponse { status: carried, .. } = &mut reply else { unreachable!() }; + *carried = status.into(); + let mut bodies = fresh_bodies(); + let deferred = deliver(reply, "m-s", "view", "huginn-yggdrasil", &mut bodies, now()); + let CultNetMessage::OperationResponse { status: envelope, .. } = &deferred else { + panic!("expected an operation response, got {deferred:?}"); + }; + assert_eq!(envelope, status); + deferred_manifest(&deferred); + } + } + + /// One cut spec's own reference, by its cut label. + fn spec_ref(daemon: &mut Daemon, cut: &str) -> PipelineRef { + let selection = Selection { + schemas: Some(vec![PipelineKind::CutSpec.type_id().into()]), + fields: Some(vec![FieldPredicate { + index: "cut".into(), + op: "any_of".into(), + values: Some(vec![cut.into()]), + number: None, + }]), + ..Selection::default() + }; + let HuginnMindResponse::Query(page) = + daemon.handle(HuginnMindRequest::Query { instance: slug(INSTANCE), selection, semantic: None }, now()).answered() + else { + panic!("expected a page"); + }; + assert_eq!(page.matched, 1, "one cut spec is labelled {cut}"); + let PipelinePageItems::Headers(headers) = &page.items else { panic!("expected headers") }; + headers[0].id.clone() + } + + /// Ruling 15 as an order in the one place both happen. Both gates are shut + /// at once: the mind is held and the port is held. A startup that opens + /// first says why the mind refused; one that binds first can only say the + /// port was taken, and never reaches the refusal that matters. + #[test] + fn startup_opens_the_mind_before_it_binds_anything() { + let root = tempfile::tempdir().unwrap(); + let held = open_unindexed(root.path(), &slug(INSTANCE)).unwrap(); + let occupied = UdpSocket::bind("127.0.0.1:0").unwrap(); + let port = occupied.local_addr().unwrap(); + + let options = Options { + state_root: root.path().to_path_buf(), + instance: slug(INSTANCE), + bind: port, + qdrant_url: "http://127.0.0.1:1".into(), + ollama_url: "http://127.0.0.1:1".into(), + embedding_model: "a-model".into(), + }; + let started = |options: &Options| { + let (embedder, index) = crate::index::fakes::pair(); + startup(options, embedder, index, Backoff::default()) + }; + let error = format!("{:#}", started(&options).err().expect("a held mind refuses")); + assert!(error.contains("MindAlreadyOwned"), "the refusal is the mind's, not the socket's: {error}"); + assert!(!error.contains("binding"), "the socket was never reached: {error}"); + + // With the mind free and the port still held, the socket is the only + // gate left, and startup reports it. + drop(held); + let error = format!("{:#}", started(&options).err().expect("a held port refuses")); + assert!(error.contains("binding"), "{error}"); + drop(occupied); + let (_daemon, hub, _registry) = started(&options).expect("both gates open"); + assert_eq!(hub.local_addr().unwrap(), port); + } + + #[test] + fn options_are_exactly_the_six_the_operator_supplies() { + let root = if cfg!(windows) { r"C:\state" } else { "/state" }; + let args = |values: &[&str]| values.iter().map(|value| value.to_string()).collect::>().into_iter(); + let good = [ + "--state-root", root, "--instance", "yggdrasil", "--bind", "127.0.0.1:17872", "--qdrant-url", + "http://127.0.0.1:6333", "--ollama-url", "http://10.77.0.4:11434", "--embedding-model", "qwen3-embedding:0.6b", + ]; + assert_eq!( + parse_options(args(&good)).unwrap(), + Options { + state_root: PathBuf::from(root), + instance: slug("yggdrasil"), + bind: "127.0.0.1:17872".parse().unwrap(), + qdrant_url: "http://127.0.0.1:6333".into(), + ollama_url: "http://10.77.0.4:11434".into(), + embedding_model: "qwen3-embedding:0.6b".into(), + } + ); + // Each of the six is required: there is no index-less mode. + for name in ["--state-root", "--instance", "--bind", "--qdrant-url", "--ollama-url", "--embedding-model"] { + let at = good.iter().position(|value| *value == name).unwrap(); + let mut without = good.to_vec(); + without.drain(at..at + 2); + assert!(parse_options(args(&without)).is_err(), "{name} is required"); + } + fn with<'a>(good: &[&'a str], name: &str, value: &'a str) -> Vec<&'a str> { + let at = good.iter().position(|option| *option == name).unwrap(); + let mut changed = good.to_vec(); + changed[at + 1] = value; + changed + } + for bad in [ + [good.to_vec(), vec!["--bind", "127.0.0.1:2"]].concat(), + with(&good, "--bind", "not-an-address"), + with(&good, "--state-root", "relative"), + with(&good, "--qdrant-url", "https://127.0.0.1:6333"), + with(&good, "--ollama-url", "10.77.0.4:11434"), + with(&good, "--embedding-model", ""), + [good.to_vec(), vec!["--idunn-anchor", "x"]].concat(), + ] { + assert!(parse_options(args(&bad)).is_err(), "{bad:?}"); + } + } +} diff --git a/crates/huginn-daemon/tests/live_index_smoke.rs b/crates/huginn-daemon/tests/live_index_smoke.rs new file mode 100644 index 0000000..85fabc2 --- /dev/null +++ b/crates/huginn-daemon/tests/live_index_smoke.rs @@ -0,0 +1,123 @@ +//! A live check of the two adapters against a real Qdrant and a real Ollama. +//! Ignored by default: it needs both services, and it writes to Qdrant, so it +//! touches one scratch collection it creates (`huginn-smoke-`, and a +//! dotted sibling to learn whether Qdrant accepts a `.` in a name) and deletes +//! both at the end. It never names, lists or reads any other collection. +//! +//! HUGINN_SMOKE_QDRANT=http://127.0.0.1:6333 \ +//! HUGINN_SMOKE_OLLAMA=http://10.77.0.4:11434 \ +//! HUGINN_SMOKE_MODEL=qwen3-embedding:0.6b \ +//! cargo test -p huginn-daemon --test live_index_smoke -- --ignored --nocapture + +use std::collections::BTreeSet; +use std::time::Duration; + +use huginn_daemon::index::ollama::{OllamaEmbedder, QUERY_INSTRUCTION}; +use huginn_daemon::index::qdrant::QdrantIndex; +use huginn_daemon::index::{ + CollectionMeta, Described, Embedder, MANAGED_BY, Point, PointPayload, VectorIndex, point_id, +}; +use huginn_mind::INDEX_TEXT_VERSION; + +fn env(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| panic!("{name} is required")) +} + +fn raw(method: &str, url: &str, body: Option<&str>) -> (u16, String) { + let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into(); + let mut response = match (method, body) { + ("DELETE", _) => agent.delete(url).call(), + ("POST", Some(body)) => agent.post(url).header("content-type", "application/json").send(body), + _ => agent.get(url).call(), + } + .expect("the request was sent"); + let status = response.status().as_u16(); + (status, response.body_mut().read_to_string().unwrap()) +} + +#[test] +#[ignore = "needs a live Qdrant and Ollama, and writes a scratch collection"] +fn the_adapters_embed_write_query_and_clean_up_a_scratch_collection() { + let (qdrant, ollama, model) = (env("HUGINN_SMOKE_QDRANT"), env("HUGINN_SMOKE_OLLAMA"), env("HUGINN_SMOKE_MODEL")); + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let plain = format!("huginn-smoke-{:x}-{stamp:x}", std::process::id()); + let dotted = format!("{plain}.dotted"); + + let mut embedder = OllamaEmbedder::new(&ollama, &model); + let identity = embedder.model_identity(Duration::from_secs(60)).expect("the model is listed"); + println!("identity: {identity:?}"); + let texts = ["The organ owns the mind.".to_string(), "A recipe for lentil soup.".to_string()]; + let vectors = embedder.embed(&texts, Duration::from_secs(60)).expect("two texts embed"); + assert_eq!(vectors.len(), 2); + assert!(vectors.iter().all(|vector| vector.len() == identity.dimensions as usize)); + + let meta = CollectionMeta { + managed_by: MANAGED_BY.into(), + instance: "smoke".into(), + mind: "mind-commit-smoke".into(), + model: identity.name.clone(), + model_digest: identity.digest.clone(), + dimensions: identity.dimensions, + index_text_version: INDEX_TEXT_VERSION, + }; + let mut store = QdrantIndex::new(&qdrant); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + for name in [&plain, &dotted] { + assert_eq!(store.describe(name, Duration::from_secs(60)).unwrap(), Described::Absent); + store.recreate(name, &meta).unwrap(); + assert_eq!(store.describe(name, Duration::from_secs(60)).unwrap(), Described::Labelled(meta.clone())); + assert!(store.ids(name).unwrap().is_empty()); + let points: Vec = texts + .iter() + .zip(&vectors) + .enumerate() + .map(|(at, (text, vector))| Point { + id: point_id(&format!("smoke:doc:{at}")), + vector: vector.clone(), + payload: PointPayload { + doc_id: format!("smoke:doc:{at}"), + kind: "question".into(), + root: "smoke".into(), + ordinal: at as u64 + 1, + text_sha256: format!("{}", text.len()), + }, + }) + .collect(); + store.upsert(name, &points).unwrap(); + let expected: BTreeSet = points.iter().map(|point| point.id.clone()).collect(); + assert_eq!(store.ids(name).unwrap(), expected); + // The nearest point to the first text is the first point. + let query = serde_json_free_query(&vectors[0]); + let (status, answer) = raw("POST", &format!("{qdrant}/collections/{name}/points/search"), Some(&query)); + assert_eq!(status, 200, "{answer}"); + assert!(answer.contains(&points[0].id), "{answer}"); + // The read side: a paraphrase that shares no exact words with the + // first document finds it, through the adapter, with the query + // instruction the worker uses. + let asked = format!("Instruct: {QUERY_INSTRUCTION} +Query: Who is responsible for keeping the memory?"); + let query = embedder.embed(&[asked], Duration::from_secs(60)).expect("the query embeds").remove(0); + let hits = store.search(name, &query, 2, Duration::from_secs(60)).unwrap(); + assert_eq!(hits.len(), 2, "{hits:?}"); + assert_eq!((hits[0].doc_id.as_str(), hits[0].kind.as_str()), ("smoke:doc:0", "question"), "{hits:?}"); + assert!(hits[0].score > hits[1].score, "{hits:?}"); + println!("{name}: described, created, upserted {} points, queried, searched: {hits:?}", points.len()); + } + })); + for name in [&plain, &dotted] { + let (status, _) = raw("DELETE", &format!("{qdrant}/collections/{name}?wait=true"), None); + println!("deleted {name}: {status}"); + } + let (status, _) = raw("GET", &format!("{qdrant}/collections/{plain}"), None); + assert_eq!(status, 404, "the scratch collection is gone"); + if let Err(panic) = outcome { + std::panic::resume_unwind(panic); + } +} + +/// The nearest-neighbour body, written out by hand so the test needs no JSON +/// crate of its own. +fn serde_json_free_query(vector: &[f32]) -> String { + let numbers: Vec = vector.iter().map(|value| value.to_string()).collect(); + format!("{{\"vector\":[{}],\"limit\":1}}", numbers.join(",")) +} diff --git a/crates/huginn-mind/Cargo.toml b/crates/huginn-mind/Cargo.toml new file mode 100644 index 0000000..883e9b0 --- /dev/null +++ b/crates/huginn-mind/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "huginn-mind" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish.workspace = true + +# The organ's rule engine: storage, identity and admission of one instance's +# mind. Document shape, bounds, formats and keys come from `epiphany-pipeline`; +# the store comes from CultLib's Rust CultCache at the same rev Epiphany pins, +# so the leaf's wrappers and this crate's cache are one `cultcache-rs`. The +# read side selects through CultNet's typed selection (`cultnet-rs`, the same +# rev the daemon pins): the selection's vocabulary and evaluator are the +# substrate's, this crate supplies the rows. No socket, no index: those are the +# daemon's (Cut 10, Cut 11). +[dependencies] +anyhow = "1" +base64 = "0.22" +chrono = "0.4.44" +cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "8fc74c709444b884a2bd339e0c306bf9484d99d2", package = "cultcache-rs" } +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "8fc74c709444b884a2bd339e0c306bf9484d99d2", package = "cultnet-rs" } +epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "73edee5cdaddece8bda9c6c3271e8ddd064337f9", package = "epiphany-pipeline" } +rmp-serde = "1" +schemars = "1" +serde = { version = "1", features = ["derive"] } +sha2 = "0.10" + +[dev-dependencies] +serde_json = "1" +tempfile = "3" + +# `test-support` exposes `Mind::store_path_for` and `Mind::open_with`, a +# grammar-checked path accessor and the fail-closed open door, for tests in +# other crates that need to reach a raw store directly. It buys no runtime +# surface: gated `#[cfg(any(test, feature = "test-support"))]`, so it +# compiles only under `cargo test` in this crate or when a dependent's own +# dev-dependencies enable it (see huginn-daemon/Cargo.toml). +[features] +test-support = [] diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs new file mode 100644 index 0000000..6443e94 --- /dev/null +++ b/crates/huginn-mind/src/admission.rs @@ -0,0 +1,2094 @@ +//! Admission: the single door into a mind. +//! +//! `admit` prepares typed documents through the leaf and calls +//! `admit_prepared`, the one commit path for Cut 8, Cut 10's sink, Cut 12's +//! hand-off and import, and Cut 13's tools. The steps run in order and the +//! first failing step is the outcome: +//! +//! - A1 the batch names this mind, and attributes itself to a named agent and +//! session (`refuse_provenance`); A2 one to sixty-four envelopes; +//! - A3 every envelope passes the leaf's bounds, formats and key +//! recomputation; A4 identities are unique in the batch; A5 every document +//! carrying an instance names this mind; +//! - A6 an empty mind's first batch carries exactly one `instance` document, +//! and a batch carrying one derives the epoch record; +//! - A7 every reference names a document present in the image or the batch, +//! looked up by `(kind, id)`; the ones resolved in the image become the +//! receipt's strong reads; +//! - the derived writes (a ruling's `Answered` resolution, a hand-off's +//! stewardship withdrawal or assignment) are appended and pass A3-A7; +//! - A8 the per-kind rules, below; A9 an exact replay answers with the stored +//! receipt, after validation and never before it (ruling 20); A10 no write +//! collides with the image; A11 the commit. +//! +//! Every cross-field and cross-document rule lives here and nowhere else: +//! the leaf never gains one, the daemon and the client never re-derive one. +//! In force, the sequences and the scopes are `docs.rs`'s, shared with the +//! views (Cut 9). + +use std::collections::BTreeSet; + +use chrono::{DateTime, Utc}; +use cultcache_rs::CultCacheEnvelope; +use epiphany_pipeline::{ + ClaimOutcome, FindingConfidence, Line, PipelineDocument, PipelineKind, PipelineRef, PipelineRefusal, + PipelineResolution, PipelineStewardship, ResolutionOutcome, RulingAuthority, Short, Slug, pipeline_key, + validate_pipeline_write_envelope, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::docs::{CitationRole, Docs, Staged, citations, kind_of_id, outcome_citations}; +use crate::mind::{HuginnMindEpoch, Mind, unavailable}; +use crate::receipt::{self, CommitOutcome, PipelineProvenance}; +use crate::refusal::MindRefusal; +use crate::store::MindStore; + +/// The most envelopes one batch may carry. +pub const BATCH_MAX: usize = 64; + +/// One admission request: the mind it is for, who asks, and the documents. +/// Rides the wire whole (`wire::HuginnMindRequest::Admit`). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct PipelineAdmissionBatch { + pub instance: Slug, + pub provenance: PipelineProvenance, + pub documents: Vec, +} + +/// How admission ended. `Committed.writes` names every document the batch +/// landed, derived writes included; Cut 11 indexes from it after `admit` +/// returns, and never inside it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum PipelineAdmissionOutcome { + Committed { receipt_id: String, committed_at: String, writes: Vec }, + AlreadyAdmitted { receipt_id: String }, + Refused(MindRefusal), + Conflict { identities: Vec }, +} + +impl Mind { + /// Validates and prepares every document through the leaf, then admits + /// the envelopes. + pub fn admit(&mut self, batch: PipelineAdmissionBatch, now: DateTime) -> PipelineAdmissionOutcome { + let mut envelopes = Vec::with_capacity(batch.documents.len()); + for document in &batch.documents { + if let Err(refusal) = document.validate() { + return PipelineAdmissionOutcome::Refused(MindRefusal::Document(refusal)); + } + match document.prepare(self.cache()) { + Ok(envelope) => envelopes.push(envelope), + Err(error) => return PipelineAdmissionOutcome::Refused(unavailable(error)), + } + } + self.admit_prepared(&batch.instance, batch.provenance, envelopes, now) + } + + /// The single commit path: envelopes prepared by anyone, validated here + /// whoever prepared them. + pub fn admit_prepared( + &mut self, + instance: &Slug, + provenance: PipelineProvenance, + envelopes: Vec, + now: DateTime, + ) -> PipelineAdmissionOutcome { + match self.admit_steps(instance, provenance, envelopes, now) { + Ok(outcome) => outcome, + Err(refusal) => PipelineAdmissionOutcome::Refused(refusal), + } + } + + fn admit_steps( + &mut self, + instance: &Slug, + provenance: PipelineProvenance, + envelopes: Vec, + now: DateTime, + ) -> Result { + // A1 + self.require_instance(instance)?; + refuse_provenance(&provenance)?; + // A2 + if envelopes.is_empty() || envelopes.len() > BATCH_MAX { + return Err(MindRefusal::BatchSize { actual: envelopes.len() as u32 }); + } + let mind = self.instance().clone(); + let mut docs = Docs::from_image(self.envelopes())?; + // A3, A5, then A4 on push. + for envelope in envelopes { + docs.push(stage(envelope, &mind)?)?; + } + // A6 + let carries_instance = docs.batch.iter().filter(|staged| staged.kind == PipelineKind::Instance).count() == 1; + if self.is_empty() && !carries_instance { + return Err(MindRefusal::MissingIdentity); + } + // A7 + let mut strong = BTreeSet::new(); + for staged in &docs.batch { + resolve(&docs, staged, &mind, &mut strong)?; + } + // Derived writes pass A3-A7 themselves. + let originals = docs.batch.len(); + for document in derive(&docs, &mind) { + document.validate()?; + let envelope = document.prepare(self.cache()).map_err(unavailable)?; + docs.push(stage(envelope, &mind)?)?; + } + for staged in &docs.batch[originals..] { + resolve(&docs, staged, &mind, &mut strong)?; + } + // The pins: cited image bytes, and every write. + let strong_reads = strong + .iter() + .filter_map(|(type_id, key)| self.raw_envelope(type_id, key).cloned()) + .collect::>(); + let mut writes = docs.batch.iter().map(|staged| staged.envelope.clone()).collect::>(); + if carries_instance { + writes.push(HuginnMindEpoch::envelope(self.cache())?); + } + let candidate = receipt::candidate(&mind, provenance, &strong_reads, &writes, receipt::head(self)? + 1, now)?; + // A8 + for staged in &docs.batch { + check(&docs, staged, &mind)?; + } + // A9 + if let Some(existing) = receipt::replay(self, &candidate)? { + return Ok(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: existing.receipt_id }); + } + // A10 + refuse_collisions(&docs)?; + // A11 + let landed = docs.batch.iter().map(|staged| PipelineRef { kind: staged.kind, id: Short(staged.key.clone()) }).collect(); + match receipt::commit(self, candidate, strong_reads, writes)? { + CommitOutcome::Committed(receipt) => Ok(PipelineAdmissionOutcome::Committed { + receipt_id: receipt.receipt_id, + committed_at: receipt.committed_at, + writes: landed, + }), + CommitOutcome::Conflict(identities) => Ok(PipelineAdmissionOutcome::Conflict { identities }), + } + } +} + +/// The bound `epiphany_pipeline::Short` declares, in UTF-8 bytes. The leaf +/// exposes no validator for it, so `short_bound_is_the_leafs_own` pins this to +/// the `maxLength` its schema publishes. +const SHORT_MAX: usize = 200; + +/// Who admitted a batch is the receipt's only record of it, so the mind checks +/// it here and every writer, whatever its transport, gets the check: the agent, +/// the session and the tool follow the leaf's `Title` rule (one alphanumeric +/// character, no control or line-break character, no bidi control) within +/// `Short`'s bound. The leaf keeps that rule private, so +/// `provenance_text_is_the_leafs_title_rule` pins this to it. Refused as the +/// leaf's own refusals, since the fields are the leaf's own type. +fn refuse_provenance(provenance: &PipelineProvenance) -> Result<(), MindRefusal> { + let bidi = |c: char| { + matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') + }; + let control = |c: char| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'); + for (field, value) in [ + ("provenance.agent", &provenance.agent), + ("provenance.session", &provenance.session), + ("provenance.tool", &provenance.tool), + ] { + let text = value.0.as_str(); + if !text.chars().any(char::is_alphanumeric) || text.contains(control) || text.contains(bidi) { + return Err(PipelineRefusal::InvalidFormat { field: field.into(), value: text.into() }.into()); + } + if text.len() > SHORT_MAX { + return Err(PipelineRefusal::FieldBound { field: field.into(), limit: SHORT_MAX as u32, actual: text.len() as u32 }.into()); + } + } + Ok(()) +} + +/// A3 through the leaf, then A5. +fn stage(envelope: CultCacheEnvelope, mind: &Slug) -> Result { + validate_pipeline_write_envelope(&envelope)?; + let document = PipelineDocument::decode(&envelope)?; + refuse_foreign_instance(&document, mind)?; + Ok(Staged { kind: document.kind(), key: envelope.key.clone(), document, envelope }) +} + +/// A5: every document carrying an instance field names this mind; a hand-off +/// names it on one side. +fn refuse_foreign_instance(document: &PipelineDocument, mind: &Slug) -> Result<(), MindRefusal> { + let foreign = |declared: &Slug| MindRefusal::ForeignInstance { declared: declared.0.clone(), mind: mind.0.clone() }; + match document { + PipelineDocument::Instance(value) if value.instance != *mind => Err(foreign(&value.instance)), + PipelineDocument::Stewardship(value) if value.instance != *mind => Err(foreign(&value.instance)), + PipelineDocument::HandOff(value) if value.from_instance != *mind && value.to_instance != *mind => { + Err(foreign(&value.from_instance)) + } + _ => Ok(()), + } +} + +/// What a missing referent is called, by the field that cited it. +enum Missing { + Reference, + SpecOfReport(String), + Supersessor, +} + +struct Reference { + kind: PipelineKind, + id: String, + missing: Missing, +} + +/// A7's field list is `docs::citations`: every `PipelineRef` and every +/// full-id `Short` field, each carrying the refusal its absence earns. A +/// hand-off's documents are cited on the source side only, and each must read +/// as an id of a kind; the receiving side imports them afterwards (Cut 12). +fn references(staged: &Staged, mind: &Slug) -> Result, MindRefusal> { + if let PipelineDocument::HandOff(hand_off) = &staged.document { + if hand_off.from_instance != *mind { + return Ok(Vec::new()); + } + if let Some(document) = hand_off.documents.iter().find(|document| kind_of_id(&document.0).is_none()) { + return Err(PipelineRefusal::InvalidFormat { + field: "hand_off.documents".into(), + value: document.0.clone(), + } + .into()); + } + } + Ok(citations(&staged.document) + .into_iter() + .map(|(role, target)| Reference { + kind: target.kind, + id: target.id.0, + missing: match role { + CitationRole::CutSpec => Missing::SpecOfReport(staged.key.clone()), + CitationRole::SupersededBy => Missing::Supersessor, + _ => Missing::Reference, + }, + }) + .collect()) +} + +/// A7: every referent is in the batch or the image; the image ones are the +/// receipt's strong reads. +fn resolve(docs: &Docs, staged: &Staged, mind: &Slug, strong: &mut BTreeSet<(String, String)>) -> Result<(), MindRefusal> { + for reference in references(staged, mind)? { + if docs.in_batch(reference.kind, &reference.id).is_some() { + continue; + } + if docs.in_image(reference.kind, &reference.id).is_some() { + strong.insert((reference.kind.type_id().to_string(), reference.id)); + continue; + } + return Err(match reference.missing { + Missing::Reference => MindRefusal::MissingReference { kind: reference.kind, id: reference.id }, + Missing::SpecOfReport(report) => MindRefusal::CutReportWithoutSpec { report }, + Missing::Supersessor => MindRefusal::UnknownSupersessor { id: reference.id }, + }); + } + Ok(()) +} + +/// The writes admission derives from the batch: a ruling that answers +/// derives the `Answered` resolution of its question; a hand-off derives +/// the stewardship withdrawal on its source side and the stewardship +/// assignment on its receiving side. One the batch already carries is not +/// derived again. +fn derive(docs: &Docs, mind: &Slug) -> Vec { + use PipelineDocument as D; + use PipelineKind as K; + let mut derived = Vec::new(); + for staged in &docs.batch { + match &staged.document { + D::Ruling(ruling) => { + if let Some(question) = &ruling.answers { + let subject = PipelineRef { kind: K::Question, id: question.clone() }; + let sequence = docs.derived_resolution_sequence(&subject, |outcome| { + matches!(outcome, ResolutionOutcome::Answered { by } if by.kind == K::Ruling && by.id.0 == staged.key) + }); + derived.push(D::Resolution(PipelineResolution { + sequence, + subject, + outcome: ResolutionOutcome::Answered { by: PipelineRef { kind: K::Ruling, id: Short(staged.key.clone()) } }, + rationale: ruling.ruling.clone(), + resolved_on: ruling.ruled_on.clone(), + })); + } + } + D::HandOff(hand_off) => { + if hand_off.from_instance == *mind + && let Some((stewardship_key, _)) = docs.stewardship_of(mind, &hand_off.repo, Some(&staged.key)) + { + let subject = PipelineRef { kind: K::Stewardship, id: Short(stewardship_key.to_string()) }; + let sequence = docs.derived_resolution_sequence(&subject, |outcome| { + matches!(outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == staged.key) + }); + derived.push(D::Resolution(PipelineResolution { + sequence, + subject, + outcome: ResolutionOutcome::Withdrawn { reason: Line(staged.key.clone()) }, + rationale: hand_off.reason.clone(), + resolved_on: hand_off.handed_on.clone(), + })); + } + if hand_off.to_instance == *mind { + derived.push(D::Stewardship(PipelineStewardship { + instance: mind.clone(), + sequence: docs.derived_stewardship_sequence(mind, &hand_off.repo, &staged.key), + repo: hand_off.repo.clone(), + assigned_on: hand_off.handed_on.clone(), + note: Line(staged.key.clone()), + })); + } + } + _ => {} + } + } + derived.retain(|document| !docs.batch.iter().any(|staged| staged.document == *document)); + derived +} + +/// A8: the per-kind rules. +fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { + use PipelineDocument as D; + use PipelineKind as K; + let key = staged.key.as_str(); + match &staged.document { + D::Campaign(campaign) => { + if campaign.repos.is_empty() { + return Err(MindRefusal::EmptyRepos { campaign: key.into() }); + } + for repo in &campaign.repos { + if docs.stewardship_of(mind, repo, None).is_none() { + return Err(MindRefusal::RepoNotStewarded { repo: repo.0.clone() }); + } + } + Ok(()) + } + D::Target(target) => { + let predecessor = D::Target(epiphany_pipeline::PipelineTarget { revision: target.revision.wrapping_sub(1), ..target.clone() }); + revision_rule(docs, K::Target, key, target.revision, &predecessor)?; + unique_labels("target.invariants", target.invariants.iter().map(|invariant| invariant.label.0.as_str())) + } + D::Question(question) => { + if question.options.len() < 2 || !question.options.iter().any(|option| option.label == question.recommended) { + return Err(MindRefusal::InvalidOptions { question: key.into() }); + } + unique_labels("question.options", question.options.iter().map(|option| option.label.0.as_str())) + } + D::Ruling(ruling) => { + if ruling.operator_quote.is_some() && ruling.authority != RulingAuthority::Operator { + return Err(MindRefusal::QuoteWithoutOperator { ruling: key.into() }); + } + let Some(question_id) = &ruling.answers else { return Ok(()) }; + let Some(D::Question(question)) = docs.find(K::Question, &question_id.0) else { + return Err(MindRefusal::MissingReference { kind: K::Question, id: question_id.0.clone() }); + }; + let own = |resolution: &PipelineResolution| { + matches!(&resolution.outcome, ResolutionOutcome::Answered { by } if by.kind == K::Ruling && by.id.0 == key) + }; + if !docs.in_force_unless(K::Question, &question_id.0, own) { + return Err(MindRefusal::AlreadyResolved { subject: question_id.0.clone() }); + } + let choice = ruling.choice.as_ref().map_or("", |choice| choice.0.as_str()); + if !question.options.iter().any(|option| option.label.0 == choice) { + return Err(MindRefusal::InvalidChoice { ruling: key.into(), choice: choice.into() }); + } + Ok(()) + } + D::CutSpec(spec) => { + let campaign = docs.of_kind(K::Campaign).find_map(|(_, document)| match document { + D::Campaign(campaign) if campaign.slug == spec.campaign => Some(campaign), + _ => None, + }); + let Some(campaign) = campaign else { + return Err(MindRefusal::MissingReference { kind: K::Campaign, id: spec.campaign.0.clone() }); + }; + if !campaign.repos.contains(&spec.repo) { + return Err(MindRefusal::RepoNotInCampaign { repo: spec.repo.0.clone() }); + } + for ruling in &spec.rulings { + if !docs.in_force(K::Ruling, &ruling.0) { + return Err(MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: ruling.0.clone() }); + } + } + let predecessor = D::CutSpec(epiphany_pipeline::PipelineCutSpec { revision: spec.revision.wrapping_sub(1), ..spec.clone() }); + revision_rule(docs, K::CutSpec, key, spec.revision, &predecessor) + } + D::CutReport(report) => { + let Some(D::CutSpec(spec)) = docs.find(K::CutSpec, &report.cut_spec.0) else { + return Err(MindRefusal::CutReportWithoutSpec { report: key.into() }); + }; + if !docs.in_force(K::CutSpec, &report.cut_spec.0) { + return Err(MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: report.cut_spec.0.clone() }); + } + if report.repo != spec.repo { + return Err(MindRefusal::SpecMismatch { field: "repo".into() }); + } + if report.branch != spec.branch { + return Err(MindRefusal::SpecMismatch { field: "branch".into() }); + } + if !report.commits.iter().any(|commit| commit.sha == report.range.head) { + return Err(MindRefusal::RangeOutsideCommits { head: report.range.head.0.clone() }); + } + Ok(()) + } + D::Verdict(verdict) => { + let Some(D::CutReport(report)) = docs.find(K::CutReport, &verdict.cut_report.0) else { + return Err(MindRefusal::MissingReference { kind: K::CutReport, id: verdict.cut_report.0.clone() }); + }; + for claim in &verdict.claims { + let confirmed = claim.findings.iter().filter_map(|finding| docs.find(K::Finding, &finding.0)).any(|document| { + matches!(document, D::Finding(finding) if finding.confidence == FindingConfidence::Confirmed) + }); + match claim.outcome { + ClaimOutcome::Falsified if !confirmed => { + return Err(MindRefusal::FalsifiedClaimWithoutConfirmedFinding { claim: claim.claim.0.clone() }); + } + ClaimOutcome::Unproven if confirmed => { + return Err(MindRefusal::UnprovenClaimWithConfirmedFinding { claim: claim.claim.0.clone() }); + } + _ => {} + } + for label in &claim.mutations { + if !report.mutations.iter().any(|mutation| mutation.label == *label) { + return Err(MindRefusal::UnknownMutationLabel { label: label.0.clone() }); + } + } + } + for promise in &report.promises { + let count = verdict.claims.iter().filter(|claim| claim.promise.as_ref() == Some(&promise.label)).count(); + if count != 1 { + return Err(MindRefusal::PromiseWithoutVerdict { promise: promise.label.0.clone() }); + } + } + Ok(()) + } + D::Finding(finding) => { + if finding.evidence.is_empty() || finding.locations.is_empty() { + return Err(MindRefusal::FindingWithoutEvidence); + } + let target = docs.of_kind(K::Target).find_map(|(target_key, document)| match document { + D::Target(target) if target.campaign == finding.campaign && docs.in_force(K::Target, target_key) => Some(target), + _ => None, + }); + for label in &finding.invariants { + if !target.is_some_and(|target| target.invariants.iter().any(|invariant| invariant.label == *label)) { + return Err(MindRefusal::UnknownInvariant { label: label.0.clone() }); + } + } + Ok(()) + } + D::Resolution(resolution) => resolution_rule(docs, resolution), + D::Stewardship(stewardship) => { + let expected = docs.latest_stewardship(&stewardship.instance, &stewardship.repo, Some(stewardship)) + 1; + if stewardship.sequence != expected { + return Err(MindRefusal::StewardshipOutOfSequence { + repo: stewardship.repo.0.clone(), + expected, + actual: stewardship.sequence, + }); + } + if docs.stewardships_of(mind, &stewardship.repo, None).iter().any(|(other, _)| *other != key) { + return Err(MindRefusal::AlreadyStewarded { repo: stewardship.repo.0.clone() }); + } + Ok(()) + } + D::HandOff(hand_off) => { + if hand_off.from_instance == *mind && docs.stewardship_of(mind, &hand_off.repo, Some(key)).is_none() { + return Err(MindRefusal::NotStewarded { repo: hand_off.repo.0.clone() }); + } + Ok(()) + } + D::FollowUp(_) | D::Instance(_) => Ok(()), + } +} + +/// Revision 1 stands alone; revision N needs a resolution in the batch that +/// supersedes revision N-1 by this document. The predecessor's key is the +/// leaf's, from the same document one revision back. +fn revision_rule(docs: &Docs, kind: PipelineKind, key: &str, revision: u32, predecessor: &PipelineDocument) -> Result<(), MindRefusal> { + if revision == 1 { + return Ok(()); + } + let refusal = || MindRefusal::RevisionWithoutSupersession { kind, revision }; + if revision == 0 { + return Err(refusal()); + } + let predecessor_key = pipeline_key(predecessor)?; + let superseded = docs.batch.iter().any(|staged| { + matches!(&staged.document, PipelineDocument::Resolution(resolution) + if resolution.subject.kind == kind + && resolution.subject.id.0 == predecessor_key + && matches!(&resolution.outcome, ResolutionOutcome::Superseded { by } + if by.iter().any(|supersessor| supersessor.kind == kind && supersessor.id.0 == key))) + }); + if superseded { Ok(()) } else { Err(refusal()) } +} + +fn unique_labels<'a>(field: &str, labels: impl Iterator) -> Result<(), MindRefusal> { + let mut seen = BTreeSet::new(); + for label in labels { + if !seen.insert(label) { + return Err(MindRefusal::DuplicateLabel { field: field.into(), label: label.into() }); + } + } + Ok(()) +} + +fn outcome_name(outcome: &ResolutionOutcome) -> &'static str { + match outcome { + ResolutionOutcome::Superseded { .. } => "Superseded", + ResolutionOutcome::Answered { .. } => "Answered", + ResolutionOutcome::Fixed { .. } => "Fixed", + ResolutionOutcome::Deferred { .. } => "Deferred", + ResolutionOutcome::Recorded { .. } => "Recorded", + ResolutionOutcome::Withdrawn { .. } => "Withdrawn", + } +} + +/// The resolution matrix: which outcomes a subject kind admits, and of which +/// kinds its referents must be. Admission's, never the leaf's. +/// +/// A resolution is itself resolvable, by withdrawal alone: the operator's +/// ruling on Q17 keeps a withdrawn resolution attached to its subject rather +/// than erasing it, and a subject whose resolution is withdrawn may be +/// resolved again at the next sequence, which the leaf's key now carries. The +/// chain stops there: Q19 A caps it at depth two, in `resolution_rule`, since +/// a withdrawal that could itself be withdrawn would leave two closures +/// standing over one subject. +fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { + use PipelineKind as K; + use ResolutionOutcome as O; + let all = |by: &[PipelineRef], kind: K| by.iter().all(|supersessor| supersessor.kind == kind); + let fits = match (subject, outcome) { + (K::Target, O::Superseded { by }) => all(by, K::Target), + (K::Question, O::Answered { by }) => by.kind == K::Ruling, + (K::Question, O::Withdrawn { .. }) => true, + (K::Ruling, O::Superseded { by }) => all(by, K::Ruling), + (K::CutSpec, O::Superseded { by }) => all(by, K::CutSpec), + (K::CutSpec, O::Withdrawn { .. }) => true, + (K::Finding, O::Fixed { by, .. }) => by.as_ref().is_none_or(|report| report.kind == K::CutReport), + (K::Finding, O::Deferred { to }) => to.kind == K::FollowUp, + (K::Finding, O::Recorded { .. } | O::Withdrawn { .. }) => true, + (K::FollowUp, O::Fixed { by, .. }) => by.as_ref().is_none_or(|report| report.kind == K::CutReport), + (K::FollowUp, O::Superseded { by }) => all(by, K::FollowUp), + (K::FollowUp, O::Withdrawn { .. }) => true, + (K::Stewardship, O::Superseded { by }) => all(by, K::Stewardship), + (K::Stewardship, O::Withdrawn { .. }) => true, + (K::Resolution, O::Withdrawn { .. }) => true, + _ => false, + }; + fits +} + +/// The resolution row: the sequence, the in-force subject, the matrix, the +/// Q19 cap and the reinstatement rule it rests on, a non-empty supersession, +/// every referent in force, and the two +/// coherence rules (an `Answered` ruling answers this question; a cut spec is +/// superseded within its cut). +fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), MindRefusal> { + use PipelineDocument as D; + use PipelineKind as K; + let subject_kind = resolution.subject.kind; + let subject_id = resolution.subject.id.0.as_str(); + if let ResolutionOutcome::Superseded { by } = &resolution.outcome && by.is_empty() { + return Err(MindRefusal::EmptySupersession); + } + // The sequence is the writer's and is checked against the image, as a + // revision is: the previous plus one, counting every record of this + // subject other than this document itself. + let expected = docs.latest_resolution(&resolution.subject, Some(resolution)) + 1; + if resolution.sequence != expected { + return Err(MindRefusal::ResolutionOutOfSequence { + subject: subject_id.into(), + expected, + actual: resolution.sequence, + }); + } + // A8: a subject with a resolution still in force is closed. A withdrawn + // one is not in force, so the subject is open and this is its next record. + if !docs.in_force_unless(subject_kind, subject_id, |other| other == resolution) { + return Err(MindRefusal::AlreadyResolved { subject: subject_id.into() }); + } + let incompatible = || MindRefusal::IncompatibleResolution { subject_kind, outcome: outcome_name(&resolution.outcome).into() }; + if !matrix(subject_kind, &resolution.outcome) { + return Err(incompatible()); + } + // Q19 A: the chain stops at depth two. A withdrawal cannot be withdrawn; + // to reinstate a closure, resolve the subject again at the next sequence. + if subject_kind == K::Resolution + && let Some(D::Resolution(subject)) = docs.find(K::Resolution, subject_id) + && subject.subject.kind == K::Resolution + { + return Err(incompatible()); + } + // Q19 A's rationale one step out from the cap: a withdrawal never + // re-raises an earlier record over a later one. Withdrawing a resolution + // puts its subject back in force, so refuse while a later record of that + // subject's own scope stands -- a repo's next assignment, a document's + // next revision. + if matches!(resolution.outcome, ResolutionOutcome::Withdrawn { .. }) + && subject_kind == K::Resolution + && let Some(D::Resolution(reinstating)) = docs.find(K::Resolution, subject_id) + && let Some(base) = docs.find(reinstating.subject.kind, &reinstating.subject.id.0) + && let Some(later) = docs.later_in_force(base) + { + return Err(MindRefusal::WouldReinstateOverLater { + subject: reinstating.subject.id.0.clone(), + later: later.into(), + }); + } + for (_, referent) in outcome_citations(&resolution.outcome) { + if !docs.in_force(referent.kind, &referent.id.0) { + return Err(MindRefusal::CitesResolvedDocument { kind: referent.kind, id: referent.id.0 }); + } + } + match &resolution.outcome { + ResolutionOutcome::Superseded { by } if subject_kind == K::CutSpec => { + let Some(D::CutSpec(subject)) = docs.find(K::CutSpec, subject_id) else { return Ok(()) }; + for supersessor in by { + if let Some(D::CutSpec(spec)) = docs.find(K::CutSpec, &supersessor.id.0) + && spec.cut != subject.cut + { + return Err(incompatible()); + } + } + } + ResolutionOutcome::Answered { by } => { + let Some(D::Ruling(ruling)) = docs.find(K::Ruling, &by.id.0) else { return Ok(()) }; + if ruling.answers.as_ref().map(|answers| answers.0.as_str()) != Some(subject_id) { + return Err(incompatible()); + } + } + _ => {} + } + Ok(()) +} + +/// A10: a write whose identity the image already holds. Every kind answers +/// the same way now that a resolution key carries a sequence: the identity is +/// taken. Whether a subject is closed is A8's question, not this one's. +fn refuse_collisions(docs: &Docs) -> Result<(), MindRefusal> { + for staged in &docs.batch { + if docs.in_image(staged.kind, &staged.key).is_some() { + return Err(MindRefusal::IdentityCollision { kind: staged.kind, id: staged.key.clone() }); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::*; + use crate::receipt::{DocumentVersion, Faculty, HuginnCommitReceipt}; + use crate::store::test_stores::{MemoryStore, RefusingStore, SwapCommand}; + use cultcache_rs::DatabaseEntry; + use epiphany_pipeline::{PIPELINE_SCHEMA_EPOCH, PipelineDocument as D, PipelineKind as K}; + use sha2::{Digest, Sha256}; + + fn receipt_count(mind: &Mind) -> usize { + mind.receipts().unwrap().len() + } + + #[test] + fn an_empty_store_opens_and_the_first_write_must_carry_the_instance() { + let store = MemoryStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + assert!(mind.is_empty()); + assert_eq!(refusal(admit(&mut mind, vec![stewardship(INSTANCE, REPO), campaign(&[REPO])])), MindRefusal::MissingIdentity); + assert!(store.rows().is_empty()); + let (receipt_id, writes) = committed(admit(&mut mind, vec![instance(INSTANCE), stewardship(INSTANCE, REPO)])); + assert!(writes.contains(&r(K::Instance, &format!("{INSTANCE}:instance:self")))); + let receipts = mind.receipts().unwrap(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].receipt_id, receipt_id); + let identities = receipts[0].writes.iter().map(DocumentVersion::identity).collect::>(); + assert!(identities.contains(&(K::Instance.type_id(), format!("{INSTANCE}:instance:self").as_str()))); + assert!(identities.contains(&(HuginnMindEpoch::TYPE, PIPELINE_SCHEMA_EPOCH)), "the epoch record is derived: {identities:?}"); + assert!(receipts[0].strong_reads.is_empty()); + // The store now opens again as this instance, and as nothing else. + assert!(!Mind::open_with(store.clone(), &slug(INSTANCE)).unwrap().is_empty()); + assert!(Mind::open_with(store, &slug(OTHER_INSTANCE)).is_err()); + } + + /// The identity the index labels its collection with: none before the + /// first write, then the first receipt's id and never a later one. + #[test] + fn the_genesis_receipt_id_is_the_first_admissions_and_stays_put() { + let mut mind = opened(MemoryStore::new(), INSTANCE); + assert_eq!(mind.genesis_receipt_id().unwrap(), None); + let (first, _) = committed(admit(&mut mind, vec![instance(INSTANCE), stewardship(INSTANCE, REPO)])); + assert_eq!(mind.genesis_receipt_id().unwrap(), Some(first.clone())); + let (second, _) = committed(admit(&mut mind, vec![campaign(&[REPO])])); + assert_ne!(first, second); + assert_eq!(mind.genesis_receipt_id().unwrap(), Some(first)); + } + + fn admit_attributed(agent: &str, session: &str) -> (Mind, PipelineAdmissionOutcome) { + admit_signed(agent, session, "admit") + } + + fn admit_signed(agent: &str, session: &str, tool: &str) -> (Mind, PipelineAdmissionOutcome) { + let mut mind = opened(MemoryStore::new(), INSTANCE); + let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: s(agent), session: s(session), tool: s(tool) }; + let batch = PipelineAdmissionBatch { instance: slug(INSTANCE), provenance, documents: vec![instance(INSTANCE), stewardship(INSTANCE, REPO)] }; + let outcome = mind.admit(batch, now()); + (mind, outcome) + } + + /// Who admitted a batch is validated by the mind, so no writer can commit + /// a blank, multi-line or oversized attribution, and a refused batch leaves + /// nothing behind. + #[test] + fn provenance_names_an_agent_a_session_and_a_tool_on_one_clean_line_within_short() { + let over = "x".repeat(SHORT_MAX + 1); + let exact = "y".repeat(SHORT_MAX); + for bad in [ + "", + " \t ", + "\u{2003}", + "\u{200B}", + "line\nbreak", + "line\rbreak", + "a\u{0B}b", + "a\u{0C}b", + "a\u{85}b", + "a\u{2028}b", + "a\u{2029}b", + "a\0b", + "a\u{1B}[31mb", + "a\u{202E}b", + "a\u{2066}b", + "a\u{200F}b", + ] { + for field in ["provenance.agent", "provenance.session", "provenance.tool"] { + let (mind, outcome) = match field { + "provenance.agent" => admit_signed(bad, "session-1", "admit"), + "provenance.session" => admit_signed("claude", bad, "admit"), + _ => admit_signed("claude", "session-1", bad), + }; + let refused = refusal(outcome); + assert!( + matches!(&refused, MindRefusal::Document(PipelineRefusal::InvalidFormat { field: named, .. }) if named == field), + "{field}={bad:?}: {refused:?}" + ); + assert!(mind.is_empty(), "{field}={bad:?}: a refused batch wrote"); + } + } + for (field, outcome) in [ + ("provenance.agent", admit_signed(&over, "session-1", "admit")), + ("provenance.session", admit_signed("claude", &over, "admit")), + ("provenance.tool", admit_signed("claude", "session-1", &over)), + ] { + let (mind, outcome) = outcome; + assert_eq!( + refusal(outcome), + MindRefusal::Document(PipelineRefusal::FieldBound { field: field.into(), limit: 200, actual: 201 }) + ); + assert!(mind.is_empty()); + } + // The bound is in bytes, not characters: 101 two-byte characters are 202 bytes. + let (_, outcome) = admit_attributed(&"é".repeat(101), "session-1"); + assert_eq!( + refusal(outcome), + MindRefusal::Document(PipelineRefusal::FieldBound { field: "provenance.agent".into(), limit: 200, actual: 202 }) + ); + let (_, outcome) = admit_signed("claude", "session-1", &"é".repeat(101)); + assert!(matches!(refusal(outcome), MindRefusal::Document(PipelineRefusal::FieldBound { actual: 202, .. }))); + let (_, outcome) = admit_attributed(&"é".repeat(100), "session-1"); + committed(outcome); + // The bound is inclusive and a name with inner spaces is a name. + let (mind, outcome) = admit_signed(&exact, "session 7", "admit tool"); + committed(outcome); + let receipts = mind.receipts().unwrap(); + assert_eq!(receipts[0].provenance.agent, s(&exact)); + assert_eq!(receipts[0].provenance.session, s("session 7")); + assert_eq!(receipts[0].provenance.tool, s("admit tool")); + } + + /// The leaf's `Title` rule is private, so this holds the provenance rule to + /// it: the two accept and refuse the same text. + #[test] + fn provenance_text_is_the_leafs_title_rule() { + let leaf_accepts = |text: &str| { + let D::Campaign(mut campaign) = campaign(&[REPO]) else { unreachable!() }; + campaign.title = epiphany_pipeline::Title(text.into()); + D::Campaign(campaign).validate().is_ok() + }; + let mine_accepts = |text: &str| { + let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: s(text), session: s("s"), tool: s("t") }; + refuse_provenance(&provenance).is_ok() + }; + for text in [ + "claude", "session 7", "", " ", "\t", "\u{200B}", "\u{2003}", "!!!", "a\nb", "a\u{0B}b", "a\u{85}b", "a\u{2028}b", + "a\0b", "a\u{7F}b", "a\u{202E}b", "a\u{061C}b", "a\u{2069}b", "a\u{200D}b", "é", "日本", "a\u{FEFF}b", + &"x".repeat(200), &"x".repeat(201), &"é".repeat(100), &"é".repeat(101), + ] { + assert_eq!(mine_accepts(text), leaf_accepts(text), "{text:?}"); + } + } + + #[test] + fn short_bound_is_the_leafs_own() { + let schema = serde_json::to_value(schemars::schema_for!(Short)).unwrap(); + assert_eq!(schema["maxLength"], serde_json::json!(SHORT_MAX)); + } + + #[test] + fn admission_refuses_a_foreign_instance_whatever_the_transport() { + let store = MemoryStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + let before = store.rows(); + let foreign = MindRefusal::ForeignInstance { declared: OTHER_INSTANCE.into(), mind: INSTANCE.into() }; + let envelopes = vec![prepare(&question("Q1", &["A", "B"], "A"))]; + assert_eq!( + refusal(mind.admit_prepared(&slug(OTHER_INSTANCE), provenance(Faculty::Hands), envelopes, now())), + foreign + ); + assert_eq!(refusal(admit(&mut mind, vec![stewardship(OTHER_INSTANCE, OTHER_REPO)])), foreign); + assert_eq!( + refusal(admit(&mut mind, vec![hand_off("a", "b", REPO, &[])])), + MindRefusal::ForeignInstance { declared: "a".into(), mind: INSTANCE.into() } + ); + assert_eq!(refusal(admit(&mut mind, vec![instance(OTHER_INSTANCE)])), foreign); + assert_eq!(store.rows(), before); + } + + #[test] + fn two_minds_in_one_state_root_stay_separate() { + let root = tempfile::tempdir().unwrap(); + let mut yggdrasil = Mind::open(root.path(), &slug(INSTANCE)).unwrap(); + let mut thought_cage = Mind::open(root.path(), &slug(OTHER_INSTANCE)).unwrap(); + assert_ne!( + Mind::store_path_for(root.path(), &slug(INSTANCE)).unwrap(), + Mind::store_path_for(root.path(), &slug(OTHER_INSTANCE)).unwrap() + ); + committed(admit(&mut yggdrasil, vec![instance(INSTANCE), stewardship(INSTANCE, REPO)])); + committed(admit(&mut thought_cage, vec![instance(OTHER_INSTANCE)])); + let key = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1"); + assert!(yggdrasil.envelope(K::Stewardship, &key).is_some()); + assert!(thought_cage.envelope(K::Stewardship, &key).is_none()); + assert_eq!(yggdrasil.envelopes().len(), 4); + assert_eq!(thought_cage.envelopes().len(), 3); + } + + #[test] + fn keys_are_recomputed_and_a_forged_key_refuses() { + let mut mind = seeded(); + let mut envelope = prepare(&question("Q1", &["A", "B"], "A")); + let expected = envelope.key.clone(); + envelope.key = format!("{expected}-forged"); + let outcome = mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Hands), vec![envelope.clone()], now()); + assert_eq!( + refusal(outcome), + MindRefusal::Document(PipelineRefusal::InvalidIdentity { kind: K::Question, key: envelope.key, expected }) + ); + } + + #[test] + fn references_must_exist_in_image_or_batch() { + let mut mind = seeded(); + let absent_question = id("question", "Q9"); + let mut answering = ruling("R1"); + answering.answers = Some(s(&absent_question)); + answering.choice = Some(l("A")); + assert_eq!( + refusal(admit(&mut mind, vec![D::Ruling(answering)])), + MindRefusal::MissingReference { kind: K::Question, id: absent_question } + ); + let absent_finding = id("finding", "cut-1.s1.F9"); + assert_eq!( + refusal(admit(&mut mind, vec![follow_up("FU-1", r(K::Finding, &absent_finding))])), + MindRefusal::MissingReference { kind: K::Finding, id: absent_finding } + ); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], Some("P1"), &[])])])), + MindRefusal::MissingReference { kind: K::CutReport, id: id("cut_report", "cut-1.h1") } + ); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])), + MindRefusal::MissingReference { kind: K::Question, id: id("question", "Q1") } + ); + // A well-formed id of another kind is a missing reference of the + // cited kind, not a wrong-kind refusal. + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let mut spec = cut_spec("1", 1); + spec.rulings = vec![s(&id("question", "Q1"))]; + assert_eq!( + refusal(admit(&mut mind, vec![D::CutSpec(spec)])), + MindRefusal::MissingReference { kind: K::Ruling, id: id("question", "Q1") } + ); + } + + #[test] + fn batch_is_all_or_nothing() { + let store = RefusingStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + let before = store.rows(); + let receipts = receipt_count(&mind); + let mut quoted = ruling("R1"); + quoted.operator_quote = Some("go ahead".into()); + let batch = vec![question("Q1", &["A", "B"], "A"), question("Q2", &["A", "B"], "B"), D::Ruling(quoted)]; + assert_eq!(refusal(admit(&mut mind, batch)), MindRefusal::QuoteWithoutOperator { ruling: id("ruling", "R1") }); + assert_eq!(store.rows(), before); + assert_eq!(receipt_count(&mind), receipts); + + store.command(SwapCommand::Lose); + let outcome = admit(&mut mind, vec![question("Q1", &["A", "B"], "A")]); + assert!(matches!(outcome, PipelineAdmissionOutcome::Conflict { .. }), "{outcome:?}"); + assert_eq!(store.rows(), before); + assert_eq!(receipt_count(&mind), receipts); + + store.command(SwapCommand::Fail); + assert!(matches!(refusal(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])), MindRefusal::Unavailable { .. })); + assert_eq!(store.rows(), before); + + store.command(SwapCommand::Delegate); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + assert_eq!(receipt_count(&mind), receipts + 1); + } + + #[test] + fn exact_replay_returns_already_admitted_across_provenance() { + let mut mind = seeded(); + let receipts = receipt_count(&mind); + let (first, _) = committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let batch = PipelineAdmissionBatch { + instance: slug(INSTANCE), + provenance: provenance(Faculty::Soul), + documents: vec![question("Q1", &["A", "B"], "A")], + }; + let later = now() + chrono::Duration::hours(1); + assert_eq!(mind.admit(batch, later), PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: first.clone() }); + assert_eq!(receipt_count(&mind), receipts + 1); + // The first write, epoch record and all, replays too. + let seed_batch = PipelineAdmissionBatch { + instance: slug(INSTANCE), + provenance: provenance(Faculty::Imagination), + documents: vec![instance(INSTANCE), stewardship(INSTANCE, REPO), campaign(&[REPO]), target(1, &[INVARIANT])], + }; + assert!(matches!(mind.admit(seed_batch, later), PipelineAdmissionOutcome::AlreadyAdmitted { .. })); + assert_eq!(receipt_count(&mind), receipts + 1); + } + + /// RS-1: the ordinal is `head + 1` at admission, not a rank derived from + /// the clock or the receipt id. Three batches land at one `now`, so a + /// mutant that ranked by `(committed_at, receipt_id)` would fall back to + /// id order among them; the ids are checked not to already sort in + /// admission order, so that fallback provably disagrees with the truth. + #[test] + fn ordinals_are_admission_order_not_clock_or_id_order() { + let mut mind = seeded(); + assert_eq!(receipt::head(&mind).unwrap(), 1, "the seed batch is ordinal 1"); + let same_time = now(); + let (id1, _) = committed(admit_at(&mut mind, vec![question_n(1)], same_time)); + let (id2, _) = committed(admit_at(&mut mind, vec![question_n(2)], same_time)); + let (id3, _) = committed(admit_at(&mut mind, vec![question_n(3)], same_time)); + + let mut by_id = [id1.clone(), id2.clone(), id3.clone()]; + by_id.sort(); + assert_ne!( + by_id, + [id1.clone(), id2.clone(), id3.clone()], + "the fixture needs receipt ids that do not already sort in admission order" + ); + + let receipts = mind.receipts().unwrap(); + let ordinal_of = |id: &str| receipts.iter().find(|receipt| receipt.receipt_id == id).unwrap().ordinal; + assert_eq!(ordinal_of(&id1), 2); + assert_eq!(ordinal_of(&id2), 3); + assert_eq!(ordinal_of(&id3), 4); + assert_eq!(receipt::head(&mind).unwrap(), 4); + } + + /// RS-1, A9: the ordinal is not digested, so an exact replay finds the + /// receipt it first landed with and keeps that receipt's ordinal, even + /// when the head has moved since and a fresh candidate would be assigned + /// a different one. + #[test] + fn an_exact_replay_keeps_its_ordinal() { + let mut mind = seeded(); + let (first_id, _) = committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let first_ordinal = + mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == first_id).unwrap().ordinal; + + // Move the head, so a replay's own candidate would carry a different + // ordinal than the one already on the stored receipt. + committed(admit(&mut mind, vec![question("Q2", &["A", "B"], "A")])); + + let outcome = admit(&mut mind, vec![question("Q1", &["A", "B"], "A")]); + assert_eq!(outcome, PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: first_id.clone() }); + + let receipts = mind.receipts().unwrap(); + assert_eq!(receipts.iter().filter(|receipt| receipt.receipt_id == first_id).count(), 1, "no second receipt lands"); + let stored = receipts.iter().find(|receipt| receipt.receipt_id == first_id).unwrap(); + assert_eq!(stored.ordinal, first_ordinal, "the replay does not overwrite the ordinal"); + } + + /// A batch that derives a write replays like any other: the second + /// admission re-derives the record the first one landed, sequence and + /// all, so A9 recognises the digest and answers with the first receipt + /// instead of the derived write colliding with itself. + #[test] + fn an_exact_replay_re_derives_the_rulings_resolution() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let mut answering = ruling("R1"); + answering.answers = Some(s(&id("question", "Q1"))); + answering.choice = Some(l("A")); + let (receipt_id, writes) = committed(admit(&mut mind, vec![D::Ruling(answering.clone())])); + assert!(writes.contains(&r(K::Resolution, &id("resolution", "question.Q1.n1")))); + let receipts = receipt_count(&mind); + assert_eq!( + admit(&mut mind, vec![D::Ruling(answering)]), + PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id } + ); + assert_eq!(receipt_count(&mind), receipts, "a replay writes no second receipt"); + } + + /// The same on both sides of a hand-off: the source re-derives the + /// withdrawal it landed, the receiving mind the assignment. + #[test] + fn an_exact_replay_re_derives_both_sides_of_a_hand_off() { + let away = hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[]); + let mut source = seeded(); + let (receipt_id, _) = committed(admit(&mut source, vec![away.clone()])); + let receipts = receipt_count(&source); + assert_eq!(admit(&mut source, vec![away.clone()]), PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id }); + assert_eq!(receipt_count(&source), receipts); + + let mut receiving = opened(MemoryStore::new(), OTHER_INSTANCE); + committed(admit(&mut receiving, vec![instance(OTHER_INSTANCE)])); + let (receipt_id, _) = committed(admit(&mut receiving, vec![away.clone()])); + let receipts = receipt_count(&receiving); + assert_eq!(admit(&mut receiving, vec![away]), PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id }); + assert_eq!(receipt_count(&receiving), receipts); + } + + /// The sequence a ruling's answer takes is the one that ruling's own + /// answer carries, not whatever answer the question already has. A + /// question reopened by withdrawing its first answer is answered again at + /// the next sequence, so the first answer stays readable under it. + #[test] + fn a_ruling_answering_a_reopened_question_takes_the_next_sequence() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let mut first = ruling("R1"); + first.answers = Some(s(&id("question", "Q1"))); + first.choice = Some(l("A")); + committed(admit(&mut mind, vec![D::Ruling(first)])); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &id("resolution", "question.Q1.n1")), withdrawn())])); + let mut second = ruling("R2"); + second.answers = Some(s(&id("question", "Q1"))); + second.choice = Some(l("B")); + let (_, writes) = committed(admit(&mut mind, vec![D::Ruling(second)])); + assert_eq!(writes, vec![ + r(K::Ruling, &id("ruling", "R2")), + r(K::Resolution, &id("resolution", "question.Q1.n2")), + ]); + assert!(mind.envelope(K::Resolution, &id("resolution", "question.Q1.n1")).is_some()); + } + + #[test] + fn a_refused_batch_is_not_answered_from_a_stored_receipt() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::Ruling(ruling("R1"))])); + let mut spec = cut_spec("1", 1); + spec.rulings = vec![s(&id("ruling", "R1"))]; + let envelopes = vec![prepare(&D::CutSpec(spec))]; + committed(mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Imagination), envelopes.clone(), now())); + committed(admit(&mut mind, vec![ + D::Ruling(ruling("R2")), + resolution(r(K::Ruling, &id("ruling", "R1")), superseded(&[r(K::Ruling, &id("ruling", "R2"))])), + ])); + assert_eq!( + refusal(mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Imagination), envelopes, now())), + MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: id("ruling", "R1") } + ); + } + + #[test] + fn a_receipt_names_the_exact_bytes_it_read_and_wrote() { + let store = MemoryStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + committed(admit(&mut mind, vec![D::Ruling(ruling("R1"))])); + let cited = mind.envelope(K::Ruling, &id("ruling", "R1")).unwrap().clone(); + let mut spec = cut_spec("1", 1); + spec.rulings = vec![s(&id("ruling", "R1"))]; + let (receipt_id, _) = committed(admit(&mut mind, vec![D::CutSpec(spec)])); + let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); + assert_eq!(receipt.strong_reads, vec![DocumentVersion::from_envelope(&cited)]); + let written = mind.envelope(K::CutSpec, &id("cut_spec", "cut-1.r1")).unwrap(); + assert_eq!(receipt.writes, vec![DocumentVersion::from_envelope(written)]); + for version in receipt.strong_reads.iter().chain(&receipt.writes) { + assert_eq!(version.payload_sha256, format!("{:x}", Sha256::digest(&version.payload_msgpack))); + } + let digest = rmp_serde::to_vec_named(&(&receipt.instance, &receipt.strong_reads, &receipt.writes)).unwrap(); + assert_eq!(receipt_id, format!("mind-commit-{:x}", Sha256::digest(&digest))); + + // The same content admitted by another faculty in another mind has + // the same id: provenance is not digested. + let mut twin = opened(MemoryStore::new(), INSTANCE); + seed(&mut twin); + committed(admit(&mut twin, vec![D::Ruling(ruling("R1"))])); + let mut spec = cut_spec("1", 1); + spec.rulings = vec![s(&id("ruling", "R1"))]; + let batch = PipelineAdmissionBatch { instance: slug(INSTANCE), provenance: provenance(Faculty::Soul), documents: vec![D::CutSpec(spec)] }; + let (twin_id, _) = committed(twin.admit(batch, now() + chrono::Duration::days(1))); + assert_eq!(twin_id, receipt_id); + let twin_receipt = twin.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == twin_id).unwrap(); + assert_ne!(twin_receipt.provenance, receipt.provenance); + } + + #[test] + fn a_document_is_written_once_and_superseded_by_resolution() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::Ruling(ruling("R8"))])); + let mut rewritten = ruling("R8"); + rewritten.ruling = "A repo owns its mind.".into(); + assert_eq!( + refusal(admit(&mut mind, vec![D::Ruling(rewritten)])), + MindRefusal::IdentityCollision { kind: K::Ruling, id: id("ruling", "R8") } + ); + committed(admit(&mut mind, vec![ + D::Ruling(ruling("R9")), + resolution(r(K::Ruling, &id("ruling", "R8")), superseded(&[r(K::Ruling, &id("ruling", "R9"))])), + ])); + assert_eq!(mind.get(K::Ruling, &id("ruling", "R8")).unwrap(), Some(D::Ruling(ruling("R8")))); + } + + /// A8: one resolution of a subject is in force at a time, and the next + /// record of that subject is the next sequence. The two rules are + /// separate refusals: a second closure at the right sequence is refused + /// because the first still stands, and one at a taken sequence is refused + /// before the question of standing arises. + #[test] + fn a_subject_with_a_resolution_in_force_refuses_another() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let subject = r(K::Question, &id("question", "Q1")); + committed(admit(&mut mind, vec![resolution(subject.clone(), withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution_n(subject.clone(), 2, ResolutionOutcome::Withdrawn { reason: "again".into() })])), + MindRefusal::AlreadyResolved { subject: id("question", "Q1") } + ); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(subject.clone(), ResolutionOutcome::Withdrawn { reason: "again".into() })])), + MindRefusal::ResolutionOutOfSequence { subject: id("question", "Q1"), expected: 2, actual: 1 } + ); + let mut answering = ruling("R1"); + answering.answers = Some(s(&id("question", "Q1"))); + answering.choice = Some(l("A")); + assert_eq!( + refusal(admit(&mut mind, vec![D::Ruling(answering)])), + MindRefusal::AlreadyResolved { subject: id("question", "Q1") } + ); + } + + /// Q17 B and Q19 A together: withdrawing a resolution reopens its subject + /// without erasing the record. The withdrawn closure is still readable by + /// key, the subject takes its next resolution at the next sequence, and + /// the chain stops at depth two -- a withdrawal cannot be withdrawn. + #[test] + fn a_withdrawn_resolution_reopens_its_subject_and_stays_readable() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let subject = r(K::Question, &id("question", "Q1")); + let first = id("resolution", "question.Q1.n1"); + committed(admit(&mut mind, vec![resolution(subject.clone(), withdrawn())])); + + // The withdrawal of that closure: one nesting out, under the + // resolution it withdraws. + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &first), withdrawn())])); + let withdrawal = id("resolution", "resolution.question.Q1.n1.n1"); + assert!(mind.get(K::Resolution, &withdrawal).unwrap().is_some()); + + // The subject is open again, and its next record is `n2`. The first + // one is still there, outcome and all: this is a log, not an erasure. + committed(admit(&mut mind, vec![resolution_n(subject.clone(), 2, withdrawn())])); + let Some(D::Resolution(kept)) = mind.get(K::Resolution, &first).unwrap() else { panic!() }; + assert_eq!(kept.outcome, withdrawn()); + assert_eq!(kept.sequence, 1); + + // `n2` stands, so a third is refused, and the withdrawal cannot + // itself be withdrawn to re-raise `n1` over it (Q19 A). + assert_eq!( + refusal(admit(&mut mind, vec![resolution_n(subject, 3, withdrawn())])), + MindRefusal::AlreadyResolved { subject: id("question", "Q1") } + ); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])), + MindRefusal::IncompatibleResolution { subject_kind: K::Resolution, outcome: "Withdrawn".into() } + ); + + // And a ruling may answer a question whose first closure was + // withdrawn: the derived resolution takes the next sequence. + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + committed(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &first), withdrawn())])); + let mut answering = ruling("R1"); + answering.answers = Some(s(&id("question", "Q1"))); + answering.choice = Some(l("A")); + let (_, writes) = committed(admit(&mut mind, vec![D::Ruling(answering)])); + assert_eq!(writes, vec![ + r(K::Ruling, &id("ruling", "R1")), + r(K::Resolution, &id("resolution", "question.Q1.n2")), + ]); + } + + /// The sequence is exactly the previous plus one: a gap refuses as loudly + /// as a taken sequence, on both sequenced kinds, and before the question + /// of what still stands arises. + #[test] + fn a_sequence_gap_refuses_on_both_sequenced_kinds() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let subject = r(K::Question, &id("question", "Q1")); + committed(admit(&mut mind, vec![resolution(subject.clone(), withdrawn())])); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &id("resolution", "question.Q1.n1")), withdrawn())])); + committed(admit(&mut mind, vec![resolution_n(subject.clone(), 2, withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution_n(subject, 4, withdrawn())])), + MindRefusal::ResolutionOutOfSequence { subject: id("question", "Q1"), expected: 3, actual: 4 } + ); + + let mut steward = seeded(); + committed(admit(&mut steward, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[])])); + let D::HandOff(mut back) = hand_off(OTHER_INSTANCE, INSTANCE, REPO, &[]) else { panic!() }; + back.handed_on = epiphany_pipeline::Date("2026-09-17".into()); + committed(admit(&mut steward, vec![D::HandOff(back)])); + assert_eq!( + refusal(admit(&mut steward, vec![stewardship_n(INSTANCE, REPO, 4)])), + MindRefusal::StewardshipOutOfSequence { repo: REPO.into(), expected: 3, actual: 4 } + ); + } + + /// The sequence counts the batch as well as the image: two records of one + /// subject landing together are the second and the third, never two + /// firsts, and the first of them is refused for the sequence rather than + /// for what the other closes. + #[test] + fn the_latest_resolution_counts_the_batch() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let subject = r(K::Question, &id("question", "Q1")); + assert_eq!( + refusal(admit(&mut mind, vec![ + resolution_n(subject.clone(), 1, withdrawn()), + resolution_n(subject, 2, ResolutionOutcome::Withdrawn { reason: "again".into() }), + ])), + MindRefusal::ResolutionOutOfSequence { subject: id("question", "Q1"), expected: 3, actual: 1 } + ); + } + + /// The Q19 cap reads the batch too: a chain three deep landing in one + /// batch is capped exactly as one landing over three admissions. + #[test] + fn the_cap_reads_a_chain_landing_in_one_batch() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + committed(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![ + resolution(r(K::Resolution, &id("resolution", "question.Q1.n1")), withdrawn()), + resolution(r(K::Resolution, &id("resolution", "resolution.question.Q1.n1.n1")), withdrawn()), + ])), + MindRefusal::IncompatibleResolution { subject_kind: K::Resolution, outcome: "Withdrawn".into() } + ); + } + + /// Q18 A and Q20 A at admission: a repo is stewarded by one record at a + /// time on a mind, and a repo handed away and handed back is a second + /// record rather than a collision or an overwrite. + #[test] + fn a_repo_is_stewarded_once_at_a_time_and_again_after_a_transfer() { + let mut mind = seeded(); + let first = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1"); + assert_eq!( + refusal(admit(&mut mind, vec![stewardship_n(INSTANCE, REPO, 2)])), + MindRefusal::AlreadyStewarded { repo: REPO.into() } + ); + let D::Stewardship(mut retaken) = stewardship(INSTANCE, REPO) else { panic!() }; + retaken.note = "assigned again".into(); + assert_eq!( + refusal(admit(&mut mind, vec![D::Stewardship(retaken)])), + MindRefusal::StewardshipOutOfSequence { repo: REPO.into(), expected: 2, actual: 1 } + ); + + // Handing the repo away withdraws the assignment, and the withdrawal + // is a resolution of that record, sequence and all. The stewardship it + // reads to derive that is an image document, so it is pinned. + let (receipt_id, writes) = committed(admit(&mut mind, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[])])); + let away = format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.gamecult_-epiphany.{}", date().0); + assert_eq!(writes, vec![ + r(K::HandOff, &away), + r(K::Resolution, &format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n1.n1")), + ]); + let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); + let stewardship_envelope = mind.envelope(K::Stewardship, &first).unwrap().clone(); + assert!( + receipt.strong_reads.contains(&DocumentVersion::from_envelope(&stewardship_envelope)), + "the derived withdrawal's own citation is pinned: {:?}", + receipt.strong_reads + ); + + // Nothing stewards the repo now, and `stewardship_of` says so: a new + // campaign over it is refused. + let D::Campaign(mut second) = campaign(&[REPO]) else { panic!() }; + second.slug = slug("second"); + assert_eq!( + refusal(admit(&mut mind, vec![D::Campaign(second)])), + MindRefusal::RepoNotStewarded { repo: REPO.into() } + ); + + // Handed back, the mind stewards it again, as `n2`. No field names a + // return: this is the same hand-off shape with the instances swapped. + let D::HandOff(mut back) = hand_off(OTHER_INSTANCE, INSTANCE, REPO, &[]) else { panic!() }; + back.handed_on = epiphany_pipeline::Date("2026-09-17".into()); + let (_, writes) = committed(admit(&mut mind, vec![D::HandOff(back)])); + let again = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n2"); + assert_eq!(writes, vec![ + r(K::HandOff, &format!("{OTHER_INSTANCE}:hand_off:{INSTANCE}.gamecult_-epiphany.2026-09-17")), + r(K::Stewardship, &again), + ]); + let Some(D::Stewardship(taken)) = mind.get(K::Stewardship, &again).unwrap() else { panic!() }; + assert_eq!(taken.sequence, 2); + assert_eq!(taken.assigned_on, epiphany_pipeline::Date("2026-09-17".into())); + assert!(mind.envelope(K::Stewardship, &first).is_some(), "the first assignment is still readable by key"); + } + + /// Q19 A's rationale, which the cap alone does not carry: a withdrawal + /// never re-raises an earlier record over a later one. The repo went away + /// and came back, so withdrawing the hand-off's withdrawal would put the + /// first assignment back in force beside the second. + #[test] + fn a_withdrawal_does_not_reinstate_a_stewardship_over_a_later_one() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[])])); + let D::HandOff(mut back) = hand_off(OTHER_INSTANCE, INSTANCE, REPO, &[]) else { panic!() }; + back.handed_on = epiphany_pipeline::Date("2026-09-17".into()); + committed(admit(&mut mind, vec![D::HandOff(back)])); + let first = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1"); + let again = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n2"); + let withdrawal = format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n1.n1"); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])), + MindRefusal::WouldReinstateOverLater { subject: first, later: again.clone() } + ); + let docs = Docs::from_image(mind.envelopes()).unwrap(); + let in_force = docs.stewardships_of(&slug(INSTANCE), &repo(REPO), None).iter().map(|(key, _)| *key).collect::>(); + assert_eq!(in_force, vec![again.as_str()], "one assignment stands, and it is the later one"); + } + + /// The same rule one kind over: withdrawing a supersession while the + /// revision that superseded stands would leave two revisions in force. + #[test] + fn a_withdrawal_does_not_reinstate_a_revision_over_a_later_one() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![ + target(2, &[INVARIANT]), + resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, &id("target", "r2"))])), + ])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &id("resolution", "target.r1.n1")), withdrawn())])), + MindRefusal::WouldReinstateOverLater { subject: id("target", "r1"), later: id("target", "r2") } + ); + assert!(docs_of(&mind).in_force(K::Target, &id("target", "r2"))); + assert!(!docs_of(&mind).in_force(K::Target, &id("target", "r1"))); + } + + fn docs_of(mind: &Mind) -> Docs { + Docs::from_image(mind.envelopes()).unwrap() + } + + /// The withdrawal a hand-off derives is a record of its subject like any + /// other: the second time the repo leaves, it takes the next sequence. + #[test] + fn a_hand_offs_derived_withdrawal_takes_the_next_sequence() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[])])); + // Nothing later stands over the first assignment, so withdrawing the + // withdrawal puts it back in force and the repo is this mind's again. + let withdrawal = format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n1.n1"); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])); + let D::HandOff(mut again) = hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[]) else { panic!() }; + again.handed_on = epiphany_pipeline::Date("2026-09-17".into()); + let (_, writes) = committed(admit(&mut mind, vec![D::HandOff(again)])); + assert_eq!(writes, vec![ + r(K::HandOff, &format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.gamecult_-epiphany.2026-09-17")), + r(K::Resolution, &format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n1.n2")), + ]); + } + + fn hand_off_on(from: &str, to: &str, day: &str) -> D { + let D::HandOff(mut hand_off) = hand_off(from, to, REPO, &[]) else { panic!() }; + hand_off.handed_on = epiphany_pipeline::Date(day.into()); + D::HandOff(hand_off) + } + + fn stewardship_key(sequence: u32) -> String { + format!("{INSTANCE}:stewardship:gamecult_-epiphany.n{sequence}") + } + + /// Which assignment a hand-off's derived withdrawal names is a question of + /// content, not of order. While the withdrawal is being derived the + /// hand-off's own is excluded from what stands, so two assignments are in + /// force at once, and after ten transfers the one that stewards the repo + /// now sorts first: `n11` before `n2`. The replay must still withdraw the + /// assignment it withdrew the first time, or it derives a second + /// withdrawal of a standing assignment and collides instead of answering + /// with its receipt. + #[test] + fn a_source_side_replay_withdraws_the_assignment_it_withdrew() { + let mut mind = seeded(); + let mut aways = Vec::new(); + for transfer in 1..=10u32 { + let day = format!("2026-09-{transfer:02}"); + let away = hand_off_on(INSTANCE, OTHER_INSTANCE, &day); + let (receipt_id, _) = committed(admit(&mut mind, vec![away.clone()])); + aways.push((away, receipt_id)); + committed(admit(&mut mind, vec![hand_off_on(OTHER_INSTANCE, INSTANCE, &day)])); + } + let in_force = docs_of(&mind).stewardships_of(&slug(INSTANCE), &repo(REPO), None).iter().map(|(key, _)| key.to_string()).collect::>(); + assert_eq!(in_force, vec![stewardship_key(11)]); + + for index in [1usize, 8] { + let (away, receipt_id) = &aways[index]; + assert_eq!( + admit(&mut mind, vec![away.clone()]), + PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: receipt_id.clone() }, + "the replay of transfer {} re-derives its own withdrawal", + index + 1 + ); + } + + // The standing assignment is handed away and reinstated, so it too + // carries a withdrawal -- of another hand-off. Sorting first, it is + // what a match on the outcome alone would pick. + committed(admit(&mut mind, vec![hand_off_on(INSTANCE, OTHER_INSTANCE, "2026-09-11")])); + let withdrawal = format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n11.n1"); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])); + let (away, receipt_id) = &aways[1]; + assert_eq!( + admit(&mut mind, vec![away.clone()]), + PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: receipt_id.clone() } + ); + } + + /// `later_in_force` counts what stands, not what exists: the repo went + /// away, came back and went away again, so the second assignment is later + /// than the first but withdrawn, and reinstating the first leaves one + /// assignment in force rather than two. + #[test] + fn a_withdrawn_later_assignment_does_not_block_a_reinstatement() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![hand_off_on(INSTANCE, OTHER_INSTANCE, "2026-09-16")])); + committed(admit(&mut mind, vec![hand_off_on(OTHER_INSTANCE, INSTANCE, "2026-09-17")])); + committed(admit(&mut mind, vec![hand_off_on(INSTANCE, OTHER_INSTANCE, "2026-09-18")])); + let withdrawal = format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n1.n1"); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])); + let in_force = docs_of(&mind).stewardships_of(&slug(INSTANCE), &repo(REPO), None).iter().map(|(key, _)| key.to_string()).collect::>(); + assert_eq!(in_force, vec![stewardship_key(1)]); + } + + /// And it reads the batch: the assignment that stands later than the one + /// a withdrawal would reinstate may be landing beside that withdrawal, + /// derived by a hand-off in the same batch. + #[test] + fn a_reinstatement_is_blocked_by_an_assignment_in_its_own_batch() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![hand_off_on(INSTANCE, OTHER_INSTANCE, "2026-09-16")])); + let withdrawal = format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n1.n1"); + assert_eq!( + refusal(admit(&mut mind, vec![ + hand_off_on(OTHER_INSTANCE, INSTANCE, "2026-09-17"), + resolution(r(K::Resolution, &withdrawal), withdrawn()), + ])), + MindRefusal::WouldReinstateOverLater { subject: stewardship_key(1), later: stewardship_key(2) } + ); + } + + /// A cut spec's revisions are sequenced within its cut, so another cut's + /// later revision is not what a withdrawal reads: once cut A's second + /// revision is gone, reinstating its first commits while cut B's second + /// stands. + #[test] + fn a_reinstatement_reads_the_revisions_of_its_own_cut() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("A", 1)), D::CutSpec(cut_spec("B", 1))])); + for cut in ["A", "B"] { + committed(admit(&mut mind, vec![ + D::CutSpec(cut_spec(cut, 2)), + resolution( + r(K::CutSpec, &id("cut_spec", &format!("cut-{cut}.r1"))), + superseded(&[r(K::CutSpec, &id("cut_spec", &format!("cut-{cut}.r2")))]), + ), + ])); + } + let supersession = id("resolution", "cut_spec.cut-A.r1.n1"); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &supersession), withdrawn())])), + MindRefusal::WouldReinstateOverLater { + subject: id("cut_spec", "cut-A.r1"), + later: id("cut_spec", "cut-A.r2"), + } + ); + committed(admit(&mut mind, vec![resolution(r(K::CutSpec, &id("cut_spec", "cut-A.r2")), withdrawn())])); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &supersession), withdrawn())])); + assert!(docs_of(&mind).in_force(K::CutSpec, &id("cut_spec", "cut-A.r1"))); + assert!(docs_of(&mind).in_force(K::CutSpec, &id("cut_spec", "cut-B.r2"))); + } + + /// A seeded mind with one document of every resolvable kind, and the + /// non-resolvable ones beside them. + fn world() -> Mind { + let mut mind = seeded(); + committed(admit(&mut mind, vec![ + stewardship(INSTANCE, OTHER_REPO), + question("Q1", &["A", "B"], "A"), + D::Ruling(ruling("R1")), + D::CutSpec(cut_spec("1", 1)), + D::CutReport(cut_report("1", 1)), + verdict("1", 1, vec![claim(ClaimOutcome::Falsified, &[&id("finding", "cut-1.s1.F1")], Some("P1"), &["M1"])]), + D::Finding(finding("1", 1, "F1", FindingConfidence::Confirmed)), + follow_up("FU-1", r(K::Finding, &id("finding", "cut-1.s1.F1"))), + hand_off(INSTANCE, OTHER_INSTANCE, OTHER_REPO, &[]), + ])); + mind + } + + #[test] + fn the_resolution_matrix_is_admissions() { + let incompatible = |kind: K, outcome: &str| MindRefusal::IncompatibleResolution { subject_kind: kind, outcome: outcome.into() }; + let fixed = || ResolutionOutcome::Fixed { commit: sha(), by: None }; + let target_ref = r(K::Target, &id("target", "r1")); + let target_r2 = r(K::Target, &id("target", "r2")); + let question_ref = r(K::Question, &id("question", "Q1")); + let ruling_ref = r(K::Ruling, &id("ruling", "R1")); + let spec_ref = r(K::CutSpec, &id("cut_spec", "cut-1.r1")); + let finding_ref = r(K::Finding, &id("finding", "cut-1.s1.F1")); + let follow_up_ref = r(K::FollowUp, &id("follow_up", "FU-1")); + let stewardship_ref = r(K::Stewardship, &format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1")); + let campaign_ref = r(K::Campaign, &id("campaign", "self")); + let report_ref = r(K::CutReport, &id("cut_report", "cut-1.h1")); + let verdict_ref = r(K::Verdict, &id("verdict", "cut-1.s1")); + let instance_ref = r(K::Instance, &format!("{INSTANCE}:instance:self")); + let hand_off_ref = r(K::HandOff, &format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.gamecult_-huginn.2026-09-16")); + + // Accepted, one per resolvable kind. + committed(admit(&mut world(), vec![target(2, &[INVARIANT]), resolution(target_ref.clone(), superseded(&[target_r2.clone()]))])); + committed(admit(&mut world(), vec![resolution(question_ref.clone(), withdrawn())])); + committed(admit(&mut world(), vec![D::Ruling(ruling("R2")), resolution(ruling_ref.clone(), superseded(&[r(K::Ruling, &id("ruling", "R2"))]))])); + committed(admit(&mut world(), vec![resolution(spec_ref.clone(), withdrawn())])); + committed(admit(&mut world(), vec![resolution(finding_ref.clone(), ResolutionOutcome::Recorded { reason: "noted".into() })])); + committed(admit(&mut world(), vec![resolution(finding_ref.clone(), ResolutionOutcome::Deferred { to: follow_up_ref.clone() })])); + committed(admit(&mut world(), vec![resolution(follow_up_ref.clone(), fixed())])); + committed(admit(&mut world(), vec![resolution(stewardship_ref.clone(), withdrawn())])); + let mut mind = world(); + committed(admit(&mut mind, vec![resolution(question_ref.clone(), withdrawn())])); + let nested = r(K::Resolution, &id("resolution", "question.Q1.n1")); + committed(admit(&mut mind, vec![resolution(nested.clone(), withdrawn())])); + + // Refused, one per kind, and every non-resolvable kind. + assert_eq!(refusal(admit(&mut world(), vec![resolution(target_ref.clone(), withdrawn())])), incompatible(K::Target, "Withdrawn")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(question_ref.clone(), fixed())])), incompatible(K::Question, "Fixed")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(ruling_ref.clone(), withdrawn())])), incompatible(K::Ruling, "Withdrawn")); + // A kind admits the outcomes of its own row and no others: a question + // is answered or withdrawn, never superseded; a target is superseded, + // never answered. + assert_eq!( + refusal(admit(&mut world(), vec![ + question("Q2", &["A", "B"], "A"), + resolution(question_ref, superseded(&[r(K::Question, &id("question", "Q2"))])), + ])), + incompatible(K::Question, "Superseded") + ); + assert_eq!( + refusal(admit(&mut world(), vec![resolution(target_ref, ResolutionOutcome::Answered { by: ruling_ref })])), + incompatible(K::Target, "Answered") + ); + assert_eq!( + refusal(admit(&mut world(), vec![resolution(spec_ref.clone(), ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) })])), + incompatible(K::CutSpec, "Answered") + ); + // A cut spec superseded across cuts is refused too. + let mut other_cut = cut_spec("2", 1); + other_cut.cut = l("2"); + assert_eq!( + refusal(admit(&mut world(), vec![D::CutSpec(other_cut), resolution(spec_ref, superseded(&[r(K::CutSpec, &id("cut_spec", "cut-2.r1"))]))])), + incompatible(K::CutSpec, "Superseded") + ); + assert_eq!( + refusal(admit(&mut world(), vec![resolution(finding_ref.clone(), superseded(&[finding_ref.clone()]))])), + incompatible(K::Finding, "Superseded") + ); + assert_eq!( + refusal(admit(&mut world(), vec![resolution(follow_up_ref, ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) })])), + incompatible(K::FollowUp, "Answered") + ); + assert_eq!( + refusal(admit(&mut world(), vec![resolution(stewardship_ref, ResolutionOutcome::Recorded { reason: "no".into() })])), + incompatible(K::Stewardship, "Recorded") + ); + let mut mind = world(); + committed(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(nested.clone(), superseded(&[nested]))])), + incompatible(K::Resolution, "Superseded") + ); + for (subject, kind) in [(campaign_ref, K::Campaign), (report_ref, K::CutReport), (verdict_ref, K::Verdict), (instance_ref, K::Instance), (hand_off_ref, K::HandOff)] { + assert_eq!(refusal(admit(&mut world(), vec![resolution(subject, withdrawn())])), incompatible(kind, "Withdrawn")); + } + } + + #[test] + fn supersession_names_each_supersessor_and_none_is_empty() { + let subject = r(K::Ruling, &id("ruling", "R1")); + assert_eq!(refusal(admit(&mut world(), vec![resolution(subject.clone(), superseded(&[]))])), MindRefusal::EmptySupersession); + let (_, writes) = committed(admit(&mut world(), vec![ + D::Ruling(ruling("R2")), + D::Ruling(ruling("R3")), + resolution(subject.clone(), superseded(&[r(K::Ruling, &id("ruling", "R2")), r(K::Ruling, &id("ruling", "R3"))])), + ])); + assert_eq!(writes.len(), 3); + assert_eq!( + refusal(admit(&mut world(), vec![D::Ruling(ruling("R2")), resolution(subject.clone(), superseded(&[r(K::Ruling, &id("ruling", "R2")), r(K::Ruling, &id("ruling", "R4"))]))])), + MindRefusal::UnknownSupersessor { id: id("ruling", "R4") } + ); + let mut mind = world(); + committed(admit(&mut mind, vec![D::Ruling(ruling("R2")), D::Ruling(ruling("R3")), resolution(r(K::Ruling, &id("ruling", "R2")), superseded(&[r(K::Ruling, &id("ruling", "R3"))]))])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(subject, superseded(&[r(K::Ruling, &id("ruling", "R2"))]))])), + MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: id("ruling", "R2") } + ); + } + + #[test] + fn a_ruling_answering_a_question_derives_the_answered_resolution_atomically() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let mut answering = ruling("R1"); + answering.answers = Some(s(&id("question", "Q1"))); + answering.choice = Some(l("C")); + assert_eq!( + refusal(admit(&mut mind, vec![D::Ruling(answering.clone())])), + MindRefusal::InvalidChoice { ruling: id("ruling", "R1"), choice: "C".into() } + ); + answering.choice = Some(l("A")); + let (receipt_id, writes) = committed(admit(&mut mind, vec![D::Ruling(answering)])); + assert_eq!(writes, vec![r(K::Ruling, &id("ruling", "R1")), r(K::Resolution, &id("resolution", "question.Q1.n1"))]); + let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); + assert_eq!(receipt.writes.len(), 2); + let Some(D::Resolution(derived)) = mind.get(K::Resolution, &id("resolution", "question.Q1.n1")).unwrap() else { panic!() }; + assert_eq!(derived.outcome, ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) }); + let mut again = ruling("R2"); + again.answers = Some(s(&id("question", "Q1"))); + again.choice = Some(l("B")); + assert_eq!(refusal(admit(&mut mind, vec![D::Ruling(again)])), MindRefusal::AlreadyResolved { subject: id("question", "Q1") }); + + // An explicit `Answered` must name the ruling that actually answers + // its subject: a ruling answering Q1 does not resolve Q2. + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A"), question("Q2", &["A", "B"], "A")])); + let mut answering = ruling("R1"); + answering.answers = Some(s(&id("question", "Q1"))); + answering.choice = Some(l("A")); + let wrong = resolution(r(K::Question, &id("question", "Q2")), ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) }); + assert_eq!( + refusal(admit(&mut mind, vec![D::Ruling(answering.clone()), wrong.clone()])), + MindRefusal::IncompatibleResolution { subject_kind: K::Question, outcome: "Answered".into() } + ); + // The coherence is checked whether the ruling is in the batch or in + // the image: a later batch citing the stored R1 is refused the same + // way, and the derived resolution of Q1 is not what saves it. + committed(admit(&mut mind, vec![D::Ruling(answering)])); + assert_eq!( + refusal(admit(&mut mind, vec![wrong])), + MindRefusal::IncompatibleResolution { subject_kind: K::Question, outcome: "Answered".into() } + ); + } + + #[test] + fn a_batch_is_one_to_sixty_four_documents_each_with_its_own_identity() { + let mut mind = seeded(); + // A2 at both ends, and at the boundary the batch is admitted whole. + assert_eq!(refusal(admit(&mut mind, vec![])), MindRefusal::BatchSize { actual: 0 }); + let over = (0..65).map(|index| question(&format!("Q{index}"), &["A", "B"], "A")).collect::>(); + assert_eq!(refusal(admit(&mut mind, over)), MindRefusal::BatchSize { actual: 65 }); + let full = (0..BATCH_MAX).map(|index| question(&format!("Q{index}"), &["A", "B"], "A")).collect::>(); + let (_, writes) = committed(admit(&mut mind, full)); + assert_eq!(writes.len(), BATCH_MAX); + // A4: one identity, once, whatever the two documents say. + assert_eq!( + refusal(admit(&mut mind, vec![question("Z", &["A", "B"], "A"), question("Z", &["A", "B"], "B")])), + MindRefusal::IdentityCollision { kind: K::Question, id: id("question", "Z") } + ); + } + + #[test] + fn a_question_offers_at_least_two_options_and_recommends_one_of_them() { + let mut mind = seeded(); + assert_eq!( + refusal(admit(&mut mind, vec![question("Q7", &["A"], "A")])), + MindRefusal::InvalidOptions { question: id("question", "Q7") } + ); + assert_eq!( + refusal(admit(&mut mind, vec![question("Q8", &["A", "B"], "C")])), + MindRefusal::InvalidOptions { question: id("question", "Q8") } + ); + committed(admit(&mut mind, vec![question("Q9", &["A", "B"], "B")])); + } + + #[test] + fn a_label_names_one_option_and_one_invariant() { + let mut mind = seeded(); + assert_eq!( + refusal(admit(&mut mind, vec![question("Q1", &["A", "A"], "A")])), + MindRefusal::DuplicateLabel { field: "question.options".into(), label: "A".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![ + target(2, &["x", "x"]), + resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, &id("target", "r2"))])), + ])), + MindRefusal::DuplicateLabel { field: "target.invariants".into(), label: "x".into() } + ); + } + + /// Every A7 reference the image resolved is pinned, not the first of + /// them, so a batch citing three documents cannot commit over a change to + /// the other two. + #[test] + fn every_cited_image_document_is_a_strong_read() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::Ruling(ruling("R1")), D::Ruling(ruling("R2")), D::Ruling(ruling("R3"))])); + let cited = ["R1", "R2", "R3"].map(|label| mind.envelope(K::Ruling, &id("ruling", label)).unwrap().clone()); + let mut spec = cut_spec("1", 1); + spec.rulings = cited.iter().map(|envelope| s(&envelope.key)).collect(); + let (receipt_id, _) = committed(admit(&mut mind, vec![D::CutSpec(spec)])); + let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); + assert_eq!(receipt.strong_reads.len(), 3); + for envelope in &cited { + assert!(receipt.strong_reads.contains(&DocumentVersion::from_envelope(envelope)), "{}", envelope.key); + } + + // Two batch documents citing one image document pin it once: the pins + // are a set, so a second citation neither duplicates the pin nor + // cancels it. + let mut twice = cut_spec("2", 1); + twice.rulings = vec![s(&id("ruling", "R1"))]; + let D::Question(mut raised) = question("Q1", &["A", "B"], "A") else { panic!() }; + raised.raised_in = Some(r(K::Ruling, &id("ruling", "R1"))); + let (receipt_id, _) = committed(admit(&mut mind, vec![D::CutSpec(twice), D::Question(raised)])); + let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); + assert_eq!(receipt.strong_reads, vec![DocumentVersion::from_envelope(&cited[0])], "cited twice, pinned once"); + } + + #[test] + fn an_operator_quote_requires_operator_authority() { + let mut mind = seeded(); + let mut quoted = ruling("R1"); + quoted.operator_quote = Some("all recommendations, go ahead".into()); + assert_eq!(refusal(admit(&mut mind, vec![D::Ruling(quoted.clone())])), MindRefusal::QuoteWithoutOperator { ruling: id("ruling", "R1") }); + quoted.authority = RulingAuthority::Operator; + committed(admit(&mut mind, vec![D::Ruling(quoted)])); + } + + #[test] + fn a_revision_requires_its_predecessors_supersession_in_the_batch() { + let mut mind = seeded(); + assert_eq!( + refusal(admit(&mut mind, vec![target(2, &[INVARIANT])])), + MindRefusal::RevisionWithoutSupersession { kind: K::Target, revision: 2 } + ); + committed(admit(&mut mind, vec![ + target(2, &[INVARIANT]), + resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, &id("target", "r2"))])), + ])); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1))])); + assert_eq!( + refusal(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 2))])), + MindRefusal::RevisionWithoutSupersession { kind: K::CutSpec, revision: 2 } + ); + committed(admit(&mut mind, vec![ + D::CutSpec(cut_spec("1", 2)), + resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), superseded(&[r(K::CutSpec, &id("cut_spec", "cut-1.r2"))])), + ])); + } + + #[test] + fn a_cut_report_cites_an_in_force_spec_and_agrees_with_it() { + let mut mind = seeded(); + assert_eq!( + refusal(admit(&mut mind, vec![D::CutReport(cut_report("1", 1))])), + MindRefusal::CutReportWithoutSpec { report: id("cut_report", "cut-1.h1") } + ); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1))])); + let mut report = cut_report("1", 1); + report.branch = s("another-branch"); + assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::SpecMismatch { field: "branch".into() }); + // A branch is compared as written. Git's refs are case-sensitive, and + // a report of `Codex/...` is a report of another branch or of none. + let mut report = cut_report("1", 1); + report.branch = s("CODEX/eureka-pipeline-state"); + assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::SpecMismatch { field: "branch".into() }); + let mut report = cut_report("1", 1); + report.repo = repo(OTHER_REPO); + assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::SpecMismatch { field: "repo".into() }); + let mut report = cut_report("1", 1); + report.range.head = epiphany_pipeline::Sha("abcdef0".into()); + assert_eq!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::RangeOutsideCommits { head: "abcdef0".into() }); + committed(admit(&mut mind, vec![resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![D::CutReport(cut_report("1", 1))])), + MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: id("cut_spec", "cut-1.r1") } + ); + } + + #[test] + fn verdict_vocabulary_binds_claims_to_findings_promises_and_mutations() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1)), D::CutReport(cut_report("1", 1))])); + let finding_id = id("finding", "cut-1.s1.F1"); + let confirmed = D::Finding(finding("1", 1, "F1", FindingConfidence::Confirmed)); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Falsified, &[], Some("P1"), &[])])])), + MindRefusal::FalsifiedClaimWithoutConfirmedFinding { claim: "Falsified claim".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![ + verdict("1", 1, vec![claim(ClaimOutcome::Unproven, &[&finding_id], Some("P1"), &[])]), + confirmed.clone(), + ])), + MindRefusal::UnprovenClaimWithConfirmedFinding { claim: "Unproven claim".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], None, &[])])])), + MindRefusal::PromiseWithoutVerdict { promise: "P1".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![ + claim(ClaimOutcome::Holds, &[], Some("P1"), &[]), + claim(ClaimOutcome::Holds, &[], Some("P1"), &[]), + ])])), + MindRefusal::PromiseWithoutVerdict { promise: "P1".into() } + ); + // A promise is measured by the claim that names it, not by any claim + // that names a promise: the report promises P1 and nothing else. + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], Some("P2"), &[])])])), + MindRefusal::PromiseWithoutVerdict { promise: "P1".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], Some("P1"), &["M9"])])])), + MindRefusal::UnknownMutationLabel { label: "M9".into() } + ); + committed(admit(&mut mind, vec![ + verdict("1", 1, vec![claim(ClaimOutcome::Falsified, &[&finding_id], Some("P1"), &["M1"])]), + confirmed, + ])); + } + + #[test] + fn a_finding_names_evidence_locations_and_known_invariants() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![ + D::CutSpec(cut_spec("1", 1)), + D::CutReport(cut_report("1", 1)), + verdict("1", 1, vec![claim(ClaimOutcome::Holds, &[], Some("P1"), &[])]), + ])); + let mut bare = finding("1", 1, "F1", FindingConfidence::Plausible); + bare.evidence = vec![]; + assert_eq!(refusal(admit(&mut mind, vec![D::Finding(bare)])), MindRefusal::FindingWithoutEvidence); + // Locations are evidence's other half: a finding that names no place + // in the code is refused the same way. + let mut placeless = finding("1", 1, "F1", FindingConfidence::Plausible); + placeless.locations = vec![]; + assert_eq!(refusal(admit(&mut mind, vec![D::Finding(placeless)])), MindRefusal::FindingWithoutEvidence); + let mut unknown = finding("1", 1, "F1", FindingConfidence::Plausible); + unknown.invariants = vec![l("nope")]; + assert_eq!(refusal(admit(&mut mind, vec![D::Finding(unknown)])), MindRefusal::UnknownInvariant { label: "nope".into() }); + + + // A raw envelope without `range` cannot be built from the type, and + // it is the leaf's decode that refuses it, before any organ rule. + #[derive(serde::Serialize)] + struct Rangeless { + campaign: Slug, + verdict: Short, + label: epiphany_pipeline::Label, + confidence: FindingConfidence, + severity: epiphany_pipeline::FindingSeverity, + claim: Line, + invariants: Vec, + locations: Vec, + failure_scenario: epiphany_pipeline::Para, + evidence: Vec, + precedents: Vec, + origin: epiphany_pipeline::FindingOrigin, + } + let whole = finding("1", 1, "F1", FindingConfidence::Plausible); + let rangeless = Rangeless { + campaign: whole.campaign.clone(), + verdict: whole.verdict.clone(), + label: whole.label.clone(), + confidence: whole.confidence, + severity: whole.severity, + claim: whole.claim.clone(), + invariants: whole.invariants.clone(), + locations: whole.locations.clone(), + failure_scenario: whole.failure_scenario.clone(), + evidence: whole.evidence.clone(), + precedents: vec![], + origin: whole.origin, + }; + let mut envelope = prepare(&D::Finding(whole)); + envelope.payload = rmp_serde::to_vec_named(&(rangeless,)).unwrap(); + let outcome = mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Soul), vec![envelope], now()); + assert!( + matches!(refusal(outcome), MindRefusal::Document(PipelineRefusal::InvalidFormat { field, .. }) if field == "payload") + ); + + // The in-force target owns the invariant vocabulary. Supersede r1 with + // an r2 that drops its label, and the label is unknown again; r2's own + // label is what a finding may cite. The target is looked for in image + // and batch, like every other citation, so a finding may cite the + // vocabulary of the revision landing beside it: `other` is admitted in + // r2's own batch, before r2 is anywhere but here. + let mut fresh = finding("1", 1, "F4", FindingConfidence::Plausible); + fresh.invariants = vec![l("other")]; + assert_eq!( + refusal(admit(&mut mind, vec![D::Finding(fresh.clone())])), + MindRefusal::UnknownInvariant { label: "other".into() } + ); + committed(admit(&mut mind, vec![ + target(2, &["other"]), + resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, &id("target", "r2"))])), + D::Finding(fresh), + ])); + assert_eq!( + refusal(admit(&mut mind, vec![D::Finding(finding("1", 1, "F2", FindingConfidence::Plausible))])), + MindRefusal::UnknownInvariant { label: INVARIANT.into() } + ); + let mut current = finding("1", 1, "F3", FindingConfidence::Plausible); + current.invariants = vec![l("other")]; + committed(admit(&mut mind, vec![D::Finding(current)])); + } + + #[test] + fn a_campaign_names_only_repos_this_mind_stewards() { + let mut mind = opened(MemoryStore::new(), INSTANCE); + assert_eq!( + refusal(admit(&mut mind, vec![instance(INSTANCE), campaign(&[REPO])])), + MindRefusal::RepoNotStewarded { repo: REPO.into() } + ); + committed(admit(&mut mind, vec![instance(INSTANCE), stewardship(INSTANCE, REPO), campaign(&[REPO])])); + // A campaign of no repos is the organ's refusal, not a leaf bound: + // "every repo is stewarded" is vacuous and the campaign steers + // nothing. + let D::Campaign(mut empty) = campaign(&[]) else { panic!() }; + empty.slug = slug("other"); + assert_eq!( + refusal(admit(&mut mind, vec![D::Campaign(empty)])), + MindRefusal::EmptyRepos { campaign: "other:campaign:self".into() } + ); + // The stewardship may be held in the image: a later campaign over the + // same repo carries no stewardship of its own. + let D::Campaign(mut second) = campaign(&[REPO]) else { panic!() }; + second.slug = slug("second"); + committed(admit(&mut mind, vec![D::Campaign(second)])); + let mut spec = cut_spec("1", 1); + spec.repo = repo(OTHER_REPO); + assert_eq!(refusal(admit(&mut mind, vec![D::CutSpec(spec)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() }); + } + + #[test] + fn a_hand_off_derives_this_minds_side_only() { + let key = format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.gamecult_-epiphany.2026-09-16"); + let campaign_key = id("campaign", "self"); + let stewardship_key = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1"); + + let mut source = seeded(); + assert_eq!( + refusal(admit(&mut source, vec![hand_off(INSTANCE, OTHER_INSTANCE, OTHER_REPO, &[])])), + MindRefusal::NotStewarded { repo: OTHER_REPO.into() } + ); + assert_eq!( + refusal(admit(&mut source, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[&id("ruling", "R1")])])), + MindRefusal::MissingReference { kind: K::Ruling, id: id("ruling", "R1") } + ); + let (_, writes) = committed(admit(&mut source, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[&campaign_key])])); + let withdrawal = id_of(&stewardship_key); + assert_eq!(writes, vec![r(K::HandOff, &key), r(K::Resolution, &withdrawal)]); + let Some(D::Resolution(derived)) = source.get(K::Resolution, &withdrawal).unwrap() else { panic!() }; + assert_eq!(derived.outcome, ResolutionOutcome::Withdrawn { reason: Line(key.clone()) }); + assert_eq!(derived.subject, r(K::Stewardship, &stewardship_key)); + + let mut receiving = opened(MemoryStore::new(), OTHER_INSTANCE); + committed(admit(&mut receiving, vec![instance(OTHER_INSTANCE)])); + let (_, writes) = committed(admit(&mut receiving, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[&campaign_key])])); + let assigned = format!("{OTHER_INSTANCE}:stewardship:gamecult_-epiphany.n1"); + assert_eq!(writes, vec![r(K::HandOff, &key), r(K::Stewardship, &assigned)]); + let Some(D::Stewardship(derived)) = receiving.get(K::Stewardship, &assigned).unwrap() else { panic!() }; + assert_eq!(derived.note, Line(key.clone())); + assert_eq!(derived.instance, slug(OTHER_INSTANCE)); + // The stewardship starts the day the repo was handed over, not on some + // clock of admission's own. + assert_eq!(derived.assigned_on, date()); + assert_eq!(source.envelope(K::HandOff, &key).map(|e| &e.payload), receiving.envelope(K::HandOff, &key).map(|e| &e.payload)); + + // A derived write passes A3-A7 and A8 like any other, and it counts as + // a record of its scope. A batch carrying its own assignment of the + // repo beside the hand-off is two assignments: the derived one takes + // the sequence after the batch's, and the batch's own is then out of + // sequence -- a typed refusal, not a duplicate identity the store + // complains about. + let mut colliding = opened(MemoryStore::new(), OTHER_INSTANCE); + committed(admit(&mut colliding, vec![instance(OTHER_INSTANCE)])); + assert_eq!( + refusal(admit(&mut colliding, vec![ + hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[]), + stewardship(OTHER_INSTANCE, REPO), + ])), + MindRefusal::StewardshipOutOfSequence { repo: REPO.into(), expected: 3, actual: 1 } + ); + } + + /// The key of the first resolution of a stewardship: the subject's root, + /// then its kind and local -- the subject's own sequence included -- then + /// this resolution's own sequence. + fn id_of(stewardship_key: &str) -> String { + let (root, rest) = stewardship_key.split_once(':').unwrap(); + format!("{root}:resolution:{}.n1", rest.replace(':', ".")) + } + + /// The second construction site of a receipt, deliberately: a receipt + /// planted through the store must be found by replay and must match. + #[test] + fn a_planted_receipt_with_other_content_is_refused_not_replayed() { + let store = MemoryStore::new(); + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + let envelopes = vec![prepare(&question("Q1", &["A", "B"], "A"))]; + let ordinal = receipt::head(&mind).unwrap() + 1; + let candidate = + receipt::candidate(&slug(INSTANCE), provenance(Faculty::Hands), &[], &envelopes, ordinal, now()).unwrap(); + let mut planted = HuginnCommitReceipt { writes: vec![], ..candidate.clone() }; + planted.writes = candidate.strong_reads.clone(); + planted.receipt_id = candidate.receipt_id.clone(); + store.plant(mind.cache().prepare_entry_named(&planted.receipt_id, &planted).unwrap().0); + let mut mind = opened(store, INSTANCE); + assert!(matches!( + refusal(mind.admit_prepared(&slug(INSTANCE), provenance(Faculty::Hands), envelopes, now())), + MindRefusal::Unavailable { .. } + )); + } +} diff --git a/crates/huginn-mind/src/docs.rs b/crates/huginn-mind/src/docs.rs new file mode 100644 index 0000000..ef4d723 --- /dev/null +++ b/crates/huginn-mind/src/docs.rs @@ -0,0 +1,443 @@ +//! The image and the batch, the two places a rule or a view may look, and +//! every derivation over them. "In force" is recursive: a document is in force +//! when no resolution that is itself in force names it, so a withdrawn +//! resolution stops closing its subject. Computed over image and batch at rule +//! time, over the image at read time, never stored, and never from +//! `stored_at`. + +use cultcache_rs::CultCacheEnvelope; +use epiphany_pipeline::{ + OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, PipelineStewardship, ResolutionOutcome, + Short, Slug, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::refusal::MindRefusal; + +/// A batch document after A3 and A5: decoded, keyed, and its envelope. +pub(crate) struct Staged { + pub(crate) kind: PipelineKind, + pub(crate) key: String, + pub(crate) document: PipelineDocument, + pub(crate) envelope: CultCacheEnvelope, +} + +/// An image document, decoded. +pub(crate) struct Held { + pub(crate) kind: PipelineKind, + pub(crate) key: String, + pub(crate) document: PipelineDocument, +} + +/// The image and the batch, the two places a rule may look. +pub(crate) struct Docs { + pub(crate) image: Vec, + pub(crate) batch: Vec, +} + +pub(crate) fn kind_of_type(type_id: &str) -> Option { + PipelineKind::ALL.iter().copied().find(|kind| kind.type_id() == type_id) +} + +impl Docs { + pub(crate) fn from_image(envelopes: &[CultCacheEnvelope]) -> Result { + let mut image = Vec::new(); + for envelope in envelopes { + let Some(kind) = kind_of_type(&envelope.r#type) else { continue }; + let document = PipelineDocument::decode(envelope).map_err(|refusal| MindRefusal::Unavailable { + detail: format!("stored document {} does not decode: {refusal}", envelope.key), + })?; + image.push(Held { kind, key: envelope.key.clone(), document }); + } + Ok(Self { image, batch: Vec::new() }) + } + + /// A4: one identity, once. + pub(crate) fn push(&mut self, staged: Staged) -> Result<(), MindRefusal> { + if self.batch.iter().any(|other| other.kind == staged.kind && other.key == staged.key) { + return Err(MindRefusal::IdentityCollision { kind: staged.kind, id: staged.key }); + } + self.batch.push(staged); + Ok(()) + } + + pub(crate) fn in_batch(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { + self.batch.iter().find(|staged| staged.kind == kind && staged.key == id).map(|staged| &staged.document) + } + + pub(crate) fn in_image(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { + self.image.iter().find(|held| held.kind == kind && held.key == id).map(|held| &held.document) + } + + pub(crate) fn find(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> { + self.in_batch(kind, id).or_else(|| self.in_image(kind, id)) + } + + pub(crate) fn of_kind(&self, kind: PipelineKind) -> impl Iterator { + let batch = self.batch.iter().filter(move |staged| staged.kind == kind).map(|staged| (staged.key.as_str(), &staged.document)); + let image = self.image.iter().filter(move |held| held.kind == kind).map(|held| (held.key.as_str(), &held.document)); + batch.chain(image) + } + + pub(crate) fn resolutions(&self) -> impl Iterator { + self.of_kind(PipelineKind::Resolution).filter_map(|(key, document)| match document { + PipelineDocument::Resolution(resolution) => Some((key, resolution)), + _ => None, + }) + } + + pub(crate) fn stewardships(&self) -> impl Iterator { + self.of_kind(PipelineKind::Stewardship).filter_map(|(key, document)| match document { + PipelineDocument::Stewardship(stewardship) => Some((key, stewardship)), + _ => None, + }) + } + + /// Every resolution of one subject, in image and batch: the scope a + /// subject's sequence counts in. + pub(crate) fn resolutions_of(&self, subject: &PipelineRef) -> impl Iterator { + self.resolutions().filter(move |(_, resolution)| resolution.subject == *subject) + } + + /// Every assignment of one repo to one mind, in image and batch, in force + /// or not: the scope a stewardship's sequence counts in. + pub(crate) fn assignments_of(&self, mind: &Slug, repo: &OrgRepo) -> impl Iterator { + self.stewardships().filter(move |(_, stewardship)| stewardship.instance == *mind && stewardship.repo == *repo) + } + + /// The greatest sequence among a subject's resolutions in image or batch, + /// `0` when it has none, so the next is `latest + 1`. `own` is the + /// document being checked, excluded by content identity so that an exact + /// replay measures the same `latest` the first admission did. + pub(crate) fn latest_resolution(&self, subject: &PipelineRef, own: Option<&PipelineResolution>) -> u32 { + self.resolutions_of(subject) + .filter(|(_, resolution)| own != Some(*resolution)) + .map(|(_, resolution)| resolution.sequence) + .max() + .unwrap_or(0) + } + + /// The sequence a derivation takes: the one the record it already derived + /// carries, when image or batch holds that record, so an exact replay + /// re-derives the same document and A9 answers with the stored receipt; + /// otherwise the next. A derivation is recognised by what it writes into + /// its record: a resolution by its outcome, an assignment by its note. + pub(crate) fn derived_resolution_sequence( + &self, + subject: &PipelineRef, + derived: impl Fn(&ResolutionOutcome) -> bool, + ) -> u32 { + self.resolutions() + .find(|(_, resolution)| resolution.subject == *subject && derived(&resolution.outcome)) + .map_or_else(|| self.latest_resolution(subject, None) + 1, |(_, resolution)| resolution.sequence) + } + + /// The same for the assignment a hand-off derives, which notes its key. + pub(crate) fn derived_stewardship_sequence(&self, mind: &Slug, repo: &OrgRepo, note: &str) -> u32 { + self.stewardships() + .find(|(_, stewardship)| stewardship.instance == *mind && stewardship.repo == *repo && stewardship.note.0 == note) + .map_or_else(|| self.latest_stewardship(mind, repo, None) + 1, |(_, stewardship)| stewardship.sequence) + } + + /// The same for a repo's assignments to one mind. + pub(crate) fn latest_stewardship(&self, mind: &Slug, repo: &OrgRepo, own: Option<&PipelineStewardship>) -> u32 { + self.assignments_of(mind, repo) + .filter(|(_, stewardship)| own != Some(*stewardship)) + .map(|(_, stewardship)| stewardship.sequence) + .max() + .unwrap_or(0) + } + + /// No resolution that is itself in force names the document. The + /// recursion is the whole rule: a withdrawn resolution stops counting, so + /// its subject is open again. `key(R)` is strictly longer than the id it + /// resolves, so the chain is bounded and this terminates. + pub(crate) fn in_force(&self, kind: PipelineKind, id: &str) -> bool { + self.in_force_unless(kind, id, |_| false) + } + + /// In force, ignoring a resolution the caller's own document is or + /// derives: the ruling that answers a question is what resolves it, the + /// hand-off that withdraws a stewardship is what resolves that, and a + /// resolution does not resolve its own subject out from under itself. + pub(crate) fn in_force_unless(&self, kind: PipelineKind, id: &str, own: impl Fn(&PipelineResolution) -> bool) -> bool { + self.closing_resolution_unless(kind, id, own).is_none() + } + + /// The in-force resolution naming the document, other than `own`, if any; + /// exact rather than a tie-break because A8 and the Q19 cap admit at most + /// one. + pub(crate) fn closing_resolution_unless( + &self, + kind: PipelineKind, + id: &str, + own: impl Fn(&PipelineResolution) -> bool, + ) -> Option<(&str, &PipelineResolution)> { + self.resolutions().find(|&(key, resolution)| { + resolution.subject.kind == kind + && resolution.subject.id.0 == id + && !own(resolution) + && self.in_force(PipelineKind::Resolution, key) + }) + } + + /// The resolution that closes the document, if any: the views' status, + /// derived where admission derives whether there is one at all. + pub(crate) fn closing_resolution(&self, kind: PipelineKind, id: &str) -> Option<(&str, &PipelineResolution)> { + self.closing_resolution_unless(kind, id, |_| false) + } + + /// Every in-force stewardship of `(mind, repo)`, ignoring the withdrawal + /// `hand_off_key` derives. The stewardship row holds one in force at a + /// time, but that exclusion can put a second back in the list: the one a + /// replayed hand-off withdrew, beside whatever stewards the repo now. + pub(crate) fn stewardships_of( + &self, + mind: &Slug, + repo: &OrgRepo, + hand_off_key: Option<&str>, + ) -> Vec<(&str, &PipelineStewardship)> { + self.assignments_of(mind, repo) + .filter(|(key, _)| { + self.in_force_unless(PipelineKind::Stewardship, key, |resolution| { + matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if Some(reason.0.as_str()) == hand_off_key) + }) + }) + .collect() + } + + /// The in-force record that stands later than `base` in the scope `base` + /// is sequenced in, if any: what a withdrawal that put `base` back in + /// force would leave standing beside it. + pub(crate) fn later_in_force(&self, base: &PipelineDocument) -> Option<&str> { + let kind = base.kind(); + self.of_kind(kind).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key) + } + + /// The stewardship of `(mind, repo)` a hand-off acts on: the one whose + /// withdrawal already carries `hand_off_key`, when image or batch holds + /// that withdrawal, so a replay derives the same withdrawal again and A9 + /// answers with the stored receipt; otherwise the one in force. Key order + /// cannot pick between the two the exclusion leaves standing -- once a + /// repo has changed hands ten times, `n11` sorts before `n2` -- so the + /// content match picks, as it does for the sequence the other derivations + /// take. + pub(crate) fn stewardship_of( + &self, + mind: &Slug, + repo: &OrgRepo, + hand_off_key: Option<&str>, + ) -> Option<(&str, &PipelineStewardship)> { + let candidates = self.stewardships_of(mind, repo, hand_off_key); + hand_off_key + .and_then(|hand_off| { + candidates.iter().find(|(key, _)| { + self.resolutions().any(|(_, resolution)| { + resolution.subject.kind == PipelineKind::Stewardship + && resolution.subject.id.0 == *key + && matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == hand_off) + }) + }) + }) + .or_else(|| candidates.first()) + .copied() + } +} + +/// Whether `other` is a later record than `base` in the scope `base` is +/// sequenced in: `(instance, repo)` for a stewardship, the campaign for a +/// target and the cut for a cut spec. Kinds that carry no sequence have no +/// scope and no order, and a resolution is never a base here: the cap above +/// refuses the withdrawal that would reinstate one before the question of a +/// later record arises. +fn later_than(base: &PipelineDocument, other: &PipelineDocument) -> bool { + use PipelineDocument as D; + match (base, other) { + (D::Stewardship(base), D::Stewardship(other)) => { + other.instance == base.instance && other.repo == base.repo && other.sequence > base.sequence + } + (D::Target(base), D::Target(other)) => other.campaign == base.campaign && other.revision > base.revision, + (D::CutSpec(base), D::CutSpec(other)) => { + other.campaign == base.campaign && other.cut == base.cut && other.revision > base.revision + } + _ => false, + } +} + +/// The referring field of a citation edge, one per leaf field that names +/// another document. The wire spelling is the field's own name, so a client +/// reads an edge's role as the field it came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CitationRole { + RaisedIn, + Answers, + Rulings, + Questions, + CutSpec, + Forks, + CutReport, + Findings, + Verdict, + Source, + Subject, + SupersededBy, + ResolvedBy, + DeferredTo, + Documents, +} + +impl CitationRole { + pub const ALL: [CitationRole; 15] = [ + Self::RaisedIn, + Self::Answers, + Self::Rulings, + Self::Questions, + Self::CutSpec, + Self::Forks, + Self::CutReport, + Self::Findings, + Self::Verdict, + Self::Source, + Self::Subject, + Self::SupersededBy, + Self::ResolvedBy, + Self::DeferredTo, + Self::Documents, + ]; + + /// The wire spelling, equal to the referring field's name. + pub fn name(self) -> &'static str { + match self { + Self::RaisedIn => "raised_in", + Self::Answers => "answers", + Self::Rulings => "rulings", + Self::Questions => "questions", + Self::CutSpec => "cut_spec", + Self::Forks => "forks", + Self::CutReport => "cut_report", + Self::Findings => "findings", + Self::Verdict => "verdict", + Self::Source => "source", + Self::Subject => "subject", + Self::SupersededBy => "superseded_by", + Self::ResolvedBy => "resolved_by", + Self::DeferredTo => "deferred_to", + Self::Documents => "documents", + } + } + + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|role| role.name() == name) + } + + /// The kind whose documents carry this field. + pub fn carrier(self) -> PipelineKind { + use PipelineKind as K; + match self { + Self::RaisedIn => K::Question, + Self::Answers => K::Ruling, + Self::Rulings | Self::Questions => K::CutSpec, + Self::CutSpec | Self::Forks => K::CutReport, + Self::CutReport | Self::Findings => K::Verdict, + Self::Verdict => K::Finding, + Self::Source => K::FollowUp, + Self::Subject | Self::SupersededBy | Self::ResolvedBy | Self::DeferredTo => K::Resolution, + Self::Documents => K::HandOff, + } + } + + /// The kinds a referent of this field may be: the one kind the field is + /// typed to, or every kind for the fields typed `PipelineRef` or a full + /// id of any kind. "Any" is spelled as all thirteen, never as none. + pub fn target_kinds(self) -> Vec { + use PipelineKind as K; + match self { + Self::Answers | Self::Questions | Self::Forks => vec![K::Question], + Self::Rulings => vec![K::Ruling], + Self::CutSpec => vec![K::CutSpec], + Self::CutReport => vec![K::CutReport], + Self::Findings => vec![K::Finding], + Self::Verdict => vec![K::Verdict], + Self::RaisedIn + | Self::Source + | Self::Subject + | Self::SupersededBy + | Self::ResolvedBy + | Self::DeferredTo + | Self::Documents => PipelineKind::ALL.to_vec(), + } + } +} + +/// The kind segment of a full id, for the fields typed `Short` whose referent +/// may be of any kind. +pub(crate) fn kind_of_id(id: &str) -> Option { + let name = id.split(':').nth(1)?; + PipelineKind::ALL.iter().copied().find(|kind| kind.name() == name) +} + +fn cited(kind: PipelineKind, id: &Short) -> PipelineRef { + PipelineRef { kind, id: id.clone() } +} + +/// What a resolution's outcome names, by the field that names it. +pub(crate) fn outcome_citations(outcome: &ResolutionOutcome) -> Vec<(CitationRole, PipelineRef)> { + match outcome { + ResolutionOutcome::Superseded { by } => by.iter().map(|by| (CitationRole::SupersededBy, by.clone())).collect(), + ResolutionOutcome::Answered { by } => vec![(CitationRole::ResolvedBy, by.clone())], + ResolutionOutcome::Fixed { by: Some(by), .. } => vec![(CitationRole::ResolvedBy, by.clone())], + ResolutionOutcome::Deferred { to } => vec![(CitationRole::DeferredTo, to.clone())], + ResolutionOutcome::Fixed { by: None, .. } | ResolutionOutcome::Recorded { .. } | ResolutionOutcome::Withdrawn { .. } => { + vec![] + } + } +} + +/// Every citation edge a document carries, derived from its leaf fields each +/// time it is asked and never stored: the one edge list. Admission's A7 reads +/// it to require the referents, and the read side's rows read it as their +/// references. `Fixed.commit`, `ForeignRef` precedents and the labels +/// (`depends_on`, `invariants`, `promise`) are not edges. A hand-off's +/// `documents` entry whose kind segment does not read cites nothing here; +/// admission refuses such an entry on the source side before it asks. +pub(crate) fn citations(document: &PipelineDocument) -> Vec<(CitationRole, PipelineRef)> { + use CitationRole as R; + use PipelineDocument as D; + use PipelineKind as K; + let mut edges = Vec::new(); + match document { + D::Question(question) => edges.extend(question.raised_in.iter().map(|to| (R::RaisedIn, to.clone()))), + D::Ruling(ruling) => edges.extend(ruling.answers.iter().map(|to| (R::Answers, cited(K::Question, to)))), + D::CutSpec(spec) => { + edges.extend(spec.rulings.iter().map(|to| (R::Rulings, cited(K::Ruling, to)))); + edges.extend(spec.questions.iter().map(|to| (R::Questions, cited(K::Question, to)))); + } + D::CutReport(report) => { + edges.push((R::CutSpec, cited(K::CutSpec, &report.cut_spec))); + edges.extend(report.forks.iter().map(|to| (R::Forks, cited(K::Question, to)))); + } + D::Verdict(verdict) => { + edges.push((R::CutReport, cited(K::CutReport, &verdict.cut_report))); + edges.extend( + verdict + .claims + .iter() + .flat_map(|claim| claim.findings.iter()) + .map(|to| (R::Findings, cited(K::Finding, to))), + ); + } + D::Finding(finding) => edges.push((R::Verdict, cited(K::Verdict, &finding.verdict))), + D::FollowUp(follow_up) => edges.push((R::Source, follow_up.source.clone())), + D::Resolution(resolution) => { + edges.push((R::Subject, resolution.subject.clone())); + edges.extend(outcome_citations(&resolution.outcome)); + } + D::HandOff(hand_off) => edges.extend( + hand_off.documents.iter().filter_map(|to| kind_of_id(&to.0).map(|kind| (R::Documents, cited(kind, to)))), + ), + D::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) => {} + } + edges +} diff --git a/crates/huginn-mind/src/envelope.rs b/crates/huginn-mind/src/envelope.rs new file mode 100644 index 0000000..8152a54 --- /dev/null +++ b/crates/huginn-mind/src/envelope.rs @@ -0,0 +1,228 @@ +//! CultNet's operation envelope around the mind's vocabulary. Pure functions: +//! a request goes out as `cultnet.operation_request.v0` and an answer comes +//! back as `cultnet.operation_response.v0`, both carrying base64 of named +//! MessagePack, which is what the C# reference and Eve's plugin ABI speak. +//! +//! A message this module cannot decode is a typed failure with a code, never +//! a transport error and never a call into a mind. `payload_encoding` is +//! CultNet's own check in `validate_message` and is not re-checked here. +//! `source_runtime_id` and `target_runtime_id` on a request are read by +//! nothing: ruling 18 says an identity is declared in the payload, not +//! granted by the envelope. + +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use cultnet_rs::CultNetMessage; +use crate::wire::{HuginnMindRequest, HuginnMindResponse, MIND_REQUEST_SCHEMA, MIND_RESPONSE_SCHEMA, MIND_SERVICE_ID}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// The C# reference's `CultNetOperationServer.FailureSchemaId`. The Rust +/// `cultnet-rs` publishes no type for it, so this two-field mirror carries the +/// reference's keys; parity belongs in CultLib. +pub const FAILURE_SCHEMA: &str = "gamecult.cultnet.operation_failure.v1"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct OperationFailure { + pub code: String, + pub message: String, +} + +impl OperationFailure { + fn new(code: &str, message: impl Into) -> Self { + Self { code: code.into(), message: message.into() } + } +} + +fn encode_payload(value: &T) -> Result { + Ok(STANDARD.encode(rmp_serde::to_vec_named(value)?)) +} + +/// One request, addressed to the mind service and naming its own operation. +pub fn encode_request( + message_id: &str, + request: &HuginnMindRequest, + source_runtime_id: Option, +) -> Result { + Ok(CultNetMessage::OperationRequest { + message_id: message_id.to_string(), + service_id: MIND_SERVICE_ID.to_string(), + operation: request.operation().to_string(), + payload_schema: MIND_REQUEST_SCHEMA.to_string(), + payload_encoding: "messagepack-base64".to_string(), + payload: encode_payload(request)?, + source_runtime_id, + target_runtime_id: None, + }) +} + +/// The message, or the failure that says why it is not a request of this +/// service. The envelope's `operation` string is checked against the decoded +/// request rather than trusted, so a payload cannot arrive under another +/// operation's name. +pub fn decode_request(message: &CultNetMessage) -> Result<(String, HuginnMindRequest), OperationFailure> { + let CultNetMessage::OperationRequest { message_id, service_id, operation, payload_schema, payload, .. } = message + else { + return Err(OperationFailure::new( + "not-an-operation-request", + "huginn.mind reads cultnet.operation_request.v0", + )); + }; + if service_id != MIND_SERVICE_ID { + return Err(OperationFailure::new("wrong-service", format!("{service_id} is not {MIND_SERVICE_ID}"))); + } + if payload_schema != MIND_REQUEST_SCHEMA { + return Err(OperationFailure::new( + "wrong-payload-schema", + format!("{payload_schema} is not {MIND_REQUEST_SCHEMA}"), + )); + } + let bytes = STANDARD + .decode(payload) + .map_err(|error| OperationFailure::new("payload-not-base64", error.to_string()))?; + let request: HuginnMindRequest = rmp_serde::from_slice(&bytes) + .map_err(|error| OperationFailure::new("payload-not-a-request", error.to_string()))?; + if operation != request.operation() { + return Err(OperationFailure::new( + "operation-mismatch", + format!("the envelope says {operation} and the payload is {}", request.operation()), + )); + } + Ok((message_id.clone(), request)) +} + +/// One answer, whose status the response derives. +pub fn encode_response( + message_id: &str, + operation: &str, + response: &HuginnMindResponse, + source_runtime_id: &str, +) -> Result { + Ok(CultNetMessage::OperationResponse { + message_id: message_id.to_string(), + service_id: MIND_SERVICE_ID.to_string(), + operation: operation.to_string(), + status: response.status().to_string(), + payload_schema: MIND_RESPONSE_SCHEMA.to_string(), + payload_encoding: "messagepack-base64".to_string(), + payload: encode_payload(response)?, + diagnostics: vec![], + source_runtime_id: Some(source_runtime_id.to_string()), + }) +} + +/// The answer to an envelope that never reached a mind. Infallible: the +/// failure is two strings and the daemon must always be able to say no. +pub fn encode_failure( + message_id: &str, + operation: &str, + failure: &OperationFailure, + source_runtime_id: &str, +) -> CultNetMessage { + CultNetMessage::OperationResponse { + message_id: message_id.to_string(), + service_id: MIND_SERVICE_ID.to_string(), + operation: operation.to_string(), + status: "rejected".to_string(), + payload_schema: FAILURE_SCHEMA.to_string(), + payload_encoding: "messagepack-base64".to_string(), + payload: STANDARD.encode(rmp_serde::to_vec_named(failure).expect("two strings encode")), + diagnostics: vec![failure.code.clone()], + source_runtime_id: Some(source_runtime_id.to_string()), + } +} + +/// The client's side: the correlation key and either the mind's answer or the +/// failure that says the envelope never reached one. +pub fn decode_response(message: &CultNetMessage) -> Result<(String, Result)> { + let CultNetMessage::OperationResponse { message_id, payload_schema, payload, .. } = message else { + anyhow::bail!("huginn.mind answers with cultnet.operation_response.v0"); + }; + let bytes = STANDARD.decode(payload)?; + let answer = match payload_schema.as_str() { + MIND_RESPONSE_SCHEMA => Ok(rmp_serde::from_slice::(&bytes)?), + FAILURE_SCHEMA => Err(rmp_serde::from_slice::(&bytes)?), + other => anyhow::bail!("{other} is neither {MIND_RESPONSE_SCHEMA} nor {FAILURE_SCHEMA}"), + }; + Ok((message_id.clone(), answer)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::{INSTANCE, OTHER_INSTANCE as OTHER, instance, provenance, slug}; + use crate::wire::MindStatus; + use crate::{Faculty, MindRefusal, PipelineAdmissionBatch, PipelineAdmissionOutcome}; + + fn refusal() -> MindRefusal { + MindRefusal::ForeignInstance { declared: OTHER.into(), mind: INSTANCE.into() } + } + + /// A refusal is an answer: it rides the response schema with a `rejected` + /// status and decodes back to the same value. A conflict and a replay are + /// answers about the mind, not rejections of the request. + #[test] + fn a_refusal_is_a_typed_response_not_a_transport_failure() { + let cases = [ + (HuginnMindResponse::Refused(refusal()), "rejected"), + (HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(refusal())), "rejected"), + ( + HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { + receipt_id: "r1".into(), + committed_at: "2026-09-16T12:00:00Z".into(), + writes: vec![], + }), + "accepted", + ), + (HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: "r1".into() }), "accepted"), + (HuginnMindResponse::Admit(PipelineAdmissionOutcome::Conflict { identities: vec![] }), "accepted"), + ( + HuginnMindResponse::Whoami(MindStatus { + instance: slug(INSTANCE), + schema_epoch: "epiphany.pipeline.epoch.v2".into(), + documents: 0, + receipts: 0, + index: crate::wire::IndexStatus::Current, + }), + "accepted", + ), + ]; + for (response, expected) in cases { + let message = encode_response("m-1", "admit", &response, "huginn-yggdrasil").unwrap(); + let CultNetMessage::OperationResponse { status, payload_schema, diagnostics, .. } = &message else { + panic!("not an operation response"); + }; + assert_eq!(status, expected, "{response:?}"); + assert_eq!(payload_schema, MIND_RESPONSE_SCHEMA); + assert!(diagnostics.is_empty()); + assert_eq!(decode_response(&message).unwrap(), ("m-1".into(), Ok(response))); + } + + let failure = OperationFailure::new("wrong-service", "odin.catalog is not huginn.mind"); + let message = encode_failure("m-2", "query", &failure, "huginn-yggdrasil"); + let CultNetMessage::OperationResponse { status, payload_schema, diagnostics, .. } = &message else { + panic!("not an operation response"); + }; + assert_eq!((status.as_str(), payload_schema.as_str()), ("rejected", FAILURE_SCHEMA)); + assert_eq!(diagnostics, &vec!["wrong-service".to_string()]); + assert_eq!(decode_response(&message).unwrap(), ("m-2".into(), Err(failure))); + } + + /// The round trip the client and the daemon share, and the one code that + /// `serve::answer` cannot reach because it answers a foreign message with + /// `Error` instead. + #[test] + fn a_request_round_trips_and_a_foreign_message_is_not_a_request() { + let request = HuginnMindRequest::Admit(PipelineAdmissionBatch { + instance: slug(INSTANCE), + provenance: provenance(Faculty::Hands), + documents: vec![instance(INSTANCE)], + }); + let message = encode_request("m-3", &request, Some("eureka-state".into())).unwrap(); + assert_eq!(decode_request(&message).unwrap(), ("m-3".into(), request)); + + let foreign = CultNetMessage::Error { error: "not for you".into(), code: None, details: None }; + assert_eq!(decode_request(&foreign).unwrap_err().code, "not-an-operation-request"); + } +} diff --git a/crates/huginn-mind/src/fixtures.rs b/crates/huginn-mind/src/fixtures.rs new file mode 100644 index 0000000..0f0f59f --- /dev/null +++ b/crates/huginn-mind/src/fixtures.rs @@ -0,0 +1,412 @@ +//! Test fixtures shared by the modules' tests: one valid document of each +//! kind, prepared through the leaf against the organ's cache, and a seeded +//! mind to admit into. + +use chrono::{DateTime, TimeZone, Utc}; +use cultcache_rs::CultCacheEnvelope; +use epiphany_pipeline::{ + AuthorityMap, ClaimOutcome, CodeLocation, CommitRange, CutVerification, Date, DocRef, Evidence, EvidenceKind, + FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, MutationRecord, OrgRepo, PipelineCampaign, + PipelineCutReport, PipelineCutSpec, PipelineDocument, PipelineFinding, PipelineFollowUp, PipelineHandOff, + PipelineInstance, PipelineKind, PipelineQuestion, PipelineRef, PipelineResolution, PipelineRuling, + PipelineStewardship, PipelineTarget, PipelineVerdict, Promise, QuestionOption, ReportCommit, ResolutionOutcome, + RulingAuthority, Sha, Short, Slug, StructuralDelta, TargetInvariant, VerdictClaim, +}; + +use crate::admission::{PipelineAdmissionBatch, PipelineAdmissionOutcome}; +use crate::mind::{HuginnMindEpoch, Mind, schema_cache}; +use crate::receipt::{Faculty, PipelineProvenance}; +use crate::refusal::MindRefusal; +use crate::store::MindStore; +use crate::store::test_stores::MemoryStore; + +pub(crate) const INSTANCE: &str = "yggdrasil"; +pub(crate) const OTHER_INSTANCE: &str = "thought-cage"; +/// A foreign instance of `INSTANCE`'s own length, differing in its last byte +/// alone: a comparison of lengths, of first bytes, or one gated on the +/// declared name being the longer admits it, and a comparison of values does +/// not. +pub(crate) const NEAR_INSTANCE: &str = "yggdrasix"; +/// A foreign instance carrying `INSTANCE` whole as its prefix, so a prefix +/// test admits it. +pub(crate) const PREFIXED_INSTANCE: &str = "yggdrasil-two"; +/// A foreign instance differing from `INSTANCE` in case alone. A `Slug` is +/// dot-joined labels, each `[A-Za-z0-9_-]{1,64}`, bounded to 64 bytes whole +/// (`epiphany_pipeline::dotted_text`), so this is a name a client may declare +/// and a mind may hold: two minds whose names differ only in case are two +/// minds, and a comparison that folds case answers one of them for the other. +pub(crate) const CASED_INSTANCE: &str = "Yggdrasil"; +/// Two foreign instances differing from `OTHER_INSTANCE` (`thought-cage`) in +/// their separator alone: `_` and `.` are both legal in a `Slug` beside `-` +/// (`_` inside a label, `.` joining labels), so `thought-cage`, `thought_cage` +/// and `thought.cage` are three names, not one, and a comparison that folds a +/// separator into another answers one of them for another. +pub(crate) const UNDERSCORE_INSTANCE: &str = "thought_cage"; +pub(crate) const DOTTED_INSTANCE: &str = "thought.cage"; +pub(crate) const CAMPAIGN: &str = "eureka-state"; +pub(crate) const REPO: &str = "GameCult/Epiphany"; +pub(crate) const OTHER_REPO: &str = "GameCult/Huginn"; +pub(crate) const INVARIANT: &str = "mind-admits"; + +pub(crate) fn slug(value: &str) -> Slug { + value.into() +} + +pub(crate) fn s(value: &str) -> Short { + value.into() +} + +pub(crate) fn l(value: &str) -> Label { + value.into() +} + +pub(crate) fn repo(value: &str) -> OrgRepo { + value.into() +} + +pub(crate) fn date() -> Date { + Date("2026-09-16".into()) +} + +pub(crate) fn sha() -> Sha { + Sha("5f98228d".into()) +} + +pub(crate) fn id(kind: &str, local: &str) -> String { + format!("{CAMPAIGN}:{kind}:{local}") +} + +pub(crate) fn r(kind: PipelineKind, id: &str) -> PipelineRef { + PipelineRef { kind, id: s(id) } +} + +fn range() -> CommitRange { + CommitRange { base: sha(), head: sha() } +} + +fn location() -> CodeLocation { + CodeLocation { path: s("crates/huginn-mind/src/admission.rs"), line: 1, end_line: None } +} + +pub(crate) fn evidence() -> Evidence { + Evidence { kind: EvidenceKind::Test, locator: "cargo test".into(), result: "ok".into() } +} + +fn doc_ref() -> DocRef { + DocRef { path: s("notes/eureka-pipeline-state-target.md"), start_line: 1, end_line: 9, commit: sha() } +} + +fn delta() -> StructuralDelta { + StructuralDelta { + lines_added: 0, + lines_removed: 0, + dependencies_added: vec![], + dependencies_removed: vec![], + formats_added: vec![], + formats_removed: vec![], + targets_added: vec![], + targets_removed: vec![], + } +} + +pub(crate) fn instance(name: &str) -> PipelineDocument { + PipelineDocument::Instance(PipelineInstance { + instance: slug(name), + display_name: s(&format!("{name} mind")), + created_at: date(), + host: s(name), + }) +} + +pub(crate) fn stewardship(instance: &str, repo_name: &str) -> PipelineDocument { + stewardship_n(instance, repo_name, 1) +} + +/// A repo's `n`th assignment to a mind. The sequence is per `(instance, +/// repo)` and the leaf keys it last, so `n2` is the record of a repo +/// transferred away and taken back. +pub(crate) fn stewardship_n(instance: &str, repo_name: &str, sequence: u32) -> PipelineDocument { + PipelineDocument::Stewardship(PipelineStewardship { + instance: slug(instance), + repo: repo(repo_name), + sequence, + assigned_on: date(), + note: "assigned".into(), + }) +} + +pub(crate) fn campaign(repos: &[&str]) -> PipelineDocument { + PipelineDocument::Campaign(PipelineCampaign { + slug: slug(CAMPAIGN), + title: "Eureka pipeline state".into(), + repos: repos.iter().map(|name| repo(name)).collect(), + working_branch: s("codex/eureka-pipeline-state"), + target_doc: doc_ref(), + }) +} + +pub(crate) fn target(revision: u32, invariants: &[&str]) -> PipelineDocument { + PipelineDocument::Target(PipelineTarget { + campaign: slug(CAMPAIGN), + revision, + invariants: invariants + .iter() + .map(|label| TargetInvariant { label: l(label), statement: "Only admission writes.".into() }) + .collect(), + not_in_scope: vec![], + canonical_implementations: vec![], + doc: doc_ref(), + }) +} + +pub(crate) fn question(label: &str, options: &[&str], recommended: &str) -> PipelineDocument { + PipelineDocument::Question(PipelineQuestion { + campaign: slug(CAMPAIGN), + label: l(label), + title: "Who owns the state?".into(), + question: "Who owns the state?".into(), + options: options.iter().map(|option| QuestionOption { label: l(option), text: "an option".into() }).collect(), + recommended: l(recommended), + depends: vec![], + raised_in: None, + asked_on: date(), + }) +} + +pub(crate) fn ruling(label: &str) -> PipelineRuling { + PipelineRuling { + campaign: slug(CAMPAIGN), + label: l(label), + title: "An instance owns its mind".into(), + answers: None, + choice: None, + ruling: "An instance owns its mind.".into(), + operator_quote: None, + ruled_on: date(), + precedents: vec![], + authority: RulingAuthority::Standing, + } +} + +pub(crate) fn cut_spec(cut: &str, revision: u32) -> PipelineCutSpec { + PipelineCutSpec { + campaign: slug(CAMPAIGN), + cut: l(cut), + revision, + title: "A cut".into(), + repo: repo(REPO), + branch: s("codex/eureka-pipeline-state"), + base: sha(), + depends_on: vec![], + first: vec![], + deletes: vec![], + keeps_moves: vec![], + adds: vec![], + file_changes: vec![], + authority_map: None::, + verification: CutVerification { builds: vec![], tests: vec![], negative: vec![], operator: vec![] }, + estimate: delta(), + rulings: vec![], + questions: vec![], + } +} + +pub(crate) fn cut_report(cut: &str, attempt: u32) -> PipelineCutReport { + PipelineCutReport { + campaign: slug(CAMPAIGN), + cut_spec: s(&id("cut_spec", &format!("cut-{cut}.r1"))), + attempt, + repo: repo(REPO), + branch: s("codex/eureka-pipeline-state"), + commits: vec![ReportCommit { sha: sha(), subject: "Land the cut".into(), builds: true }], + range: range(), + verification: vec![evidence()], + mutations: vec![MutationRecord { + label: l("M1"), + rule: "the rule".into(), + location: location(), + before: "before".into(), + after: "after".into(), + commit: sha(), + failed_as_expected: true, + }], + deviations: vec![], + forks: vec![], + structural_delta: delta(), + landed_names: vec![], + undone: vec![], + promises: vec![Promise { label: l("P1"), text: "One derived key per document.".into() }], + } +} + +pub(crate) fn claim(outcome: ClaimOutcome, findings: &[&str], promise: Option<&str>, mutations: &[&str]) -> VerdictClaim { + VerdictClaim { + claim: Line(format!("{outcome:?} claim")), + outcome, + evidence: vec![evidence()], + findings: findings.iter().map(|finding| s(finding)).collect(), + promise: promise.map(l), + mutations: mutations.iter().map(|label| l(label)).collect(), + } +} + +pub(crate) fn verdict(cut: &str, pass: u32, claims: Vec) -> PipelineDocument { + PipelineDocument::Verdict(PipelineVerdict { + campaign: slug(CAMPAIGN), + cut_report: s(&id("cut_report", &format!("cut-{cut}.h1"))), + pass, + range: range(), + claims, + }) +} + +pub(crate) fn finding(cut: &str, pass: u32, label: &str, confidence: FindingConfidence) -> PipelineFinding { + PipelineFinding { + campaign: slug(CAMPAIGN), + verdict: s(&id("verdict", &format!("cut-{cut}.s{pass}"))), + label: l(label), + range: range(), + confidence, + severity: FindingSeverity::High, + claim: "A dotted label composes two keys.".into(), + invariants: vec![l(INVARIANT)], + locations: vec![location()], + failure_scenario: "Two documents claim one key.".into(), + evidence: vec![evidence()], + precedents: vec![], + origin: FindingOrigin::Introduced, + } +} + +pub(crate) fn follow_up(label: &str, source: PipelineRef) -> PipelineDocument { + PipelineDocument::FollowUp(PipelineFollowUp { + campaign: slug(CAMPAIGN), + label: l(label), + source, + repo: repo(REPO), + locations: vec![location()], + item: "Per-kind admission rules.".into(), + why_it_can_wait: "The organ owns admission.".into(), + owner: s("Hands"), + }) +} + +pub(crate) fn resolution(subject: PipelineRef, outcome: ResolutionOutcome) -> PipelineDocument { + resolution_n(subject, 1, outcome) +} + +/// A subject's `n`th resolution. The sequence is per subject and the leaf +/// keys it last, so a withdrawn closure stays under its subject and the next +/// one is nameable. +pub(crate) fn resolution_n(subject: PipelineRef, sequence: u32, outcome: ResolutionOutcome) -> PipelineDocument { + PipelineDocument::Resolution(PipelineResolution { + subject, + sequence, + outcome, + rationale: "Resolved.".into(), + resolved_on: date(), + }) +} + +pub(crate) fn withdrawn() -> ResolutionOutcome { + ResolutionOutcome::Withdrawn { reason: "moot".into() } +} + +pub(crate) fn superseded(by: &[PipelineRef]) -> ResolutionOutcome { + ResolutionOutcome::Superseded { by: by.to_vec() } +} + +pub(crate) fn hand_off(from: &str, to: &str, repo_name: &str, documents: &[&str]) -> PipelineDocument { + PipelineDocument::HandOff(PipelineHandOff { + from_instance: slug(from), + to_instance: slug(to), + repo: repo(repo_name), + documents: documents.iter().map(|document| s(document)).collect(), + reason: "The workstation mind takes the campaign.".into(), + handed_on: date(), + }) +} + +pub(crate) fn provenance(faculty: Faculty) -> PipelineProvenance { + PipelineProvenance { faculty, agent: s("claude"), session: s("session-1"), tool: s("admit") } +} + +pub(crate) fn now() -> DateTime { + Utc.with_ymd_and_hms(2026, 9, 16, 12, 0, 0).unwrap() +} + +/// The leaf's envelope for a document, keyed and encoded as the organ stores +/// it. +pub(crate) fn prepare(document: &PipelineDocument) -> CultCacheEnvelope { + document.validate().unwrap(); + document.prepare(&schema_cache().unwrap()).unwrap() +} + +/// The epoch record admission derives on the first write. +pub(crate) fn epoch() -> CultCacheEnvelope { + HuginnMindEpoch::envelope(&schema_cache().unwrap()).unwrap() +} + +pub(crate) fn opened(store: S, instance: &str) -> Mind { + Mind::open_with(store, &slug(instance)).unwrap() +} + +/// Admits as Hands at the fixed clock. +pub(crate) fn admit(mind: &mut Mind, documents: Vec) -> PipelineAdmissionOutcome { + admit_at(mind, documents, now()) +} + +/// Admits as Hands at the clock the caller names: the views order by the +/// receipt's `committed_at`, which is this `now`. +pub(crate) fn admit_at( + mind: &mut Mind, + documents: Vec, + now: DateTime, +) -> PipelineAdmissionOutcome { + let batch = PipelineAdmissionBatch { instance: mind.instance().clone(), provenance: provenance(Faculty::Hands), documents }; + mind.admit(batch, now) +} + +/// Admits as another faculty at the fixed clock: attribution the views filter +/// on and no rule reads (ruling 18). +pub(crate) fn admit_as( + mind: &mut Mind, + faculty: Faculty, + documents: Vec, +) -> PipelineAdmissionOutcome { + let batch = PipelineAdmissionBatch { instance: mind.instance().clone(), provenance: provenance(faculty), documents }; + mind.admit(batch, now()) +} + +/// The `n`th plain question, for the tests that need more of them than they +/// need to tell apart. +pub(crate) fn question_n(n: u32) -> PipelineDocument { + question(&format!("Q{n}"), &["A", "B"], "A") +} + +/// The first write into a fresh yggdrasil mind: its identity, stewardship of +/// the campaign repo, the campaign and a first target with one invariant. +pub(crate) fn seed(mind: &mut Mind) { + let outcome = admit(mind, vec![instance(INSTANCE), stewardship(INSTANCE, REPO), campaign(&[REPO]), target(1, &[INVARIANT])]); + assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}"); +} + +pub(crate) fn seeded() -> Mind { + let mut mind = opened(MemoryStore::new(), INSTANCE); + seed(&mut mind); + mind +} + +pub(crate) fn refusal(outcome: PipelineAdmissionOutcome) -> MindRefusal { + match outcome { + PipelineAdmissionOutcome::Refused(refusal) => refusal, + other => panic!("expected a refusal, got {other:?}"), + } +} + +pub(crate) fn committed(outcome: PipelineAdmissionOutcome) -> (String, Vec) { + match outcome { + PipelineAdmissionOutcome::Committed { receipt_id, writes, .. } => (receipt_id, writes), + other => panic!("expected a commit, got {other:?}"), + } +} diff --git a/crates/huginn-mind/src/index.rs b/crates/huginn-mind/src/index.rs new file mode 100644 index 0000000..7ac4869 --- /dev/null +++ b/crates/huginn-mind/src/index.rs @@ -0,0 +1,312 @@ +//! What of a document is searchable, and nothing else about the index. +//! +//! `index_text` is a rule about the leaf's documents: which fields are prose a +//! reader would search for. It is a pure function of one document, so it reads +//! no receipt, no provenance and no status, and nothing derived from it is +//! stored: the daemon's projection recomputes it, and `INDEX_TEXT_VERSION` is +//! how a change to it makes every projection rebuild. No network, no JSON, no +//! store handle: the daemon owns the vector store and the embedder. + +use epiphany_pipeline::{PipelineDocument, PipelineKind, PipelineRef}; +use sha2::{Digest, Sha256}; + +use crate::docs::Docs; +use crate::mind::Mind; +use crate::query::AdmissionIndex; +use crate::refusal::MindRefusal; +use crate::rows::root_and_local; +use crate::store::MindStore; + +/// Bumped whenever `index_text` changes what it returns for any document. +pub const INDEX_TEXT_VERSION: u32 = 1; + +/// The most bytes one document's text may carry, cut at a character boundary. +pub const INDEX_TEXT_MAX_BYTES: usize = 16 * 1024; + +/// One document as the projection needs it: the key, the searchable text and +/// the digest of that text, and the ordinal of the receipt that wrote it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IndexEntry { + pub id: PipelineRef, + /// The key's root segment: a campaign slug, or an instance. + pub root: String, + pub text: String, + pub text_sha256: String, + pub ordinal: u64, +} + +/// The searchable text of a document, or `None` when its kind carries none +/// (`instance`, `stewardship`) or every field it names is empty. Lines are +/// joined by newlines. Withdrawn resolutions are indexed like any other: a +/// closed thing stays findable. +pub fn index_text(document: &PipelineDocument) -> Option { + use PipelineDocument as D; + let mut lines: Vec<&str> = Vec::new(); + match document { + D::Campaign(campaign) => lines.push(&campaign.title.0), + D::Target(target) => { + lines.extend(target.invariants.iter().map(|invariant| invariant.statement.0.as_str())); + lines.extend(target.not_in_scope.iter().map(|line| line.0.as_str())); + lines.extend(target.canonical_implementations.iter().map(|line| line.0.as_str())); + } + D::Question(question) => { + lines.push(&question.title.0); + lines.push(&question.question.0); + lines.extend(question.options.iter().map(|option| option.text.0.as_str())); + } + D::Ruling(ruling) => { + lines.push(&ruling.title.0); + lines.push(&ruling.ruling.0); + lines.extend(ruling.operator_quote.iter().map(|quote| quote.0.as_str())); + } + D::CutSpec(spec) => { + lines.push(&spec.title.0); + lines.extend(spec.first.iter().map(|line| line.0.as_str())); + lines.extend(spec.adds.iter().map(|line| line.0.as_str())); + lines.extend(spec.keeps_moves.iter().map(|line| line.0.as_str())); + } + D::CutReport(report) => { + lines.extend(report.commits.iter().map(|commit| commit.subject.0.as_str())); + for deviation in &report.deviations { + lines.push(&deviation.what.0); + lines.push(&deviation.why.0); + } + lines.extend(report.undone.iter().map(|line| line.0.as_str())); + lines.extend(report.promises.iter().map(|promise| promise.text.0.as_str())); + } + D::Verdict(verdict) => lines.extend(verdict.claims.iter().map(|claim| claim.claim.0.as_str())), + D::Finding(finding) => { + lines.push(&finding.claim.0); + lines.push(&finding.failure_scenario.0); + lines.extend(finding.evidence.iter().map(|evidence| evidence.result.0.as_str())); + } + D::FollowUp(follow_up) => { + lines.push(&follow_up.item.0); + lines.push(&follow_up.why_it_can_wait.0); + } + D::Resolution(resolution) => lines.push(&resolution.rationale.0), + D::HandOff(hand_off) => lines.push(&hand_off.reason.0), + D::Instance(_) | D::Stewardship(_) => {} + } + lines.retain(|line| !line.is_empty()); + if lines.is_empty() { + return None; + } + let mut text = lines.join("\n"); + if text.len() > INDEX_TEXT_MAX_BYTES { + let mut cut = INDEX_TEXT_MAX_BYTES; + while !text.is_char_boundary(cut) { + cut -= 1; + } + text.truncate(cut); + } + Some(text) +} + +fn sha256_hex(text: &str) -> String { + Sha256::digest(text.as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect() +} + +impl Mind { + /// The entries to project: every indexable document the mind holds, or + /// only the named ones. The ordinal is the writing receipt's. A named + /// document the mind does not hold is a refusal, not a silent omission: a + /// caller names what a commit just landed. + pub fn index_entries(&self, only: Option<&[PipelineRef]>) -> Result, MindRefusal> { + let documents: Vec<(PipelineKind, String, PipelineDocument)> = match only { + Some(refs) => { + let mut held = Vec::with_capacity(refs.len()); + for reference in refs { + reference.validate_ref()?; + let document = self.get(reference.kind, &reference.id.0)?.ok_or_else(|| { + MindRefusal::Unavailable { detail: format!("index: {} is not held", reference.id.0) } + })?; + held.push((reference.kind, reference.id.0.clone(), document)); + } + held + } + None => Docs::from_image(self.envelopes())? + .image + .into_iter() + .map(|held| (held.kind, held.key, held.document)) + .collect(), + }; + let facts = AdmissionIndex::build(self)?; + let mut entries = Vec::new(); + for (kind, key, document) in documents { + let Some(text) = index_text(&document) else { continue }; + let ordinal = facts.ordinal_of(kind, &key)?; + entries.push(IndexEntry { + root: root_and_local(&key).0.to_string(), + id: PipelineRef { kind, id: key.as_str().into() }, + text_sha256: sha256_hex(&text), + text, + ordinal, + }); + } + Ok(entries) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::*; + use crate::receipt::Faculty; + use crate::store::test_stores::MemoryStore; + use epiphany_pipeline::{ + ClaimOutcome, Deviation, FindingConfidence, PipelineDocument as D, PipelineKind as K, RulingAuthority, + }; + use std::collections::BTreeMap; + + /// A mind holding one document of every kind that carries text, each with + /// more than one text field where the kind has more than one, and the + /// question's closure withdrawn beside them. Returns the hand-off's key. + fn full_mind() -> (Mind, String) { + let mut mind = opened(MemoryStore::new(), INSTANCE); + let D::Target(mut target_doc) = target(1, &[INVARIANT, "second"]) else { panic!() }; + target_doc.not_in_scope = vec!["No client writes.".into()]; + target_doc.canonical_implementations = vec!["huginn-mind admission".into()]; + committed(admit( + &mut mind, + vec![instance(INSTANCE), stewardship(INSTANCE, REPO), campaign(&[REPO]), D::Target(target_doc)], + )); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let mut ruling_doc = ruling("R1"); + ruling_doc.answers = Some(s(&id("question", "Q1"))); + ruling_doc.choice = Some(l("A")); + ruling_doc.operator_quote = Some("Own it.".into()); + ruling_doc.authority = RulingAuthority::Operator; + committed(admit(&mut mind, vec![D::Ruling(ruling_doc)])); + // The derived closure of Q1 is withdrawn: a withdrawn resolution is a + // document like any other and stays findable. + let closed = id("resolution", "question.Q1.n1"); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &closed), withdrawn())])); + let mut spec_doc = cut_spec("1", 1); + spec_doc.first = vec!["Read the leaf.".into()]; + spec_doc.adds = vec!["An index module.".into(), "A worker.".into()]; + spec_doc.keeps_moves = vec!["Admission stays.".into()]; + committed(admit(&mut mind, vec![D::CutSpec(spec_doc)])); + let mut report_doc = cut_report("1", 1); + report_doc.deviations = vec![Deviation { what: "Changed a name.".into(), why: "It collided.".into() }]; + report_doc.undone = vec!["The live smoke.".into()]; + committed(admit( + &mut mind, + vec![D::CutReport(report_doc), follow_up("FU-1", r(K::Question, &id("question", "Q1")))], + )); + committed(admit( + &mut mind, + vec![ + verdict("1", 1, vec![claim(ClaimOutcome::Falsified, &[&id("finding", "cut-1.s1.F1")], Some("P1"), &["M1"])]), + D::Finding(finding("1", 1, "F1", FindingConfidence::Confirmed)), + ], + )); + let (_, writes) = committed(admit(&mut mind, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[])])); + let hand_off_key = writes.iter().find(|write| write.kind == K::HandOff).unwrap().id.0.clone(); + (mind, hand_off_key) + } + + fn texts(mind: &Mind) -> BTreeMap { + mind.index_entries(None).unwrap().into_iter().map(|entry| (entry.id.id.0, entry.text)).collect() + } + + /// Each kind's search text is its named fields, joined by newlines, and + /// the two kinds with no prose have no entry. + #[test] + fn index_text_is_each_kinds_named_fields_and_the_identity_kinds_have_none() { + let (mind, hand_off_key) = full_mind(); + let expected: Vec<(String, &str)> = vec![ + (id("campaign", "self"), "Eureka pipeline state"), + (id("target", "r1"), "Only admission writes.\nOnly admission writes.\nNo client writes.\nhuginn-mind admission"), + (id("question", "Q1"), "Who owns the state?\nWho owns the state?\nan option\nan option"), + (id("ruling", "R1"), "An instance owns its mind\nAn instance owns its mind.\nOwn it."), + (id("cut_spec", "cut-1.r1"), "A cut\nRead the leaf.\nAn index module.\nA worker.\nAdmission stays."), + ( + id("cut_report", "cut-1.h1"), + "Land the cut\nChanged a name.\nIt collided.\nThe live smoke.\nOne derived key per document.", + ), + (id("verdict", "cut-1.s1"), "Falsified claim"), + (id("finding", "cut-1.s1.F1"), "A dotted label composes two keys.\nTwo documents claim one key.\nok"), + (id("follow_up", "FU-1"), "Per-kind admission rules.\nThe organ owns admission."), + // The withdrawn closure of Q1, admitted by hand. + (id("resolution", "resolution.question.Q1.n1.n1"), "Resolved."), + (hand_off_key, "The workstation mind takes the campaign."), + ]; + let held = texts(&mind); + for (key, text) in &expected { + match held.get(key) { + Some(found) => assert_eq!(found, text, "{key}"), + None => panic!("{key} has no entry; entries are {:?}", held.keys().collect::>()), + } + } + let kinds: Vec = mind.index_entries(None).unwrap().iter().map(|entry| entry.id.kind).collect(); + assert!(!kinds.contains(&K::Instance) && !kinds.contains(&K::Stewardship), "{kinds:?}"); + assert!(kinds.contains(&K::Resolution), "the derived closures are indexed too"); + } + + /// The text is the document's alone: the same document admitted by another + /// faculty at another clock, or closed by a later ruling, has the same + /// text and digest. Only the ordinal is the receipt's. + #[test] + fn index_text_ignores_admission_facts_and_indexes_withdrawn_resolutions() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")])); + let q1 = id("question", "Q1"); + let before = mind.index_entries(Some(&[r(K::Question, &q1)])).unwrap(); + + let mut other = seeded(); + committed(admit_as(&mut other, Faculty::Soul, vec![question("Q1", &["A", "B"], "A")])); + let mut later = ruling("R1"); + later.answers = Some(s(&q1)); + later.choice = Some(l("A")); + committed(admit(&mut other, vec![D::Ruling(later)])); + let after = other.index_entries(Some(&[r(K::Question, &q1)])).unwrap(); + assert_eq!(before, after, "a closed question is the same entry as an open one"); + assert_eq!(before[0].ordinal, 2); + + let closure = id("resolution", "question.Q1.n1"); + committed(admit(&mut other, vec![resolution(r(K::Resolution, &closure), withdrawn())])); + let withdrawn_id = id("resolution", "resolution.question.Q1.n1.n1"); + let entry = other.index_entries(Some(&[r(K::Resolution, &withdrawn_id)])).unwrap(); + assert_eq!(entry.len(), 1, "a withdrawn resolution is indexed"); + assert_eq!(entry[0].text, "Resolved."); + assert_eq!(entry[0].root, CAMPAIGN); + assert_eq!(entry[0].ordinal, 4); + } + + #[test] + fn the_digest_is_sha256_of_the_text_and_a_missing_document_is_refused() { + let mind = seeded(); + let campaign_ref = r(K::Campaign, &id("campaign", "self")); + let entry = &mind.index_entries(Some(std::slice::from_ref(&campaign_ref))).unwrap()[0]; + assert_eq!(entry.text_sha256, "49c7e65adaad4bd34261300e9c5575847357b8f269508c9b2a46ec2aa75cc019"); + assert_eq!((entry.root.as_str(), entry.ordinal), (CAMPAIGN, 1)); + let absent = r(K::Question, &id("question", "Q9")); + assert!(matches!(mind.index_entries(Some(&[absent])), Err(MindRefusal::Unavailable { .. }))); + // The identity and the stewardship carry no text: the seeded mind's + // indexable documents are its campaign and its target. + let all = mind.index_entries(None).unwrap(); + assert_eq!(all.len(), 2, "{all:?}"); + } + + /// The bound is bytes, cut at a character boundary: three-byte characters + /// put the byte limit inside one. + #[test] + fn text_is_bounded_at_a_character_boundary() { + let wide: String = format!("{}x", "\u{20ac}".repeat(333)); + assert_eq!(wide.len(), 1000); + let mut spec = cut_spec("1", 1); + spec.adds = (0..40).map(|_| wide.as_str().into()).collect(); + let text = index_text(&D::CutSpec(spec)).unwrap(); + // The title and its newline, then full lines of 1,001 bytes, then part + // of the next one, cut back to the last whole character. + let head = "A cut\n".len(); + let stride = 1001; + let full_lines = (INDEX_TEXT_MAX_BYTES - head) / stride; + let consumed = head + full_lines * stride; + let inside = INDEX_TEXT_MAX_BYTES - consumed; + assert_ne!(inside % 3, 0, "the limit falls inside a character"); + assert_eq!(text.len(), consumed + inside - inside % 3); + assert!(text.ends_with('\u{20ac}')); + } +} diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs new file mode 100644 index 0000000..29da386 --- /dev/null +++ b/crates/huginn-mind/src/lib.rs @@ -0,0 +1,62 @@ +//! `huginn-mind`: one instance's mind as typed CultCache state. +//! +//! The crate owns three decisions and nothing else. `Mind::open` owns "may +//! this store be this instance's mind": the opener refuses a foreign type, a +//! foreign epoch, a missing identity or a foreign identity before it attaches +//! anything, and takes the store's per-path lock first. `receipt::commit` owns "did a batch enter, whole, with a +//! receipt": one compare-and-swap over the store, a receipt naming the exact +//! bytes read and written, and a typed conflict when the swap loses. +//! `Mind::admit_prepared` owns "may this batch enter": every cross-field and +//! cross-document rule, every derived write, and the replay check, in one +//! ordered path that `admit`, the daemon's sink and the hand-off all share. +//! +//! The read side derives status, joins the receipts and answers one typed +//! selection (CultNet's, over rows this crate supplies) through the same +//! `docs` the rules use; nothing is stored for it. +//! +//! The wire vocabulary and its operation envelope (`envelope`) are here too, +//! so the daemon and the client share one set +//! of types. +//! +//! Document shape, bounds, formats and keys are `epiphany-pipeline`'s; the +//! organ registers, prepares, decodes and validates through the leaf's four +//! doors and never re-derives a key. The store is CultLib's owned redb +//! CultCache, whose lifetime-long exclusive lock is the single-writer +//! invariant's mechanism. No function here reads a clock or the environment: +//! `now` is passed in. + +pub mod admission; +mod docs; +pub mod envelope; +pub mod index; +pub mod mind; +mod rows; +pub mod query; +pub mod receipt; +pub mod refusal; +pub mod store; +pub mod wire; + +#[cfg(test)] +pub(crate) mod fixtures; + +pub use docs::CitationRole; +pub use index::{INDEX_TEXT_MAX_BYTES, INDEX_TEXT_VERSION, IndexEntry, index_text}; +pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome}; +pub use mind::{HuginnMindEpoch, Mind}; +pub use query::{ + AdmissionFacts, PipelineDocumentSummary, PipelineDocumentView, PipelineEdge, PipelineFacts, PipelinePageItems, + PipelineSelectionPage, PipelineStatus, PipelineStatusSummary, Ranked, SUMMARY_MAX_BYTES, SemanticQuery, +}; +pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION}; +pub use refusal::MindRefusal; +pub use store::{MindStore, OwnedRedbMessagePackBackingStore}; +pub use wire::{ + DeferredAnswer, HuginnMindRequest, HuginnMindResponse, IndexStatus, MAX_DEFERRED_BODY_BYTES, MIND_REQUEST_SCHEMA, MIND_REQUEST_SCHEMA_JSON, + MIND_RESPONSE_SCHEMA, MIND_RESPONSE_SCHEMA_JSON, MIND_SERVICE_ID, MindStatus, +}; + +/// The leaf, whole, through the one crate that pins its rev: the daemon and +/// the client name `Slug`, `PipelineRef` and the document types from here, so +/// one `[dependencies]` entry decides which revision the workspace speaks. +pub use epiphany_pipeline; diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs new file mode 100644 index 0000000..ff5fbc2 --- /dev/null +++ b/crates/huginn-mind/src/mind.rs @@ -0,0 +1,781 @@ +//! The mind: one instance's store, opened fail-closed, and its image. +//! +//! Ruling 14: a store is canonical to exactly one instance, and the identity +//! lives in the state, in the `instance` document, not in a path. The path +//! under the state root is derived from the slug for convenience; the +//! document is the authority, and a store moved to another instance's +//! directory is refused. Ruling 15: one writer, the owned redb store's +//! lifetime-long exclusive lock. Ruling 20's analogue: the opener validates +//! every gate before it attaches, so a refused store is never read into an +//! image. + +use std::path::{Path, PathBuf}; + +use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, OwnedRedbMessagePackBackingStore}; +use cultnet_rs::CursorKey; +use epiphany_pipeline::{PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineKind, Slug, register_pipeline_document_types}; + +use crate::receipt::HuginnCommitReceipt; +use crate::refusal::MindRefusal; +use crate::store::MindStore; + +/// The store's record of the schema epoch it was written at, keyed by the +/// epoch string. Derived by admission on the first write; read by the opener, +/// which refuses a store written at any other epoch (`ForeignEpoch`) so a +/// breaking schema bump refuses the old store instead of misreading it. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "huginn.mind_epoch.v1", schema = "HuginnMindEpoch")] +pub struct HuginnMindEpoch { + #[cultcache(key = 0)] + pub schema_epoch: String, +} + +impl HuginnMindEpoch { + /// The one envelope the record ever has: the current epoch, keyed by + /// itself. Admission appends it to the first write. + pub(crate) fn envelope(cache: &CultCache) -> Result { + let record = Self { schema_epoch: PIPELINE_SCHEMA_EPOCH.to_string() }; + cache + .prepare_entry_named(PIPELINE_SCHEMA_EPOCH, &record) + .map(|(envelope, _)| envelope) + .map_err(unavailable) + } +} + +/// A store or cache error, carried as data. +pub(crate) fn unavailable(error: anyhow::Error) -> MindRefusal { + MindRefusal::Unavailable { detail: format!("{error:#}") } +} + +/// A declared name's grammar, checked before its bytes are compared to +/// anything or reach the filesystem. `Slug` is `epiphany_pipeline`'s, +/// dot-joined ASCII labels, and the leaf now opens a public door onto its own +/// check, `Slug::validate_slug`, on the pattern of `PipelineRef::validate_ref` +/// (Self's ruling on the F6 fork): the grammar is the leaf's, never +/// re-derived here, so a fold that widens what counts as ASCII (a fullwidth +/// character folded to its plain form, say) has nothing local to weaken. +/// +/// The leaf's own refusal names its own field, `slug`, and for a dotted name +/// the one label that failed rather than the whole declared name; a caller +/// here reports neither, since both would misname what the caller actually +/// sent. This instead names the field the declared name was held under and +/// the whole declared name, so `require_instance` and `Mind::open` each +/// refuse by the name a client can recognise, not `instance.instance` (the +/// old wrapper's field, a client never sent) and not a label fragment. +fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefusal> { + declared.validate_slug().map_err(|_| { + MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: field.into(), + value: declared.0.clone(), + }) + }) +} + +/// A cache that knows the fifteen types a mind's store may hold: the leaf's +/// thirteen through its registrar, the epoch record and the commit receipt. +/// It is the cache every envelope is prepared against. +pub(crate) fn schema_cache() -> Result { + let mut cache = CultCache::new(); + register_pipeline_document_types(&mut cache).map_err(unavailable)?; + cache.register_entry_type::().map_err(unavailable)?; + cache.register_entry_type::().map_err(unavailable)?; + Ok(cache) +} + +fn is_known_type(type_id: &str) -> bool { + type_id == HuginnMindEpoch::TYPE + || type_id == HuginnCommitReceipt::TYPE + || PipelineKind::ALL.iter().any(|kind| kind.type_id() == type_id) +} + +/// One instance's mind: the declared instance, the store, the registered +/// cache attached to it, and the image (the store's envelopes as last pulled, +/// in identity order). The image is a cache of the store and is re-pulled +/// after every commit; nothing writes it directly. +pub struct Mind { + instance: Slug, + store: S, + cache: CultCache, + image: Vec, + /// Mints and verifies this process's selection cursors. Never stored: a + /// cursor does not survive a reopen, which the substrate accepts. + cursor_key: CursorKey, +} + +impl Mind { + /// `/minds//mind.redb`. Derived for convenience; + /// the `instance` document inside is the identity. Private: every caller + /// that can reach a raw path must go through the grammar door first, so + /// this is never exposed on its own. `open` calls it only after + /// `require_grammatical_slug` has already passed. + fn path_for(state_root: &Path, instance: &Slug) -> PathBuf { + state_root.join("minds").join(&instance.0).join("mind.redb") + } + + /// Test-only door onto the same path: validates through + /// `require_grammatical_slug` first, exactly as `open` does, so a test + /// that must reach the raw store directly (to break a row on purpose) + /// cannot bypass the grammar the way the old public `path_for` did. Built + /// only for `cfg(test)` in this crate and for `huginn-daemon`'s own tests + /// via the `test-support` feature; never compiled into a release binary. + #[cfg(any(test, feature = "test-support"))] + pub fn store_path_for(state_root: &Path, instance: &Slug) -> Result { + require_grammatical_slug("instance", instance)?; + Ok(Self::path_for(state_root, instance)) + } + + /// Opens the instance's mind under the state root, taking the store's + /// exclusive lock for the mind's lifetime. A second owner of the same + /// path, in this process or another, is `MindAlreadyOwned`. An empty + /// store is a valid open; only the first admission may write into it. + pub fn open(state_root: &Path, instance: &Slug) -> Result { + require_grammatical_slug("instance", instance)?; + let path = Self::path_for(state_root, instance); + let store = OwnedRedbMessagePackBackingStore::new(&path).map_err(|error| { + // The store reports a held lock as an error like any other; its + // message is the only signal, so it is matched here, once. + if format!("{error:#}").contains("already has an active owner") { + MindRefusal::MindAlreadyOwned { path: path.display().to_string() } + } else { + unavailable(error) + } + })?; + Self::open_checked(store, instance) + } +} + +impl Mind { + /// Test-only door onto the same fail-closed sequence `open` runs, for a + /// store already in hand (planted directly, or opened by a test through + /// `store_path_for`). Gated exactly like `store_path_for`: it compiles + /// only under `cfg(test)` in this crate or for a dependent's own tests + /// via the `test-support` feature. `huginn-daemon` enables that feature + /// only in `[dev-dependencies]`, so a plain `cargo build --release -p + /// huginn-daemon` never pulls it into the shipped binary; but + /// `--all-targets` (and `cargo test`) unifies dev-dependency features + /// across the whole invocation, and that build's `huginn-daemon` binary + /// does carry `open_with`. As of this writing no Idunn deploy recipe for + /// Huginn exists to pin which of those commands is the actual shipped + /// build, so treat this as the open question rather than a settled + /// release-only guarantee. A second in-process owner of one store is not + /// yet sealed for every `MindStore` impl (F2's recorded follow-up); + /// production reaches this sequence only through `Mind::open`, which + /// takes the per-path redb lock first. + #[cfg(any(test, feature = "test-support"))] + pub fn open_with(store: S, instance: &Slug) -> Result { + Self::open_checked(store, instance) + } + + /// Fail-closed, in this order, nothing attached until every step passes: + /// pull the raw envelopes; if anything is stored, exactly one epoch + /// record at the current epoch; every type is one of the fifteen; and + /// exactly one `instance` document naming the declared instance; then + /// register, attach and pull. The epoch gate runs first so a store + /// written at a foreign epoch is refused as `ForeignEpoch`, never as + /// `ForeignStore`: a real epoch bump always moves every type id, so the + /// type gate would otherwise fire first and the epoch gate would never + /// be reached for the one case it exists for (F5). This is the one + /// construction site of a `Mind`; `open` and the test-only `open_with` + /// both call it and neither reimplements it. + fn open_checked(store: S, instance: &Slug) -> Result { + require_grammatical_slug("instance", instance)?; + let raw = store.pull_all().map_err(unavailable)?; + refuse_foreign_epoch(&raw)?; + refuse_foreign_types(&raw)?; + refuse_foreign_identity(&raw, instance)?; + let (cache, image) = attach(store.clone())?; + Ok(Self { instance: instance.clone(), store, cache, image, cursor_key: CursorKey::random() }) + } + + pub fn instance(&self) -> &Slug { + &self.instance + } + + /// Ruling 14 across every transport: the declared instance is this mind's, + /// else `ForeignInstance { declared, mind }`. A1 for admission; the daemon + /// asks it for every read that names an instance, and compares nothing + /// itself. + pub fn require_instance(&self, declared: &Slug) -> Result<(), MindRefusal> { + require_grammatical_slug("declared", declared)?; + if declared != self.instance() { + return Err(MindRefusal::ForeignInstance { + declared: declared.0.clone(), + mind: self.instance().0.clone(), + }); + } + Ok(()) + } + + /// The image: every envelope the store held at the last pull, in + /// identity order. Cut 10's snapshot source reads this. + pub fn envelopes(&self) -> &[CultCacheEnvelope] { + &self.image + } + + pub(crate) fn cursor_key(&self) -> &CursorKey { + &self.cursor_key + } + + pub fn is_empty(&self) -> bool { + self.image.is_empty() + } + + /// The stored bytes of one document, by kind and id. + pub fn envelope(&self, kind: PipelineKind, id: &str) -> Option<&CultCacheEnvelope> { + self.raw_envelope(kind.type_id(), id) + } + + /// One document, decoded through the leaf. + pub fn get(&self, kind: PipelineKind, id: &str) -> Result, MindRefusal> { + self.envelope(kind, id) + .map(|envelope| PipelineDocument::decode(envelope).map_err(MindRefusal::Document)) + .transpose() + } + + /// Every commit receipt in the image, in receipt-id order. + pub(crate) fn receipts(&self) -> Result, MindRefusal> { + self.image + .iter() + .filter(|envelope| envelope.r#type == HuginnCommitReceipt::TYPE) + .map(|envelope| { + rmp_serde::from_slice::(&envelope.payload).map_err(|error| { + MindRefusal::Unavailable { detail: format!("receipt {} does not decode: {error}", envelope.key) } + }) + }) + .collect() + } + + /// The mind's own identity: the receipt id of its first admission + /// (ordinal 1), which admission never rewrites and which digests the + /// instance and the first batch's bytes. `None` for a mind that has not + /// been written yet. The instance name is only a label, since two stores + /// on two hosts may share it; this tells them apart unless their first + /// batches were byte-identical. + pub fn genesis_receipt_id(&self) -> Result, MindRefusal> { + Ok(self.receipts()?.into_iter().find(|receipt| receipt.ordinal == 1).map(|receipt| receipt.receipt_id)) + } + + pub(crate) fn raw_envelope(&self, type_id: &str, key: &str) -> Option<&CultCacheEnvelope> { + self.image.iter().find(|envelope| envelope.r#type == type_id && envelope.key == key) + } + + pub(crate) fn cache(&self) -> &CultCache { + &self.cache + } + + pub(crate) fn store(&self) -> &S { + &self.store + } + + /// Re-pulls the image from the store through the attached cache. + pub(crate) fn refresh(&mut self) -> Result<(), MindRefusal> { + self.cache.pull_all_backing_stores().map_err(unavailable)?; + self.image = snapshot(&self.cache); + Ok(()) + } +} + +/// Step 3: a runtime store, a Mind store or any other file passed by mistake +/// dies on its first foreign type. +fn refuse_foreign_types(raw: &[CultCacheEnvelope]) -> Result<(), MindRefusal> { + if let Some(foreign) = raw.iter().find(|envelope| !is_known_type(&envelope.r#type)) { + return Err(MindRefusal::ForeignStore { r#type: foreign.r#type.clone() }); + } + Ok(()) +} + +/// Step 2: a non-empty store carries exactly one epoch record, keyed by the +/// epoch it names, at the epoch this binary writes. A store with no epoch +/// record at all is `MissingIdentity`; every other defect is `ForeignEpoch`, +/// whose `found` names the defect: the stored epoch when the value is +/// foreign, the record's key when a record is keyed by anything else, and the +/// record count rendered as `" records"` when more than one is stored. The +/// count is decided before any value is read, so a current record beside a +/// foreign one refuses the same way whichever the store returns first. +fn refuse_foreign_epoch(raw: &[CultCacheEnvelope]) -> Result<(), MindRefusal> { + if raw.is_empty() { + return Ok(()); + } + let foreign = |found: String| MindRefusal::ForeignEpoch { found, expected: PIPELINE_SCHEMA_EPOCH.into() }; + let mut records = raw.iter().filter(|envelope| envelope.r#type == HuginnMindEpoch::TYPE); + let Some(record) = records.next() else { + return Err(MindRefusal::MissingIdentity); + }; + let extra = records.count(); + if extra > 0 { + return Err(foreign(format!("{} records", extra + 1))); + } + if record.key != PIPELINE_SCHEMA_EPOCH { + return Err(foreign(record.key.clone())); + } + let record: HuginnMindEpoch = rmp_serde::from_slice(&record.payload) + .map_err(|error| MindRefusal::Unavailable { detail: format!("epoch record does not decode: {error}") })?; + if record.schema_epoch != PIPELINE_SCHEMA_EPOCH { + return Err(foreign(record.schema_epoch)); + } + Ok(()) +} + +/// Step 4: a non-empty store carries exactly one `instance` document, and it +/// names the declared instance. This is where a store moved to another +/// instance's directory is refused. +fn refuse_foreign_identity(raw: &[CultCacheEnvelope], instance: &Slug) -> Result<(), MindRefusal> { + if raw.is_empty() { + return Ok(()); + } + let mut documents = raw.iter().filter(|envelope| envelope.r#type == PipelineKind::Instance.type_id()); + let (Some(envelope), None) = (documents.next(), documents.next()) else { + return Err(MindRefusal::MissingIdentity); + }; + let PipelineDocument::Instance(stored) = PipelineDocument::decode(envelope)? else { + return Err(MindRefusal::MissingIdentity); + }; + if stored.instance != *instance { + return Err(MindRefusal::ForeignInstance { declared: instance.0.clone(), mind: stored.instance.0 }); + } + Ok(()) +} + +/// Step 5: the registered cache, the store as its one generic home, and the +/// image pulled through it. +fn attach(store: S) -> Result<(CultCache, Vec), MindRefusal> { + let mut cache = schema_cache()?; + cache.add_generic_backing_store(store).map_err(unavailable)?; + cache.pull_all_backing_stores().map_err(unavailable)?; + let image = snapshot(&cache); + Ok((cache, image)) +} + +fn snapshot(cache: &CultCache) -> Vec { + let mut image = cache.snapshot_envelopes(); + image.sort_by(|left, right| (&left.r#type, &left.key).cmp(&(&right.r#type, &right.key))); + image +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::{INSTANCE, epoch, instance, prepare, slug}; + use crate::store::test_stores::MemoryStore; + use cultcache_rs::CacheBackingStore; + + fn planted(rows: Vec) -> MemoryStore { + let store = MemoryStore::new(); + for row in rows { + store.plant(row); + } + store + } + + fn foreign(type_id: &str) -> CultCacheEnvelope { + let mut envelope = epoch(); + envelope.r#type = type_id.into(); + envelope + } + + const FOREIGN_EPOCH: &str = "epiphany.pipeline.epoch.v0"; + + /// A type id that is foreign under every epoch, unlike + /// `"epiphany.pipeline.campaign.v1"`: that string names the pinned + /// leaf's real, currently-registered campaign kind, so `is_known_type` + /// accepts it and a gate-order mutant that runs the type gate first + /// still passes these fixtures by accident. `_never_a_kind_` cannot + /// collide with any real `PipelineKind` id at any epoch this leaf pin + /// will ever carry, so these fixtures actually exercise the epoch-first + /// order rather than getting lucky on a real id. + const NEVER_A_KNOWN_TYPE: &str = "epiphany.pipeline._never_a_kind_.v1"; + + /// An epoch record under an arbitrary key naming an arbitrary epoch, so a + /// key defect and a value defect can be planted apart. + fn epoch_record(key: &str, schema_epoch: &str) -> CultCacheEnvelope { + let cache = schema_cache().unwrap(); + let record = HuginnMindEpoch { schema_epoch: schema_epoch.into() }; + cache.prepare_entry_named(key, &record).unwrap().0 + } + + fn foreign_epoch() -> CultCacheEnvelope { + epoch_record(FOREIGN_EPOCH, FOREIGN_EPOCH) + } + + #[test] + fn the_opener_refuses_foreign_epoch_missing_identity_and_foreign_type_before_attaching() { + let yggdrasil = slug(INSTANCE); + let cases: Vec<(Vec, MindRefusal)> = vec![ + // Step 3, reached because the epoch record here is current: a + // runtime store passed by mistake. + ( + vec![foreign("epiphany.runtime_spine.v47"), epoch(), prepare(&instance(INSTANCE))], + MindRefusal::ForeignStore { r#type: "epiphany.runtime_spine.v47".into() }, + ), + // Step 2 before step 4: a foreign epoch with no identity at all. + // Its key is foreign too, which is what refuses it here. + ( + vec![foreign_epoch()], + MindRefusal::ForeignEpoch { found: FOREIGN_EPOCH.into(), expected: PIPELINE_SCHEMA_EPOCH.into() }, + ), + // Step 2: the value, under the one key the record may have. + ( + vec![epoch_record(PIPELINE_SCHEMA_EPOCH, FOREIGN_EPOCH), prepare(&instance(INSTANCE))], + MindRefusal::ForeignEpoch { found: FOREIGN_EPOCH.into(), expected: PIPELINE_SCHEMA_EPOCH.into() }, + ), + // Step 2: the current epoch under any other key is not the epoch + // record, whatever it says about itself. + ( + vec![epoch_record("not-the-epoch", PIPELINE_SCHEMA_EPOCH), prepare(&instance(INSTANCE))], + MindRefusal::ForeignEpoch { found: "not-the-epoch".into(), expected: PIPELINE_SCHEMA_EPOCH.into() }, + ), + // Step 2: two records are no record, counted before either value + // is read. + ( + vec![epoch(), epoch_record("second", PIPELINE_SCHEMA_EPOCH), prepare(&instance(INSTANCE))], + MindRefusal::ForeignEpoch { found: "2 records".into(), expected: PIPELINE_SCHEMA_EPOCH.into() }, + ), + // Step 2: an identity without an epoch record. + (vec![prepare(&instance(INSTANCE))], MindRefusal::MissingIdentity), + // Step 4: an epoch record without an identity. + (vec![epoch()], MindRefusal::MissingIdentity), + // Step 4: two identities are no identity. + ( + vec![epoch(), prepare(&instance(INSTANCE)), prepare(&instance("thought-cage"))], + MindRefusal::MissingIdentity, + ), + ]; + for (rows, expected) in cases { + let store = planted(rows); + let before = store.rows(); + let refusal = Mind::open_with(store.clone(), &yggdrasil).err().expect("refused"); + assert_eq!(refusal, expected); + assert_eq!(store.pull_count(), 1, "{expected:?}: refused before attaching"); + assert_eq!(store.rows(), before, "{expected:?}: bytes unchanged"); + } + // A current record beside a foreign one is refused on the count, not + // on whichever of the two the store happens to return first. + let pair = (epoch(), foreign_epoch()); + for rows in [vec![pair.0.clone(), pair.1.clone()], vec![pair.1, pair.0]] { + assert_eq!( + refuse_foreign_epoch(&rows).err(), + Some(MindRefusal::ForeignEpoch { found: "2 records".into(), expected: PIPELINE_SCHEMA_EPOCH.into() }) + ); + } + // A store that passes every gate attaches, which is the second pull. + let store = planted(vec![epoch(), prepare(&instance(INSTANCE))]); + let mind = Mind::open_with(store.clone(), &yggdrasil).unwrap(); + assert_eq!(store.pull_count(), 2); + assert_eq!(mind.envelopes().len(), 2); + assert!(!mind.is_empty()); + let empty = MemoryStore::new(); + assert!(Mind::open_with(empty, &yggdrasil).unwrap().is_empty()); + } + + /// RS-1: receipt v2 is a new type id, so a store carrying an old, + /// v1-shaped receipt is refused at the type gate, before anything tries + /// to decode its 7-slot payload as the 8-slot v2 shape (F3: with + /// `#[serde(default)]` that decode would succeed and silently misread + /// every old receipt's ordinal as 0). + #[test] + fn a_v1_receipt_store_is_refused_at_open() { + let store = + planted(vec![epoch(), prepare(&instance(INSTANCE)), foreign("huginn.mind_commit_receipt.v1")]); + assert_eq!( + Mind::open_with(store, &slug(INSTANCE)).err(), + Some(MindRefusal::ForeignStore { r#type: "huginn.mind_commit_receipt.v1".into() }) + ); + } + + /// RS-1, F5: a real epoch bump always moves every type id (F4), so a + /// store written at a foreign epoch also carries at least one foreign + /// type. The epoch gate must run first, or the type gate fires instead + /// and the epoch gate is never reached for the one case it exists for. + /// The eight cases of the opener test above are unchanged by the swap. + #[test] + fn a_store_written_at_the_previous_epoch_is_refused_by_the_epoch_gate() { + let raw = vec![foreign_epoch(), foreign("epiphany.pipeline.campaign.v0")]; + assert_eq!( + Mind::open_with(planted(raw), &slug(INSTANCE)).err(), + Some(MindRefusal::ForeignEpoch { found: FOREIGN_EPOCH.into(), expected: PIPELINE_SCHEMA_EPOCH.into() }) + ); + } + + /// A foreign store with no epoch record at all still refuses + /// `MissingIdentity`, the epoch-first order's own answer -- not + /// `ForeignStore`, which is what a mutant that only reorders the gates + /// when there *is* some epoch record present would answer, by running + /// the type gate first over a store that has no epoch record to look at. + /// Pinned by this test. + #[test] + fn a_foreign_type_with_no_epoch_record_is_missing_identity_not_foreign_store() { + let store = planted(vec![foreign(NEVER_A_KNOWN_TYPE)]); + assert_eq!(Mind::open_with(store, &slug(INSTANCE)).err(), Some(MindRefusal::MissingIdentity)); + } + + /// F3 (RS fix batch): an epoch record at the current key, but a + /// foreign value, beside a foreign type, still refuses `ForeignEpoch` -- + /// the epoch gate reads the record's *value* before the type gate ever + /// runs, not only when the record's *key* is itself foreign. A mutant + /// that reorders the gates only on a foreign key would answer + /// `ForeignStore` here instead, since this record sits at the correct + /// key. + #[test] + fn a_foreign_epoch_value_at_the_current_key_beside_a_foreign_type_is_foreign_epoch() { + let store = planted(vec![epoch_record(PIPELINE_SCHEMA_EPOCH, FOREIGN_EPOCH), foreign(NEVER_A_KNOWN_TYPE)]); + assert_eq!( + Mind::open_with(store, &slug(INSTANCE)).err(), + Some(MindRefusal::ForeignEpoch { found: FOREIGN_EPOCH.into(), expected: PIPELINE_SCHEMA_EPOCH.into() }) + ); + } + + #[test] + fn the_instance_document_is_the_identity_not_the_path() { + // The same bytes opened under another name are refused. + let store = planted(vec![epoch(), prepare(&instance(INSTANCE))]); + assert_eq!( + Mind::open_with(store, &slug("thought-cage")).err(), + Some(MindRefusal::ForeignInstance { declared: "thought-cage".into(), mind: INSTANCE.into() }) + ); + + // A real store copied into another instance's directory is still + // yggdrasil's mind: it opens as yggdrasil, and not as thought-cage. + let root = tempfile::tempdir().unwrap(); + let written = Mind::path_for(root.path(), &slug(INSTANCE)); + { + let mut store = OwnedRedbMessagePackBackingStore::new(&written).unwrap(); + store.push(&epoch()).unwrap(); + store.push(&prepare(&instance(INSTANCE))).unwrap(); + } + let moved = Mind::path_for(root.path(), &slug("thought-cage")); + std::fs::create_dir_all(moved.parent().unwrap()).unwrap(); + std::fs::copy(&written, &moved).unwrap(); + assert_eq!( + Mind::open(root.path(), &slug("thought-cage")).err(), + Some(MindRefusal::ForeignInstance { declared: "thought-cage".into(), mind: INSTANCE.into() }) + ); + let store = OwnedRedbMessagePackBackingStore::new(&moved).unwrap(); + let mind = Mind::open_with(store, &slug(INSTANCE)).unwrap(); + assert_eq!(mind.instance(), &slug(INSTANCE)); + assert_eq!(mind.envelopes().len(), 2); + } + + /// One check, one owner: the value admission refuses a foreign instance + /// with is the value `require_instance` returns, so the daemon asking it + /// for a read and `admit_steps` asking it for a write cannot disagree. + /// + /// The foreign names are chosen so the check cannot pass by resembling a + /// comparison. `NEAR_INSTANCE` is the mind's own length and shares all but + /// its last byte, so a check reading only the lengths, only the first byte, + /// or running only when the declared name is longer lets it through; + /// `PREFIXED_INSTANCE` has the mind's whole name as its prefix, so a check + /// asking whether one starts with the other lets that one through; and + /// `CASED_INSTANCE` differs in case alone, which a slug label permits, so a + /// check folding case answers for a mind that is not this one. + #[test] + fn require_instance_is_the_one_check_admission_and_the_daemon_share() { + use crate::fixtures::{ + CASED_INSTANCE, DOTTED_INSTANCE, NEAR_INSTANCE, OTHER_INSTANCE, PREFIXED_INSTANCE, UNDERSCORE_INSTANCE, + now, opened, provenance, seeded, + }; + use crate::receipt::Faculty; + + let mut mind = seeded(); + let foreign = MindRefusal::ForeignInstance { declared: OTHER_INSTANCE.into(), mind: INSTANCE.into() }; + assert_eq!(mind.require_instance(&slug(OTHER_INSTANCE)).err(), Some(foreign.clone())); + assert_eq!(mind.require_instance(&slug(INSTANCE)), Ok(())); + + assert_eq!(NEAR_INSTANCE.len(), INSTANCE.len(), "the near name is the mind's own length"); + assert_eq!(NEAR_INSTANCE[..INSTANCE.len() - 1], INSTANCE[..INSTANCE.len() - 1], "and differs in one byte"); + assert!(PREFIXED_INSTANCE.starts_with(INSTANCE), "the prefixed name carries the mind's whole name"); + assert_eq!(CASED_INSTANCE.to_ascii_lowercase(), INSTANCE, "the cased name differs in case alone"); + assert_ne!(CASED_INSTANCE, INSTANCE); + for declared in [NEAR_INSTANCE, PREFIXED_INSTANCE, CASED_INSTANCE] { + assert_eq!( + mind.require_instance(&slug(declared)).err(), + Some(MindRefusal::ForeignInstance { declared: declared.into(), mind: INSTANCE.into() }), + "{declared} is not {INSTANCE}" + ); + } + + let batch = crate::admission::PipelineAdmissionBatch { + instance: slug(OTHER_INSTANCE), + provenance: provenance(Faculty::Hands), + documents: vec![instance(OTHER_INSTANCE)], + }; + assert_eq!(mind.admit(batch, now()), crate::admission::PipelineAdmissionOutcome::Refused(foreign)); + + // `INSTANCE` ("yggdrasil") carries no separator, so no fold of `_` or + // `.` into `-` can ever equate a declared name with it; a mind whose + // own name carries one is required to tell the fold apart. Opened as + // `OTHER_INSTANCE` ("thought-cage"), a declared name that folds to the + // same string under such a comparison must still be refused. + let separated = opened(MemoryStore::new(), OTHER_INSTANCE); + assert_eq!(UNDERSCORE_INSTANCE.replace('_', "-"), OTHER_INSTANCE, "differs from OTHER_INSTANCE by `_` alone"); + assert_eq!(DOTTED_INSTANCE.replace('.', "-"), OTHER_INSTANCE, "differs from OTHER_INSTANCE by `.` alone"); + assert_ne!(UNDERSCORE_INSTANCE, OTHER_INSTANCE); + assert_ne!(DOTTED_INSTANCE, OTHER_INSTANCE); + for declared in [UNDERSCORE_INSTANCE, DOTTED_INSTANCE] { + assert_eq!( + separated.require_instance(&slug(declared)).err(), + Some(MindRefusal::ForeignInstance { declared: declared.into(), mind: OTHER_INSTANCE.into() }), + "{declared} is not {OTHER_INSTANCE}" + ); + } + } + + /// One generated test in place of more fixture pairs. The base name + /// carries all three separator bytes a `Slug` permits -- `_`, `.` and `-` + /// -- at three non-adjacent positions, so every combination of swapping + /// one separator for another at a fixed position is itself a grammatical, + /// distinct `Slug`. The cartesian product over the three positions (26 + /// variants, the all-unchanged combination excepted) covers every + /// same-position permutation, including a fold that unifies two distinct + /// separators (`_` and `.` folded to one canonical byte) and a fold + /// applied to only one side of the comparison (the mind's own name + /// folded, the declared name left as written, or the reverse). Three more + /// variants pad the base with a leading dash, a trailing dash, and a + /// doubled interior dash -- all still grammatical, since a label's + /// hyphens carry no position or run restriction -- to reach `trim_matches` + /// and run-collapsing folds a same-position swap cannot produce. Every + /// one of the 30 variants must be refused as `ForeignInstance`, never + /// silently admitted as the mind's own name. + #[test] + fn every_separator_variant_of_a_declared_name_is_foreign_not_the_mind() { + const BASE: &str = "ab_cd.ef-gh"; + let separators: Vec<(usize, char)> = + BASE.char_indices().filter(|(_, c)| matches!(c, '_' | '.' | '-')).collect(); + assert_eq!(separators.len(), 3, "the base carries exactly one of each separator"); + + let mut variants: Vec = Vec::new(); + for a in ['_', '.', '-'] { + for b in ['_', '.', '-'] { + for c in ['_', '.', '-'] { + let mut bytes: Vec = BASE.chars().collect(); + bytes[separators[0].0] = a; + bytes[separators[1].0] = b; + bytes[separators[2].0] = c; + variants.push(bytes.into_iter().collect()); + } + } + } + variants.retain(|variant| variant != BASE); + assert_eq!(variants.len(), 26, "3^3 combinations, less the one that reproduces BASE"); + + variants.push(format!("-{BASE}")); + variants.push(format!("{BASE}-")); + variants.push(format!("-{BASE}-")); + variants.push(BASE.replacen('-', "--", 1)); + + let mut seen = std::collections::BTreeSet::new(); + variants.retain(|variant| seen.insert(variant.clone())); + assert_eq!(variants.len(), 30, "26 same-length separator permutations plus 4 padded variants, none colliding"); + + let mind = crate::fixtures::opened(MemoryStore::new(), BASE); + for declared in &variants { + assert!(declared.split('.').all(|label| !label.is_empty()), "{declared}: still grammatical"); + assert_eq!( + mind.require_instance(&slug(declared)).err(), + Some(MindRefusal::ForeignInstance { declared: declared.clone(), mind: BASE.into() }), + "{declared} must be refused as foreign, not folded into {BASE}" + ); + } + } + + #[test] + fn a_mind_has_one_owner_at_a_time() { + let root = tempfile::tempdir().unwrap(); + let yggdrasil = slug(INSTANCE); + let first = Mind::open(root.path(), &yggdrasil).unwrap(); + assert!(first.is_empty()); + let path = Mind::path_for(root.path(), &yggdrasil).display().to_string(); + assert_eq!( + Mind::open(root.path(), &yggdrasil).err(), + Some(MindRefusal::MindAlreadyOwned { path: path.clone() }) + ); + drop(first); + let second = Mind::open(root.path(), &yggdrasil).unwrap(); + assert!(second.is_empty()); + assert_eq!(Mind::path_for(root.path(), &yggdrasil), root.path().join("minds").join(INSTANCE).join("mind.redb")); + } + + /// N1, Soul's probe: `Mind::open(outer/inner, Slug("..\..\escaped"))` used + /// to return `Ok` and create `outer/escaped/mind.redb`, a store outside + /// the state root nothing beneath `outer/inner` names. `path_for` joins a + /// declared name as written and never validated it, so a `Slug` carrying + /// `..` and a path separator escaped through the one door that touches the + /// filesystem. `require_grammatical_slug` now runs before `path_for` is + /// even called, so the store is never created and the refusal is the + /// leaf's own grammar, not a filesystem error surfacing later. + #[test] + fn a_slug_outside_the_grammar_cannot_escape_the_state_root() { + let outer = tempfile::tempdir().unwrap(); + let inner = outer.path().join("inner"); + std::fs::create_dir_all(&inner).unwrap(); + + let escaping = Slug("..\\..\\escaped".into()); + assert_eq!( + Mind::open(&inner, &escaping).err(), + Some(MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "instance".into(), + value: escaping.0.clone(), + })) + ); + + assert!(!outer.path().join("escaped").join("mind.redb").exists(), "no store escaped the state root"); + assert!(!inner.join("minds").exists(), "no store was created under the state root either"); + } + + /// `open_with` calls `open_checked`, the one construction site `open` + /// shares with every store already in hand (planted directly, or opened + /// by a test through `store_path_for`), so it must run + /// `require_grammatical_slug` too, not only `open`'s own path-joining + /// caller. A store need not hold anything for this to refuse: the + /// declared name is checked before the store is even pulled. + #[test] + fn open_with_refuses_a_declared_instance_outside_the_grammar() { + let store = MemoryStore::new(); + let bad = Slug("not a slug!".into()); + assert_eq!( + Mind::open_with(store.clone(), &bad).err(), + Some(MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "instance".into(), + value: bad.0.clone(), + })) + ); + assert_eq!(store.pull_count(), 0, "refused before the store was ever pulled"); + } + + /// `require_instance` is A1 for admission and the daemon's reads + /// alike, and it runs the same grammar door on the *declared* name before + /// comparing it to the mind's own. A declared name outside the grammar is + /// `InvalidFormat { field: "declared" }`, never folded into a comparison + /// or silently treated as merely foreign. + #[test] + fn require_instance_refuses_a_declared_name_outside_the_grammar() { + let mind = crate::fixtures::seeded(); + let bad = Slug("not a slug!".into()); + assert_eq!( + mind.require_instance(&bad).err(), + Some(MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "declared".into(), + value: bad.0.clone(), + })) + ); + } + + /// A Unicode hyphen (U+2010) is not ASCII `-`, so a grammatical + /// `Slug` never contains one and a name carrying it is refused by the + /// leaf's own grammar as written -- nothing here may fold it to plain + /// `-` before asking `Slug::validate_slug`, the way a fullwidth mutant + /// pinned by + /// `daemon::tests::a_declared_instance_outside_the_grammar_is_refused_by_name_before_the_identity_check` + /// folds a fullwidth letter to its ASCII form. + #[test] + fn a_unicode_hyphen_is_not_folded_before_the_grammar_check() { + let declared = Slug(format!("ab{}cd", '\u{2010}')); + assert!(!declared.0.is_ascii(), "U+2010 is not ASCII, unlike plain '-'"); + assert_eq!( + Mind::open_with(MemoryStore::new(), &declared).err(), + Some(MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "instance".into(), + value: declared.0.clone(), + })) + ); + } +} diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs new file mode 100644 index 0000000..274d902 --- /dev/null +++ b/crates/huginn-mind/src/query.rs @@ -0,0 +1,1760 @@ +//! The read side of a mind: a document with the facts of its admission and +//! its status, and one typed selection over them. +//! +//! Nothing here is stored. Status is derived per call through the same +//! `docs::Docs` the admission rules use, so a withdrawn resolution reopens its +//! subject for a reader exactly when it reopens it for a rule. The facts come +//! from the commit receipts, from their `writes` and from nothing else: a +//! document's `admitted_at` is the `now` its batch was admitted at +//! (`committed_at`), never the store's `stored_at` stamp. A pipeline document +//! in the image that no receipt wrote is an integrity fault and refuses; the +//! store has one writer and the receipt is its proof. +//! +//! `query` is CultNet's selection over rows this crate supplies (`rows.rs`). +//! The substrate owns the filter, the order, the hop, the cursor and the +//! paging; this module owns the snapshot (`Reader::at`: every derivation runs +//! over the documents admitted at or before the page's `asOf`), the +//! projection to a header or a whole view, and the typing of the edges. +//! +//! `rank` is the semantic read: the daemon's index supplies candidate ids and +//! scores, and this module joins them back through the mind and lets the +//! selection decide. No clock, no store handle, no network. + +use std::collections::BTreeMap; + +use cultnet_rs::{ + Cursor, EdgeAnchor, EdgeMatch, Evaluation, LIMIT_MAX, LIMIT_MIN, PROJECTION_DOCUMENT, Row, Selection, select, validate, +}; +use epiphany_pipeline::{ + ClaimOutcome, CommitRange, Date, FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, OrgRepo, + PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, ResolutionOutcome, RulingAuthority, Sha, Short, + Slug, Title, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::docs::{CitationRole, Docs, Held, citations}; +use crate::mind::Mind; +use crate::receipt::{self, PipelineProvenance}; +use crate::refusal::MindRefusal; +use crate::rows::{SelectionRow, Vocabulary, refuse_values}; +use crate::store::MindStore; + +/// What admission recorded about a document when it landed: the receipt that +/// wrote it, the `now` that receipt was taken at, who asked, and the +/// receipt's ordinal, which is the read side's order and its snapshot +/// position. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct AdmissionFacts { + pub receipt_id: String, + pub admitted_at: String, + pub provenance: PipelineProvenance, + #[schemars(range(min = 1))] + pub ordinal: u64, +} + +/// Whether a document still stands, and if not, what closed it. Derived at +/// read time; no document carries a status field and no admission writes one. +/// The whole closing record travels, not only its outcome, because a reader +/// rehydrating wants why. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum PipelineStatus { + InForce, + Resolved { resolution: PipelineRef, record: PipelineResolution }, +} + +/// One document as a reader gets it: its id, the document, the facts of its +/// admission and its status. The id travels with the document because a +/// `PipelineDocument` does not carry its key and a client must never derive +/// one. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct PipelineDocumentView { + pub id: PipelineRef, + pub document: PipelineDocument, + pub admission: AdmissionFacts, + pub status: PipelineStatus, +} + +/// A semantic query: the text to find documents near, and how many. The index +/// owns which fields of each kind are text (`index_text`), so no substring +/// filter lives here to become a second answer to that question. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct SemanticQuery { + pub text: Line, + pub top_k: u32, +} + +/// A header's most encoded bytes, whatever the document's lists hold: the +/// summary carries only the facts a caller decides what to work on from, +/// each bounded by the leaf's own bounds. +pub const SUMMARY_MAX_BYTES: usize = 8_192; + +/// Whether a summarised document stands: the closing record's outcome, not its +/// rationale. A caller who wants why opens the resolution. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum PipelineStatusSummary { + InForce, + Resolved { resolution: PipelineRef, outcome: ResolutionOutcome }, +} + +/// The organ's meaning of `projection: header`: a document's id, the facts of +/// its admission, its status and the few fields of its kind a caller triages +/// by. Derived per read and bounded by `SUMMARY_MAX_BYTES`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct PipelineDocumentSummary { + pub id: PipelineRef, + pub admission: AdmissionFacts, + pub status: PipelineStatusSummary, + pub facts: PipelineFacts, +} + +/// The triage fields of each kind, in the leaf's own envelope spelling. Prose +/// (`Para`) and unbounded lists are not carried; `finding.claim` and +/// `follow_up.item` are, because the leaf typed them as one-line statements. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum PipelineFacts { + Campaign { title: Title, repos: Vec }, + Target { revision: u32, invariants: Vec