From 35dda4400160105c961ca72d0cfe0593f6e1b9d8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:26:31 +0200 Subject: [PATCH 001/333] mind-self-run: the Self-run actuator (squash of eureka-body/mind-self-run 70d0cef..654a209) Opens claude only under an open Self run; one pure decide() owns flush, grace, headroom and escalation; process-group signals; ends on child exit or EOF. Soul s3 holds (eureka-body:verdict:cut-mind-self-run.s3). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 9 + crates/mind-body/Cargo.toml | 12 +- crates/mind-body/README.md | 25 +- crates/mind-body/src/bin/mind-self-run.rs | 44 + crates/mind-body/src/bin/mind-wake.rs | 31 +- crates/mind-body/src/cli.rs | 31 + crates/mind-body/src/launch.rs | 5 +- crates/mind-body/src/lib.rs | 5 + crates/mind-body/src/self_run.rs | 374 ++++++ ...aude-2.1.268-assistant-usage-records.jsonl | 12 + .../claude-2.1.268-stream-json-probe.jsonl | 3 + crates/mind-body/src/self_run/decide.rs | 185 +++ crates/mind-body/src/self_run/decide/tests.rs | 443 +++++++ crates/mind-body/src/self_run/tests.rs | 1087 +++++++++++++++++ crates/mind-body/tests/cli.rs | 46 + crates/mind-body/tests/public_surface.txt | 30 + crates/mind-body/tests/self_run.rs | 281 +++++ 17 files changed, 2590 insertions(+), 33 deletions(-) create mode 100644 crates/mind-body/src/bin/mind-self-run.rs create mode 100644 crates/mind-body/src/self_run.rs create mode 100644 crates/mind-body/src/self_run/claude-2.1.268-assistant-usage-records.jsonl create mode 100644 crates/mind-body/src/self_run/claude-2.1.268-stream-json-probe.jsonl create mode 100644 crates/mind-body/src/self_run/decide.rs create mode 100644 crates/mind-body/src/self_run/decide/tests.rs create mode 100644 crates/mind-body/src/self_run/tests.rs create mode 100644 crates/mind-body/tests/self_run.rs diff --git a/Cargo.lock b/Cargo.lock index 04c05d1..64af826 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1126,8 +1126,10 @@ dependencies = [ "quote", "rust_decimal", "serde", + "serde_json", "syn 2.0.119", "tempfile", + "uuid", ] [[package]] @@ -1755,6 +1757,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -2097,6 +2105,7 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", + "sha1_smol", "wasm-bindgen", ] diff --git a/crates/mind-body/Cargo.toml b/crates/mind-body/Cargo.toml index 14da03e..efdcc4b 100644 --- a/crates/mind-body/Cargo.toml +++ b/crates/mind-body/Cargo.toml @@ -11,11 +11,15 @@ publish.workspace = true # binary) and the one fail-closed reader every acting organ calls # (`control::read_effective`). It also holds the launch primitive (`launch`, # `queue`): the one way her organs open a run and start its unit. The waker -# (`wake`, the `mind-wake` binary) is one cycle over them. +# (`wake`, the `mind-wake` binary) is one cycle over them. The actuator +# (`self_run`, the `mind-self-run` binary) carries her one open Self run. # # `rust_decimal` is the dial's number type: heat, cadence and the run cap are # money-adjacent and the tests pin exact products, which a float would blur. It # serialises as a string, so the stored document carries no float. +# +# `serde_json` reads Claude Code's stream-json, a foreign owner's wire (`self_run`); +# `uuid`'s v5 makes a Self run's session id derivable from the run's id. [dependencies] anyhow = "1" chrono = { version = "0.4.44", features = ["serde"] } @@ -26,6 +30,8 @@ eureka-state = { path = "../eureka-state", default-features = false } huginn-mind = { path = "../huginn-mind" } rust_decimal = { version = "1", default-features = false, features = ["std", "serde"] } serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", default-features = false, features = ["v5"] } [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -50,3 +56,7 @@ path = "src/main.rs" [[bin]] name = "mind-wake" path = "src/bin/mind-wake.rs" + +[[bin]] +name = "mind-self-run" +path = "src/bin/mind-self-run.rs" diff --git a/crates/mind-body/README.md b/crates/mind-body/README.md index 5838632..7241223 100644 --- a/crates/mind-body/README.md +++ b/crates/mind-body/README.md @@ -1,7 +1,14 @@ # mind-body -The Mind's body: the control store, the launch primitive and the `mind-wake` -one-shot. +The Mind's body: the control store, the launch primitive, the `mind-wake` +one-shot and the `mind-self-run` actuator. + +`mind-self-run --instance [--endpoint rudp://:]` runs inside +`mind-self@`. It finds her one open Self run, starts `claude` over +stream-json with a fixed argument list, asks for the flush once at the context +ceiling and stops a child that outlives the flush (SIGINT, then SIGTERM and SIGKILL). It exits 0 when the child exited 0, 1 +otherwise, 3 when she has no single open Self run and 2 when the arguments are +wrong or the mind cannot be reached. ## The container lane @@ -25,3 +32,17 @@ Mutation testing of the waker needs the glob form, because `-f src/wake.rs` finds no mutants in this workspace: `cargo mutants -p mind-body -f "**/src/wake.rs" -f "**/src/bin/mind-wake.rs"`. The second glob reaches `host()`, whose mutants only the container lane kills. + +The actuator has its own ignored test, `the_real_bin_finds_her_run_...` in +`tests/self_run.rs`: the real `mind-self-run` against the real daemon with a +fake `claude` first on `PATH`. It needs the daemon built beside the binary and a +POSIX shell, not root: + +```sh +cargo build -p huginn-daemon -p mind-body --all-targets && cargo test -p mind-body --test self_run -- --include-ignored +``` + +Mutation testing of the actuator, with the globs that reach the library and the +binary: `cargo mutants -p mind-body -f "**/src/self_run.rs" -f +"**/src/bin/mind-self-run.rs"`. The binary's mutants are killed only by the +lane above. diff --git a/crates/mind-body/src/bin/mind-self-run.rs b/crates/mind-body/src/bin/mind-self-run.rs new file mode 100644 index 0000000..04bc20d --- /dev/null +++ b/crates/mind-body/src/bin/mind-self-run.rs @@ -0,0 +1,44 @@ +//! `mind-self-run --instance [--endpoint rudp://:]`: carries +//! her one open Self run (`mind_body::self_run::carry`) inside +//! `mind-self@`. This reads the process's arguments and environment, +//! the things the library never reads, and prints the request lines and the +//! outcome. It exits 0 when the child exited 0, 1 when it exited otherwise or by +//! a signal, 3 when she has no single open Self run (no child started), and 2 +//! when the mind cannot be reached or the arguments are wrong. Without +//! `--endpoint` the daemon is `HUGINN_ENDPOINT`. + +use std::time::Duration; + +use anyhow::Result; +use eureka_state::HuginnClient; +use mind_body::cli::instance_and_endpoint; +use mind_body::launch::SystemClock; +use mind_body::queue::HuginnMind; +use mind_body::self_run::{ClaudeSpawner, carry}; + +/// How long one call to the mind may take. +const TIMEOUT: Duration = Duration::from_secs(15); + +const USAGE: &str = "usage: mind-self-run --instance [--endpoint rudp://:]"; + +// The binary is the one place that reads the process: its arguments and the +// daemon's address. The library reads neither (clippy.toml, `#![forbid]` in lib.rs). +#[allow(clippy::disallowed_methods, reason = "the binary reads its own arguments and the daemon's address, and hands them to the library")] +fn run() -> Result { + let args: Vec = std::env::args().skip(1).collect(); + let (instance, address) = instance_and_endpoint(&args, std::env::var("HUGINN_ENDPOINT").ok(), USAGE)?; + let mind = HuginnMind::new(HuginnClient::new(address, instance, TIMEOUT)); + let outcome = carry(&mind, &SystemClock, &ClaudeSpawner, &mut std::io::stdout())?; + println!("{outcome}"); + Ok(outcome.exit_status()) +} + +fn main() { + match run() { + Ok(status) => std::process::exit(status), + Err(error) => { + eprintln!("mind-self-run: {error:#}"); + std::process::exit(2); + } + } +} diff --git a/crates/mind-body/src/bin/mind-wake.rs b/crates/mind-body/src/bin/mind-wake.rs index 761c086..56385b7 100644 --- a/crates/mind-body/src/bin/mind-wake.rs +++ b/crates/mind-body/src/bin/mind-wake.rs @@ -5,13 +5,12 @@ //! mind cannot be reached or the arguments are wrong. Without `--endpoint` the //! daemon is `HUGINN_ENDPOINT`. -use std::net::SocketAddr; use std::path::Path; use std::time::Duration; -use anyhow::{Result, anyhow, bail}; -use eureka_pipeline::Slug; +use anyhow::Result; use eureka_state::HuginnClient; +use mind_body::cli::instance_and_endpoint; use mind_body::control::{CONTROL_ROOT, FileSource, control_path}; use mind_body::launch::{Ports, SystemClock, SystemdLauncher}; use mind_body::queue::HuginnMind; @@ -22,25 +21,6 @@ const TIMEOUT: Duration = Duration::from_secs(15); const USAGE: &str = "usage: mind-wake --instance [--endpoint rudp://:]"; -/// The instance and the endpoint as given. A flag given twice or an unknown -/// argument is refused without printing it. -fn parse(args: &[String]) -> Result<(Slug, Option)> { - let (mut instance, mut endpoint) = (None, None); - let mut rest = args.iter(); - while let Some(flag) = rest.next() { - let slot = match flag.as_str() { - "--instance" => &mut instance, - "--endpoint" => &mut endpoint, - _ => bail!("{USAGE}"), - }; - let value = rest.next().ok_or_else(|| anyhow!("{USAGE}"))?; - if slot.replace(value.clone()).is_some() { - bail!("{USAGE}"); - } - } - Ok((Slug(instance.ok_or_else(|| anyhow!("{USAGE}"))?), endpoint)) -} - /// The host a run records: the kernel's name for this machine. fn host() -> String { std::fs::read_to_string("/proc/sys/kernel/hostname").map(|name| name.trim().to_string()).ok().filter(|name| !name.is_empty()).unwrap_or_else(|| "unknown".into()) @@ -51,13 +31,8 @@ fn host() -> String { #[allow(clippy::disallowed_methods, reason = "the binary reads its own arguments and the daemon's address, and hands them to the library")] fn run() -> Result { let args: Vec = std::env::args().skip(1).collect(); - let (instance, endpoint) = parse(&args)?; + let (instance, address) = instance_and_endpoint(&args, std::env::var("HUGINN_ENDPOINT").ok(), USAGE)?; let source = FileSource::new(control_path(Path::new(CONTROL_ROOT), &instance)?); - let endpoint = endpoint.or_else(|| std::env::var("HUGINN_ENDPOINT").ok()).ok_or_else(|| anyhow!("no daemon endpoint: --endpoint or HUGINN_ENDPOINT"))?; - let address = endpoint - .strip_prefix("rudp://") - .and_then(|address| address.parse::().ok()) - .ok_or_else(|| anyhow!("the endpoint is not rudp://:"))?; let mind = HuginnMind::new(HuginnClient::new(address, instance, TIMEOUT)); let launcher = SystemdLauncher::new(); let host = host(); diff --git a/crates/mind-body/src/cli.rs b/crates/mind-body/src/cli.rs index 92a9895..b1debbc 100644 --- a/crates/mind-body/src/cli.rs +++ b/crates/mind-body/src/cli.rs @@ -18,6 +18,7 @@ //! fails if either step failed. No other command stops anything. use std::io::Write; +use std::net::SocketAddr; use std::path::Path; use std::str::FromStr; @@ -32,6 +33,36 @@ use crate::launch::unit; const USAGE: &str = "usage: mind-control [--root ] --instance show | check | brake hold | brake release | dial set --heat H --base-cooldown-s S --base-run-usd U"; +/// The arguments every organ binary shares: `--instance ` and an optional +/// `--endpoint rudp://:`, which `env_endpoint` (the binary's reading of +/// `HUGINN_ENDPOINT`) replaces when absent. A flag given twice, an unknown +/// argument, a name that is not a slug or an endpoint that is not +/// `rudp://:` is refused with `usage` or a fixed message, and no +/// value is echoed. +pub fn instance_and_endpoint(args: &[String], env_endpoint: Option, usage: &str) -> Result<(Slug, SocketAddr)> { + let (mut instance, mut endpoint) = (None, None); + let mut rest = args.iter(); + while let Some(flag) = rest.next() { + let slot = match flag.as_str() { + "--instance" => &mut instance, + "--endpoint" => &mut endpoint, + _ => bail!("{usage}"), + }; + let value = rest.next().ok_or_else(|| anyhow!("{usage}"))?; + if slot.replace(value.clone()).is_some() { + bail!("{usage}"); + } + } + let instance = Slug(instance.ok_or_else(|| anyhow!("{usage}"))?); + instance.validate_slug().map_err(|_| anyhow!("the instance is not a slug"))?; + let endpoint = endpoint.or(env_endpoint).ok_or_else(|| anyhow!("no daemon endpoint: --endpoint or HUGINN_ENDPOINT"))?; + let address = endpoint + .strip_prefix("rudp://") + .and_then(|address| address.parse::().ok()) + .ok_or_else(|| anyhow!("the endpoint is not rudp://:"))?; + Ok((instance, address)) +} + /// Who made the change: `SUDO_USER`, else `USER`, else `unknown`. An empty /// variable counts as unset. pub fn operator_name(sudo_user: Option, user: Option) -> String { diff --git a/crates/mind-body/src/launch.rs b/crates/mind-body/src/launch.rs index 52f04b7..7808def 100644 --- a/crates/mind-body/src/launch.rs +++ b/crates/mind-body/src/launch.rs @@ -298,8 +298,9 @@ fn close_dead(mind: &dyn MindPort, run: &PipelineRef, now: DateTime) -> Res /// The in-force runs of hers (operated by the mind) of `turn` on `instance`, /// read from typed facts: the holders of that turn's slot. The one reader of -/// the slot, for step 0 and for checking a holder admission names. -fn holders(mind: &dyn MindPort, instance: &Slug, turn: RunTurn) -> Result> { +/// the slot, for step 0 and for checking a holder admission names. `self_run` +/// reads her open Self run through it too. +pub(crate) fn holders(mind: &dyn MindPort, instance: &Slug, turn: RunTurn) -> Result> { let runs = headers(mind, &in_force(any_of(of_kind(PipelineKind::Run), "root", &[&instance.0])))?; Ok(runs .into_iter() diff --git a/crates/mind-body/src/lib.rs b/crates/mind-body/src/lib.rs index 4a86f31..19f33a6 100644 --- a/crates/mind-body/src/lib.rs +++ b/crates/mind-body/src/lib.rs @@ -10,6 +10,10 @@ //! opens a run in the mind and only then starts its unit. //! //! `wake` is the waker's one cycle over them: the `mind-wake` binary. +//! +//! `self_run` carries one Self run inside `mind-self@`: the +//! `mind-self-run` binary runs `claude` over stream-json for her one open Self +//! run, asks for the flush at the context ceiling and stops a child that outlives it. // The control root is a constant, so the library reads no part of the process // environment (clippy.toml lists the readers); `forbid` keeps an `allow` from @@ -23,6 +27,7 @@ pub mod cli; pub mod control; pub mod launch; pub mod queue; +pub mod self_run; pub mod wake; #[cfg(test)] diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs new file mode 100644 index 0000000..220c262 --- /dev/null +++ b/crates/mind-body/src/self_run.rs @@ -0,0 +1,374 @@ +//! The Self-run actuator: one `claude` child carries her one open Self run. +//! +//! `carry` finds her run (the one in-force Self run operated by the mind, read +//! through `launch::holders`: the run is the grant), starts `claude` with the +//! fixed argument list, writes the start message and watches the child's +//! stream-json output. It counts the main thread's requests, asks for the flush +//! once when the context prefix reaches the ceiling (ruling +//! `eureka-body:ruling:self-lifecycle-flush-rehydrate`: a Self run admits what +//! the mind lacks and ends, it never summarises), closes the child's input at +//! the first result, and stops a child that outlives its flush: an interrupt +//! after the grace, or once the requests of the flush's own turn have grown the +//! prefix by the headroom beyond the prefix the flush was asked at (ruling +//! `eureka-body:ruling:flush-always-gets-a-turn`), then SIGTERM and SIGKILL if +//! the child ignores that. Signals go to the child's process group. +//! +//! Every timing decision is one pure function, `decide` (the `decide` module, +//! ruling `eureka-body:ruling:self-run-decider`). `carry` is the loop that turns +//! the operating system's facts into events and executes the actions. +//! +//! The actuator admits nothing, reads neither the brake nor the environment and +//! stops no unit: the gate and the brake hold own those. It never echoes an +//! argument, an endpoint or a stream line. Stream-json is Claude Code's wire, a +//! foreign owner's JSON: it is parsed into `serde_json::Value` here and nothing +//! parsed is stored. + +use std::fmt; +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::{ChildStdin, Command, Stdio}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; +use std::time::Duration as StdDuration; + +use anyhow::{Context, Result, anyhow, bail}; +use cultnet_rs::Selection; +use eureka_pipeline::{PipelineDocument, PipelineRef, RunTurn, Slug}; +use huginn_mind::PipelinePageItems; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::launch::{Clock, holders}; +use crate::queue::MindPort; + +use decide::{Action, Event, State, decide, next_deadline}; + +/// The measured Self ceiling: at this prefix the flush is asked for. +pub const CEILING_TOKENS: u64 = 300_000; +/// What the prefix may grow by beyond the prefix the flush was asked at before +/// the actuator interrupts. Relative to the flush, never an absolute limit. +pub const FLUSH_HEADROOM_TOKENS: u64 = 60_000; +/// How long the child may take over the flush before it is interrupted. +pub const FLUSH_GRACE_S: i64 = 15 * 60; +/// How long a child may ignore a signal before the next, stronger one. +pub const ESCALATE_AFTER_S: i64 = 120; +/// How long a stream may stay open after the child has exited (a descendant +/// holding its stdout) before the run is over without it. +pub const EXIT_DRAIN_S: i64 = 2; +/// A backstop `claude` enforces; the ceiling is reached first. +pub const MAX_TURNS: u32 = 500; +/// The name the start message tells her to admit as. +pub const AGENT: &str = "mind-self-run"; +/// The namespace of the session id: the run id is the name in it. +pub const SESSION_NAMESPACE: Uuid = Uuid::NAMESPACE_OID; + +/// The longest the loop waits on the child when no deadline is nearer. +const POLL: StdDuration = StdDuration::from_secs(60); + +/// What the child is told at the start. The contract with the `mind-self` +/// skill: it names the skill, the instance and the run id. +pub fn start_message(instance: &Slug, run: &PipelineRef) -> String { + format!( + "Use the mind-self skill. You are Self for the instance `{}`; your run is `{}`. Admit as faculty SelfFaculty, agent {AGENT}.", + instance.0, run.id.0 + ) +} + +/// What the child is told once, at the context ceiling. +pub const FLUSH_MESSAGE: &str = "The context ceiling is reached. Admit to the mind what it lacks, close your run, and end."; + +/// The session id of a run: UUIDv5 over the run's id. Derived, never stored. +pub fn session_id(run: &PipelineRef) -> Uuid { + Uuid::new_v5(&SESSION_NAMESPACE, run.id.0.as_bytes()) +} + +/// The argument list of the child, and nothing else: no resume, continue or +/// fork, no model, no permission mode and no settings belong to this actuator. +/// `budget_usd` is the run's, as stored. +pub fn claude_args(session: Uuid, budget_usd: &str) -> Vec { + [ + "-p", + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--verbose", + "--session-id", + &session.to_string(), + "--max-budget-usd", + budget_usd, + "--max-turns", + &MAX_TURNS.to_string(), + "--permission-prompts", + "none", + ] + .map(String::from) + .to_vec() +} + +/// One line of the child's stream, as far as the actuator reads it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Usage { + /// A request of the main thread: the message's id and the context it + /// read (input, cache read and cache write tokens). + Request { id: String, prefix: u64 }, + Result, + /// Valid JSON that is none of the above, or a request of a subagent. + Other, + /// Not a JSON object. + Unreadable, +} + +/// Reads one stream line. +pub fn read_usage(line: &str) -> Usage { + let Ok(Value::Object(event)) = serde_json::from_str::(line) else { return Usage::Unreadable }; + match event.get("type").and_then(Value::as_str) { + Some("result") => Usage::Result, + Some("assistant") if event.get("parent_tool_use_id").is_none_or(Value::is_null) => { + let message = event.get("message"); + let Some(id) = message.and_then(|message| message.get("id")).and_then(Value::as_str) else { return Usage::Other }; + let usage = message.and_then(|message| message.get("usage")); + let field = |name: &str| usage.and_then(|usage| usage.get(name)).and_then(Value::as_u64).unwrap_or(0); + // Saturating: a sum that overflows is at or over the ceiling, never a panic or a wrap. + let prefix = field("input_tokens").saturating_add(field("cache_read_input_tokens")).saturating_add(field("cache_creation_input_tokens")); + Usage::Request { id: id.to_string(), prefix } + } + _ => Usage::Other, + } +} + +/// What the child gave in one wait: the operating system's facts about it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Read { + Line(String), + Timeout, + /// Its stdout ended. + Eof, + /// It was reaped: its exit code, none when a signal ended it. + Exited(Option), +} + +/// The running `claude`. A test hands in a scripted one. +pub trait Child { + /// Writes one line to its input. A dead child's stream closes, which is + /// how `carry` learns of it, so a failed write is not an error here. + fn write_line(&mut self, line: &str); + /// Closes its input; later writes go nowhere. + fn close_input(&mut self); + fn next(&mut self, timeout: StdDuration) -> Read; + /// Sends the signal to its whole process group; whether it was delivered. + fn stop(&mut self, how: Stop) -> bool; +} + +/// The signals the actuator sends, weakest first. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Stop { + /// SIGINT, as an operator's Ctrl-C. + Interrupt, + Terminate, + Kill, +} + +impl Stop { + fn next(self) -> Self { + match self { + Self::Interrupt => Self::Terminate, + Self::Terminate | Self::Kill => Self::Kill, + } + } +} + +pub trait Spawner { + fn spawn(&self, args: &[String]) -> Result>; +} + +/// Why a run ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Cause { + /// The child ended with no flush asked for and no interrupt. + Own, + /// The ceiling was reached and the flush asked for; the child ended with no + /// signal. Whether it ran the flush is for the mind to say, not this label. + FlushRequested, + /// A signal was delivered, and this was the strongest one delivered. + Signalled(Stop), + /// Not even SIGKILL could be delivered: the child was not waited for. + Unstoppable, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SelfRunOutcome { + /// She has not exactly one open Self run: nothing was started. + NoRun, + Ended { cause: Cause, code: Option, requests: u64, prefix: u64 }, +} + +impl SelfRunOutcome { + /// 0 when the child exited 0, 3 when no child was started, else 1 (a + /// child killed by a signal has no code). + pub fn exit_status(&self) -> i32 { + match self { + Self::NoRun => 3, + Self::Ended { code: Some(0), .. } => 0, + Self::Ended { .. } => 1, + } + } +} + +impl fmt::Display for SelfRunOutcome { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoRun => write!(f, "no run: she has no single open Self run"), + Self::Ended { cause, code, requests, prefix } => { + let cause = match cause { + Cause::Own => "own", + Cause::FlushRequested => "flush-requested", + Cause::Signalled(Stop::Interrupt) => "interrupted", + Cause::Signalled(Stop::Terminate) => "terminated", + Cause::Signalled(Stop::Kill) => "killed", + Cause::Unstoppable => "unstoppable", + }; + match code { + Some(code) => write!(f, "ended: {cause} code {code} requests {requests} prefix {prefix}"), + None => write!(f, "ended: {cause} signal requests {requests} prefix {prefix}"), + } + } + } + } +} + +/// Her one open Self run and its budget, read through the launch primitive's +/// holder reader. Anything but exactly one run is none. +fn her_run(mind: &dyn MindPort) -> Result> { + let mut held = holders(mind, mind.instance(), RunTurn::SelfRun)?; + let (Some(run), None) = (held.pop(), held.pop()) else { return Ok(None) }; + let one = Selection { keys: Some(vec![run.id.id.0.clone()]), projection: "document".into(), ..Selection::default() }; + let PipelinePageItems::Documents(views) = mind.query(&one)?.items else { bail!("the mind answered headers to a document selection") }; + match views.into_iter().next().map(|view| view.document) { + Some(PipelineDocument::Run(document)) => Ok(Some((run.id, document.budget_usd.0))), + _ => Err(anyhow!("the run's document could not be read")), + } +} + +/// Carries her one open Self run: one `claude` child from the start message to +/// its end. The request lines go to `out`. A mind that cannot be reached or a +/// child that cannot be started is an error; every other end is an outcome. +/// This is only the loop: facts in, one clock read per fact, actions out. +pub fn carry(mind: &dyn MindPort, clock: &dyn Clock, spawner: &dyn Spawner, out: &mut dyn Write) -> Result { + let Some((run, budget)) = her_run(mind)? else { return Ok(SelfRunOutcome::NoRun) }; + let mut child = spawner.spawn(&claude_args(session_id(&run), &budget))?; + let user_line = |text: &str| json!({"type": "user", "message": {"role": "user", "content": text}}).to_string(); + child.write_line(&user_line(&start_message(mind.instance(), &run))); + + let mut state = State::default(); + let mut now = clock.now(); + loop { + let wait = next_deadline(&state).map_or(POLL, |at| (at - now).to_std().unwrap_or_default().min(POLL)); + let fact = match child.next(wait) { + Read::Line(line) => match read_usage(&line) { + Usage::Request { id, prefix } => Event::Request { id, prefix }, + Usage::Result => Event::Result, + Usage::Other | Usage::Unreadable => Event::Tick, + }, + Read::Timeout => Event::Tick, + Read::Eof => Event::StdoutEof, + Read::Exited(code) => Event::ChildExited(code), + }; + now = clock.now(); + let mut event = Some(fact); + while let Some(heard) = event.take() { + let counted = state.requests; + let (next, action) = decide(state, heard, now); + state = next; + if state.requests != counted { + writeln!(out, "request {} prefix {}", state.requests, state.prefix)?; + } + match action { + Action::None => {} + Action::WriteFlush => child.write_line(&user_line(FLUSH_MESSAGE)), + Action::CloseStdin => child.close_input(), + Action::Signal(how) => event = Some(Event::Signalled { how, delivered: child.stop(how) }), + Action::Finish(outcome) => return Ok(outcome), + } + } + } +} + +/// The real spawner: the `claude` on the unit's `PATH`, with the unit's +/// environment (the library names no variable), its stderr in the journal. +pub struct ClaudeSpawner; + +impl Spawner for ClaudeSpawner { + fn spawn(&self, args: &[String]) -> Result> { + spawn_program(Path::new("claude"), args) + } +} + +fn spawn_program(program: &Path, args: &[String]) -> Result> { + let mut command = Command::new(program); + command.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit()); + // Its own process group, so that a signal reaches what the child started too. + #[cfg(unix)] + std::os::unix::process::CommandExt::process_group(&mut command, 0); + let mut process = command.spawn().context("claude could not be started")?; + let group = process.id(); + let stdin = process.stdin.take(); + let stdout = process.stdout.take().ok_or_else(|| anyhow!("claude has no stdout"))?; + let (sender, facts) = channel(); + let lines = sender.clone(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).split(b'\n') { + let Ok(bytes) = line else { break }; + if lines.send(Read::Line(String::from_utf8_lossy(&bytes).into_owned())).is_err() { + return; + } + } + let _ = lines.send(Read::Eof); + }); + // The one waitpid: the child's end is a fact of its own, not inferred from its stream. + std::thread::spawn(move || { + let _ = sender.send(Read::Exited(process.wait().ok().and_then(|status| status.code()))); + }); + Ok(Box::new(ClaudeProcess { stdin, facts, group })) +} + +struct ClaudeProcess { + stdin: Option, + facts: Receiver, + /// The child's pid, which is its process group's id. + group: u32, +} + +impl Child for ClaudeProcess { + fn write_line(&mut self, line: &str) { + if let Some(stdin) = self.stdin.as_mut() { + let _ = stdin.write_all(line.as_bytes()).and_then(|()| stdin.write_all(b"\n")).and_then(|()| stdin.flush()); + } + } + + fn close_input(&mut self) { + self.stdin = None; + } + + fn next(&mut self, timeout: StdDuration) -> Read { + match self.facts.recv_timeout(timeout) { + Ok(fact) => fact, + Err(RecvTimeoutError::Timeout) => Read::Timeout, + Err(RecvTimeoutError::Disconnected) => Read::Eof, + } + } + + fn stop(&mut self, how: Stop) -> bool { + // The `kill` program, not a libc call: the library names no FFI. + let signal = match how { + Stop::Interrupt => "INT", + Stop::Terminate => "TERM", + Stop::Kill => "KILL", + }; + Command::new("kill").args(["-s", signal, "--", &format!("-{}", self.group)]).stderr(Stdio::null()).status().is_ok_and(|status| status.success()) + } +} + +mod decide; + +#[cfg(test)] +mod tests; diff --git a/crates/mind-body/src/self_run/claude-2.1.268-assistant-usage-records.jsonl b/crates/mind-body/src/self_run/claude-2.1.268-assistant-usage-records.jsonl new file mode 100644 index 0000000..fc0a08d --- /dev/null +++ b/crates/mind-body/src/self_run/claude-2.1.268-assistant-usage-records.jsonl @@ -0,0 +1,12 @@ +{"type": "assistant", "parent_tool_use_id": null, "message": {"id": "msg_011CgHNLSfEPL5Th8GSs7vkj", "model": "claude-opus-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 39033, "cache_read_input_tokens": 45175, "output_tokens": 73, "output_tokens_details": {"thinking_tokens": 22}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 39033, "ephemeral_5m_input_tokens": 0}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 73, "cache_read_input_tokens": 45175, "cache_creation_input_tokens": 39033, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 39033}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "thinking"}]}} +{"type": "assistant", "parent_tool_use_id": null, "message": {"id": "msg_011CgHNLSfEPL5Th8GSs7vkj", "model": "claude-opus-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 39033, "cache_read_input_tokens": 45175, "output_tokens": 73, "output_tokens_details": {"thinking_tokens": 22}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 39033, "ephemeral_5m_input_tokens": 0}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 73, "cache_read_input_tokens": 45175, "cache_creation_input_tokens": 39033, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 39033}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "tool_use"}]}} +{"type": "assistant", "parent_tool_use_id": null, "message": {"id": "msg_011CgHNLcAxtUhNghfqBmEjA", "model": "claude-opus-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 16541, "cache_read_input_tokens": 84208, "output_tokens": 136, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 16541, "ephemeral_5m_input_tokens": 0}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 136, "cache_read_input_tokens": 84208, "cache_creation_input_tokens": 16541, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 16541}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "tool_use"}]}} +{"type": "assistant", "parent_tool_use_id": null, "message": {"id": "msg_011CgHNLjffqfsnx2t6FFAh9", "model": "claude-opus-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 16423, "cache_read_input_tokens": 100749, "output_tokens": 282, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 16423, "ephemeral_5m_input_tokens": 0}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 282, "cache_read_input_tokens": 100749, "cache_creation_input_tokens": 16423, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 16423}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "tool_use"}]}} +{"type": "assistant", "parent_tool_use_id": null, "message": {"id": "msg_011CgHNLjffqfsnx2t6FFAh9", "model": "claude-opus-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 16423, "cache_read_input_tokens": 100749, "output_tokens": 282, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 16423, "ephemeral_5m_input_tokens": 0}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 282, "cache_read_input_tokens": 100749, "cache_creation_input_tokens": 16423, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 16423}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "tool_use"}]}} +{"type": "assistant", "parent_tool_use_id": null, "message": {"id": "msg_011CgHNMiXFAsUftWZLhShSJ", "model": "claude-opus-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 1162, "cache_read_input_tokens": 117172, "output_tokens": 303, "output_tokens_details": {"thinking_tokens": 26}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 1162, "ephemeral_5m_input_tokens": 0}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 303, "cache_read_input_tokens": 117172, "cache_creation_input_tokens": 1162, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 1162}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "thinking"}]}} +{"type": "assistant", "parent_tool_use_id": null, "message": {"id": "msg_011CgHNMiXFAsUftWZLhShSJ", "model": "claude-opus-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 1162, "cache_read_input_tokens": 117172, "output_tokens": 303, "output_tokens_details": {"thinking_tokens": 26}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 1162, "ephemeral_5m_input_tokens": 0}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 303, "cache_read_input_tokens": 117172, "cache_creation_input_tokens": 1162, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 1162}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "tool_use"}]}} +{"type": "assistant", "parent_tool_use_id": null, "message": {"id": "msg_011CgHNN8DmkfNuiMD3mFFg1", "model": "claude-opus-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 8410, "cache_read_input_tokens": 118334, "output_tokens": 299, "output_tokens_details": {"thinking_tokens": 166}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 8410, "ephemeral_5m_input_tokens": 0}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 299, "cache_read_input_tokens": 118334, "cache_creation_input_tokens": 8410, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 8410}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "thinking"}]}} +{"type": "assistant", "parent_tool_use_id": null, "message": {"id": "msg_011CgHNN8DmkfNuiMD3mFFg1", "model": "claude-opus-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 8410, "cache_read_input_tokens": 118334, "output_tokens": 299, "output_tokens_details": {"thinking_tokens": 166}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 8410, "ephemeral_5m_input_tokens": 0}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 299, "cache_read_input_tokens": 118334, "cache_creation_input_tokens": 8410, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 8410}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "tool_use"}]}} +{"type": "assistant", "parent_tool_use_id": null, "message": {"id": "msg_011CgHNNRZ46gcJVp99ra6zp", "model": "claude-opus-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 504, "cache_read_input_tokens": 126744, "output_tokens": 137, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 504, "ephemeral_5m_input_tokens": 0}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 137, "cache_read_input_tokens": 126744, "cache_creation_input_tokens": 504, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 504}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "text"}]}} +{"type": "assistant", "parent_tool_use_id": null, "message": {"id": "msg_011CgHNNRZ46gcJVp99ra6zp", "model": "claude-opus-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 504, "cache_read_input_tokens": 126744, "output_tokens": 137, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 504, "ephemeral_5m_input_tokens": 0}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 137, "cache_read_input_tokens": 126744, "cache_creation_input_tokens": 504, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 504}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "tool_use"}]}} +{"type": "assistant", "parent_tool_use_id": null, "message": {"id": "msg_011CgHNNaiDHie99GoJBYBDt", "model": "claude-opus-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 2603, "cache_read_input_tokens": 127248, "output_tokens": 553, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 2603, "ephemeral_5m_input_tokens": 0}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 553, "cache_read_input_tokens": 127248, "cache_creation_input_tokens": 2603, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 2603}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "tool_use"}]}} diff --git a/crates/mind-body/src/self_run/claude-2.1.268-stream-json-probe.jsonl b/crates/mind-body/src/self_run/claude-2.1.268-stream-json-probe.jsonl new file mode 100644 index 0000000..81e249c --- /dev/null +++ b/crates/mind-body/src/self_run/claude-2.1.268-stream-json-probe.jsonl @@ -0,0 +1,3 @@ +{"type":"system","subtype":"init","cwd":"C:\\Users\\Meta\\AppData\\Local\\Temp\\claude\\F--Projects-CultLib\\21443208-468a-45eb-ab1d-a731c1c79772\\scratchpad\\imag-self-run\\p1","session_id":"7a1c2d3e-4b5a-5c6d-8e7f-0a1b2c3d4e5f","tools":["Task","Bash","CronCreate","CronDelete","CronList","DesignSync","Edit","EnterWorktree","ExitWorktree","Glob","Grep","ListAgents","Monitor","NotebookEdit","PowerShell","PushNotification","Read","ReportFindings","ScheduleWakeup","SendMessage","Skill","TaskCreate","TaskGet","TaskList","TaskOutput","TaskStop","TaskUpdate","ToolSearch","WebFetch","WebSearch","Workflow","Write"],"mcp_servers":[],"model":"claude-haiku-4-5-20251001","permissionMode":"default","slash_commands":["deep-research","design","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","claude-api","workflow-authoring","run","run-skill-generator","plugin-authoring","advisor","agents","auto-mode-setup","autocompact","clear","color","compact","config","context","effort","fast","heapdump","init","mcp","import","model","__remote-workflow","workflow-launch-exec","plugin-types","reload-plugins","reload-skills","rename","security-review","usage","insights","recap","skill-doctor","goal","design-consent","design-revoke","list-agents","team-onboarding"],"terminal_slash_commands":["doctor","color","reload-plugins"],"apiKeySource":"none","claude_code_version":"2.1.268","output_style":"Eureka Self","agents":["claude","Explore","general-purpose","Plan"],"skills":["deep-research","design","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","claude-api","workflow-authoring","run","run-skill-generator","plugin-authoring"],"plugins":[{"name":"plugin-authoring","path":"builtin","source":"plugin-authoring@builtin"}],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"b8f04cea-6d04-476d-84e7-bd71a903691d","messaging_socket_path":"\\\\.\\pipe\\LOCAL\\cc-msg-08f60b361528ebff88fceca2e74f13fd","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","powershell_path":"C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"} +{"type":"assistant","message":{"diagnostics":null,"id":"3af6018f-a66e-4e87-a97a-8a534e41ac02","container":null,"model":"","role":"assistant","stop_details":null,"stop_reason":"stop_sequence","stop_sequence":"","type":"message","usage":{"output_tokens_details":null,"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":null,"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":null,"iterations":null,"speed":null},"content":[{"type":"text","text":"Failed to authenticate: OAuth session expired and could not be refreshed"}],"context_management":null},"parent_tool_use_id":null,"session_id":"7a1c2d3e-4b5a-5c6d-8e7f-0a1b2c3d4e5f","uuid":"39600172-ebb4-4b39-9a7e-160b49591050","timestamp":"2026-10-22T15:23:04.474Z","error":"authentication_failed","is_api_error_message":true} +{"duration_api_ms":0,"stop_reason":"stop_sequence","session_id":"7a1c2d3e-4b5a-5c6d-8e7f-0a1b2c3d4e5f","total_cost_usd":0,"usage":{"output_tokens_details":{"thinking_tokens":0},"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":true,"num_turns":1,"subtype":"success","api_error_status":null,"result":"Failed to authenticate: OAuth session expired and could not be refreshed","type":"result","duration_ms":222,"uuid":"41e16004-c8b3-4218-af94-69aa2fc52190","queued_turn_count":0,"result_index":0} diff --git a/crates/mind-body/src/self_run/decide.rs b/crates/mind-body/src/self_run/decide.rs new file mode 100644 index 0000000..e9f96e8 --- /dev/null +++ b/crates/mind-body/src/self_run/decide.rs @@ -0,0 +1,185 @@ +//! The Self-run actuator's lifecycle decision, and nothing else. +//! +//! `decide(state, event, now) -> (state, action)` is the one owner of when the +//! flush is asked for, when the stdin closes, when a signal goes out and when +//! the run is over (ruling `eureka-body:ruling:self-run-decider`). It performs +//! no I/O and reads no clock: the loop in `carry` turns operating-system facts +//! into events, reads the clock once per event, and executes the action. A +//! timing rule is pinned here, by tables and by an enumeration of event orders. +//! +//! The flush's turn begins at the first result after the flush was written (the +//! turn already running ends there; the queued flush is the next one). The +//! headroom counts only requests after that boundary, measured from the prefix +//! the flush was written at, and the grace from the flush write (ruling +//! `eureka-body:ruling:flush-always-gets-a-turn`, refined by the decider ruling). +//! At most one action comes out of an event; a deadline that is already due +//! yields its signal at the next event, and `next_deadline` says it is now. + +use std::collections::HashSet; + +use chrono::{DateTime, Duration, Utc}; + +use super::{CEILING_TOKENS, Cause, ESCALATE_AFTER_S, EXIT_DRAIN_S, FLUSH_GRACE_S, FLUSH_HEADROOM_TOKENS, SelfRunOutcome, Stop}; + +/// What the loop saw, already turned into a fact the decision can use. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Event { + /// A request of the main thread: its message id and the context it read. + Request { id: String, prefix: u64 }, + Result, + /// `waitpid` returned: the child's exit code, none when a signal ended it. + ChildExited(Option), + StdoutEof, + /// Time passed, or a line that no rule reads. + Tick, + /// What the operating system said of the signal the last action sent. + Signalled { how: Stop, delivered: bool }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Action { + None, + WriteFlush, + CloseStdin, + /// To the child's whole process group. + Signal(Stop), + Finish(SelfRunOutcome), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Flush { + /// The prefix at which the flush was written: the headroom's base. + prefix: u64, + written: DateTime, + /// A result has come since the write: the flush is the next turn. + turn_begun: bool, + /// A request of the flush's turn reached the headroom. + exhausted: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(super) struct State { + seen: HashSet, + pub(super) requests: u64, + pub(super) prefix: u64, + result_seen: bool, + flush: Option, + /// The last signal attempted and when. + stopping: Option<(Stop, DateTime)>, + /// The strongest signal delivered. + delivered: Option, + exited: Option<(Option, DateTime)>, + eof: bool, + finished: bool, +} + +fn outcome(state: &State, cause: Cause, code: Option) -> SelfRunOutcome { + SelfRunOutcome::Ended { cause, code, requests: state.requests, prefix: state.prefix } +} + +/// How the run ended, by what was done to the child: the strongest signal delivered, else the flush, else nothing. +fn cause(state: &State) -> Cause { + match (state.delivered, &state.flush) { + (Some(how), _) => Cause::Signalled(how), + (None, Some(_)) => Cause::FlushRequested, + (None, None) => Cause::Own, + } +} + +/// The moment the loop must next be heard at, if any. A moment already past +/// means an action is due now. +pub(super) fn next_deadline(state: &State) -> Option> { + if state.finished { + return None; + } + if let Some((_, exited)) = state.exited { + return (!state.eof).then(|| exited + Duration::seconds(EXIT_DRAIN_S)); + } + match (&state.stopping, &state.flush) { + (Some((_, sent)), _) => Some(*sent + Duration::seconds(ESCALATE_AFTER_S)), + (None, Some(flush)) => Some(flush.written + Duration::seconds(FLUSH_GRACE_S)), + (None, None) => None, + } +} + +pub(super) fn decide(mut state: State, event: Event, now: DateTime) -> (State, Action) { + if state.finished { + return (state, Action::None); + } + let mut action = Action::None; + match event { + Event::Request { id, prefix } => { + if state.seen.insert(id) { + state.requests += 1; + state.prefix = prefix; + if let Some(flush) = state.flush.as_mut() + && flush.turn_begun + && flush.prefix.checked_add(FLUSH_HEADROOM_TOKENS).is_some_and(|limit| prefix >= limit) + { + flush.exhausted = true; + } + if state.flush.is_none() && !state.result_seen && state.exited.is_none() && prefix >= CEILING_TOKENS { + state.flush = Some(Flush { prefix, written: now, turn_begun: false, exhausted: false }); + action = Action::WriteFlush; + } + } + } + Event::Result => { + if !state.result_seen { + state.result_seen = true; + if let Some(flush) = state.flush.as_mut() { + flush.turn_begun = true; + } + action = Action::CloseStdin; + } + } + Event::ChildExited(code) => { + state.exited.get_or_insert((code, now)); + } + Event::StdoutEof => state.eof = true, + Event::Tick => {} + Event::Signalled { how, delivered } => { + if delivered { + state.delivered = Some(how); + } + } + } + + // A child that has exited is never written to or signalled: the run is over + // once its stream has ended, or once the stream has had its drain. + if let Some((code, exited)) = state.exited { + if state.eof || now >= exited + Duration::seconds(EXIT_DRAIN_S) { + state.finished = true; + let outcome = outcome(&state, cause(&state), code); + return (state, Action::Finish(outcome)); + } + return (state, Action::None); + } + if action != Action::None { + return (state, action); + } + if let Some((Stop::Kill, sent)) = state.stopping { + // SIGKILL went out and the child still has not been reaped: stop waiting for it. + if now >= sent + Duration::seconds(ESCALATE_AFTER_S) { + state.finished = true; + let cause = if state.delivered == Some(Stop::Kill) { Cause::Signalled(Stop::Kill) } else { Cause::Unstoppable }; + let outcome = outcome(&state, cause, None); + return (state, Action::Finish(outcome)); + } + return (state, Action::None); + } + let how = match (&state.stopping, &state.flush) { + (Some((last, sent)), _) => (now >= *sent + Duration::seconds(ESCALATE_AFTER_S)).then(|| last.next()), + (None, Some(flush)) => (flush.exhausted || now >= flush.written + Duration::seconds(FLUSH_GRACE_S)).then_some(Stop::Interrupt), + (None, None) => None, + }; + match how { + Some(how) => { + state.stopping = Some((how, now)); + (state, Action::Signal(how)) + } + None => (state, Action::None), + } +} +#[cfg(test)] +mod tests; diff --git a/crates/mind-body/src/self_run/decide/tests.rs b/crates/mind-body/src/self_run/decide/tests.rs new file mode 100644 index 0000000..814abd2 --- /dev/null +++ b/crates/mind-body/src/self_run/decide/tests.rs @@ -0,0 +1,443 @@ +//! `decide` is pure, so its rules are pinned here with no child, no mind and no +//! clock: tables, one row per boundary, and an enumeration of event orders that +//! asserts the invariants against an oracle kept from the event history alone. + +use super::*; +use crate::testkit::at; + +fn t(seconds: i64) -> DateTime { + at(seconds * 1000) +} + +fn req(id: &str, prefix: u64) -> Event { + Event::Request { id: id.to_string(), prefix } +} + +fn feed(state: State, event: Event, seconds: i64) -> (State, Action) { + decide(state, event, t(seconds)) +} + +/// Runs the events in order, each at its own second, and keeps every action. +fn run(events: Vec<(Event, i64)>) -> (State, Vec) { + let mut state = State::default(); + let mut actions = Vec::new(); + for (event, seconds) in events { + let (next, action) = feed(state, event, seconds); + state = next; + actions.push(action); + } + (state, actions) +} + +fn last(events: Vec<(Event, i64)>) -> Action { + run(events).1.pop().unwrap() +} + +fn delivered(how: Stop) -> Event { + Event::Signalled { how, delivered: true } +} + +fn ended(cause: Cause, code: Option, requests: u64, prefix: u64) -> Action { + Action::Finish(SelfRunOutcome::Ended { cause, code, requests, prefix }) +} + +const GRACE: i64 = FLUSH_GRACE_S; +const ESC: i64 = ESCALATE_AFTER_S; +const LIMIT: u64 = CEILING_TOKENS + FLUSH_HEADROOM_TOKENS; + +#[test] +fn the_flush_is_asked_once_at_the_ceiling_and_never_after_a_result_or_an_exit_or_for_a_repeated_request() { + assert_eq!(last(vec![(req("a", CEILING_TOKENS - 1), 0)]), Action::None); + assert_eq!(last(vec![(req("a", CEILING_TOKENS), 0)]), Action::WriteFlush); + assert_eq!(last(vec![(req("a", CEILING_TOKENS + 1), 0)]), Action::WriteFlush); + // Once. + assert_eq!(last(vec![(req("a", CEILING_TOKENS), 0), (req("b", CEILING_TOKENS + 5), 1)]), Action::None); + // A result first: the child is finishing, there is nothing to flush. + assert_eq!(last(vec![(Event::Result, 0), (req("a", CEILING_TOKENS), 1)]), Action::None); + // A child that is gone is not written to. + assert_eq!(last(vec![(Event::ChildExited(Some(0)), 0), (req("a", CEILING_TOKENS), 1)]), Action::None); + // The same message id is the same request again: it neither counts nor decides. + let (state, actions) = run(vec![(req("a", 10), 0), (req("a", CEILING_TOKENS), 1)]); + assert_eq!((actions[1].clone(), state.requests, state.prefix), (Action::None, 1, 10)); +} + +#[test] +fn the_input_closes_once_at_the_first_result() { + assert_eq!(last(vec![(Event::Result, 0)]), Action::CloseStdin); + assert_eq!(last(vec![(Event::Result, 0), (Event::Result, 1)]), Action::None); + assert_eq!(last(vec![(req("a", CEILING_TOKENS), 0), (Event::Result, 1)]), Action::CloseStdin); +} + +#[test] +fn the_grace_runs_from_the_flush_write_to_the_second_and_no_later_request_moves_it() { + let flushed = |rest: Vec<(Event, i64)>| last([vec![(req("a", 10), 0), (req("b", CEILING_TOKENS), 100)], rest].concat()); + assert_eq!(flushed(vec![(Event::Tick, 100 + GRACE - 1)]), Action::None); + assert_eq!(flushed(vec![(Event::Tick, 100 + GRACE)]), Action::Signal(Stop::Interrupt)); + assert_eq!(flushed(vec![(Event::Tick, 100 + GRACE + 5000)]), Action::Signal(Stop::Interrupt)); + // Requests after the flush, however late, do not refresh it. + assert_eq!(flushed(vec![(req("c", CEILING_TOKENS + 1), 100 + GRACE - 10), (Event::Tick, 100 + GRACE)]), Action::Signal(Stop::Interrupt)); + // Not from the first request, not from the start. + assert_eq!(last(vec![(req("a", 10), 0), (req("b", CEILING_TOKENS), 100), (Event::Tick, GRACE)]), Action::None); + // The deadline the loop waits for is the same moment. + let (state, _) = run(vec![(req("a", CEILING_TOKENS), 100)]); + assert_eq!(next_deadline(&state), Some(t(100 + GRACE))); + assert_eq!(next_deadline(&State::default()), None); +} + +#[test] +fn the_headroom_counts_the_requests_after_the_first_result_after_the_flush_from_the_prefix_of_the_flush() { + let after_result = |request: u64| last(vec![(req("a", CEILING_TOKENS), 0), (Event::Result, 1), (req("b", request), 2)]); + assert_eq!(after_result(LIMIT - 1), Action::None); + assert_eq!(after_result(LIMIT), Action::Signal(Stop::Interrupt)); + assert_eq!(after_result(LIMIT + 1), Action::Signal(Stop::Interrupt)); + // Measured from the flush's own prefix, not from the ceiling. + let high = |request: u64| last(vec![(req("a", 400_000), 0), (Event::Result, 1), (req("b", request), 2)]); + assert_eq!((high(459_999), high(460_000)), (Action::None, Action::Signal(Stop::Interrupt))); + // The turn already running is not the flush's turn: before a result no request counts. + assert_eq!(last(vec![(req("a", CEILING_TOKENS), 0), (req("b", LIMIT + 400_000), 1)]), Action::None); + // A big request before the result is forgotten by the boundary; only those after it count. + assert_eq!(last(vec![(req("a", CEILING_TOKENS), 0), (req("b", LIMIT + 1), 1), (Event::Result, 2), (req("c", LIMIT - 1), 3)]), Action::None); + // The result before the flush is not the boundary: the flush cannot be written then. + assert_eq!(last(vec![(Event::Result, 0), (req("a", CEILING_TOKENS), 1), (req("b", 900_000), 2)]), Action::None); + // A prefix that saturates leaves no headroom to count: only the grace is left. + let saturated = |rest: Vec<(Event, i64)>| last([vec![(req("a", u64::MAX), 0), (Event::Result, 1), (req("b", u64::MAX), 2)], rest].concat()); + assert_eq!(saturated(vec![]), Action::None); + assert_eq!(saturated(vec![(Event::Tick, GRACE)]), Action::Signal(Stop::Interrupt)); + // A request counted past the headroom stays counted: a later smaller one does not take the interrupt back. + let (state, actions) = run(vec![(req("a", CEILING_TOKENS), 0), (Event::Result, 1), (req("b", LIMIT), 2)]); + assert_eq!(actions[2], Action::Signal(Stop::Interrupt)); + assert_eq!(next_deadline(&state), Some(t(2 + ESC))); +} + +#[test] +fn the_flush_pass_never_signals_whatever_the_clock_does() { + // Two events apart by more than the grace: the write is its own pass, the signal comes in the next. + let (state, action) = feed(State::default(), req("a", CEILING_TOKENS), 0); + assert_eq!(action, Action::WriteFlush); + assert_eq!(feed(state, Event::Tick, 10 * GRACE).1, Action::Signal(Stop::Interrupt)); + // A request that both crosses the ceiling and is far past the headroom writes the flush and nothing else. + assert_eq!(last(vec![(req("a", u64::MAX), 0)]), Action::WriteFlush); +} + +#[test] +fn each_signal_follows_the_last_by_the_bound_and_the_run_ends_when_the_kill_has_had_its_time() { + let interrupted = vec![(req("a", CEILING_TOKENS), 0), (Event::Tick, GRACE), (delivered(Stop::Interrupt), GRACE)]; + let then = |rest: Vec<(Event, i64)>| last([interrupted.clone(), rest].concat()); + assert_eq!(then(vec![(Event::Tick, GRACE + ESC - 1)]), Action::None); + assert_eq!(then(vec![(Event::Tick, GRACE + ESC)]), Action::Signal(Stop::Terminate)); + // The bound runs from the last signal, not from the first or from the flush. + let late_term = [interrupted.clone(), vec![(Event::Tick, GRACE + ESC + 30), (delivered(Stop::Terminate), GRACE + ESC + 30)]].concat(); + let kill_at = |seconds: i64| last([late_term.clone(), vec![(Event::Tick, seconds)]].concat()); + assert_eq!(kill_at(GRACE + 2 * ESC + 29), Action::None); + assert_eq!(kill_at(GRACE + 2 * ESC + 30), Action::Signal(Stop::Kill)); + // After the kill: the child may still be reaped until the bound, then the run ends without it. + let killed = [late_term, vec![(Event::Tick, GRACE + 2 * ESC + 30), (delivered(Stop::Kill), GRACE + 2 * ESC + 30)]].concat(); + let after = |seconds: i64| last([killed.clone(), vec![(Event::Tick, seconds)]].concat()); + let end = GRACE + 3 * ESC + 30; + assert_eq!(after(end - 1), Action::None); + assert_eq!(after(end), ended(Cause::Signalled(Stop::Kill), None, 1, CEILING_TOKENS)); + // A kill that was not delivered, with the child still there at the bound: Unstoppable, whatever else got through. + let refused_kill = [killed[..killed.len() - 1].to_vec(), vec![(Event::Signalled { how: Stop::Kill, delivered: false }, GRACE + 2 * ESC + 30)]].concat(); + assert_eq!(last([refused_kill, vec![(Event::Tick, end)]].concat()), ended(Cause::Unstoppable, None, 1, CEILING_TOKENS)); +} + +#[test] +fn a_kill_that_was_never_delivered_ends_the_run_as_unstoppable_when_nothing_was_delivered_at_all() { + let all_refused = |how: Stop| Event::Signalled { how, delivered: false }; + let (state, _) = run(vec![ + (req("a", CEILING_TOKENS), 0), + (Event::Tick, GRACE), + (all_refused(Stop::Interrupt), GRACE), + (Event::Tick, GRACE + ESC), + (all_refused(Stop::Terminate), GRACE + ESC), + (Event::Tick, GRACE + 2 * ESC), + (all_refused(Stop::Kill), GRACE + 2 * ESC), + ]); + assert_eq!(next_deadline(&state), Some(t(GRACE + 3 * ESC))); + assert_eq!(feed(state.clone(), Event::Tick, GRACE + 3 * ESC - 1).1, Action::None); + assert_eq!(feed(state, Event::Tick, GRACE + 3 * ESC).1, ended(Cause::Unstoppable, None, 1, CEILING_TOKENS)); +} + +#[test] +fn a_child_that_ends_while_the_kill_was_in_flight_ends_the_run_by_its_own_cause_not_unstoppable() { + // The kill was refused because the child was already gone; the reap is on its way. + let (state, _) = run(vec![ + (req("a", CEILING_TOKENS), 0), + (Event::Tick, GRACE), + (delivered(Stop::Interrupt), GRACE), + (Event::Tick, GRACE + ESC), + (Event::Tick, GRACE + 2 * ESC), + (Event::Signalled { how: Stop::Kill, delivered: false }, GRACE + 2 * ESC), + (Event::ChildExited(Some(0)), GRACE + 2 * ESC + 1), + ]); + assert_eq!(feed(state, Event::StdoutEof, GRACE + 2 * ESC + 1).1, ended(Cause::Signalled(Stop::Interrupt), Some(0), 1, CEILING_TOKENS)); +} + +#[test] +fn a_child_that_has_exited_is_never_signalled_and_the_run_ends_with_its_stream_or_after_the_drain() { + // A flush asked for, the grace long past, the child already gone: no signal, only the end. + let gone = vec![(req("a", CEILING_TOKENS), 0), (Event::ChildExited(Some(0)), 10)]; + let tick = |seconds: i64| last([gone.clone(), vec![(Event::Tick, seconds)]].concat()); + assert_eq!(tick(11), Action::None); + assert_eq!(tick(10 + EXIT_DRAIN_S - 1), Action::None); + assert_eq!(tick(10 + EXIT_DRAIN_S), ended(Cause::FlushRequested, Some(0), 1, CEILING_TOKENS)); + assert_eq!(tick(10 + GRACE * 4), ended(Cause::FlushRequested, Some(0), 1, CEILING_TOKENS)); + // With the stream ended too, it ends at once, in either order. + assert_eq!(last([gone.clone(), vec![(Event::StdoutEof, 11)]].concat()), ended(Cause::FlushRequested, Some(0), 1, CEILING_TOKENS)); + assert_eq!(last(vec![(Event::StdoutEof, 0), (Event::ChildExited(None), 1)]), ended(Cause::Own, None, 0, 0)); + // The end of the stream alone is not the end of the child. + assert_eq!(last(vec![(Event::StdoutEof, 0), (Event::Tick, 5)]), Action::None); + // No flush, a signal's cause named by what was delivered, a code from the exit. + let (state, _) = run(vec![(req("a", CEILING_TOKENS), 0), (Event::Tick, GRACE), (delivered(Stop::Interrupt), GRACE), (Event::ChildExited(Some(130)), GRACE + 1)]); + assert_eq!(feed(state, Event::StdoutEof, GRACE + 1).1, ended(Cause::Signalled(Stop::Interrupt), Some(130), 1, CEILING_TOKENS)); + // The deadline of a child that has gone is the end of its drain. + let (state, _) = run(vec![(Event::ChildExited(Some(0)), 7)]); + assert_eq!(next_deadline(&state), Some(t(7 + EXIT_DRAIN_S))); +} + +#[test] +fn without_a_flush_nothing_is_ever_signalled_and_a_finished_run_decides_nothing_more() { + let quiet = vec![(req("a", 10), 0), (Event::Result, 1), (req("b", 900_000), 2), (Event::Tick, 100_000)]; + assert!(run(quiet).1.iter().all(|action| !matches!(action, Action::Signal(_)))); + let (state, actions) = run(vec![(Event::ChildExited(Some(3)), 0), (Event::StdoutEof, 0)]); + assert_eq!(actions[1], ended(Cause::Own, Some(3), 0, 0)); + assert_eq!(next_deadline(&state), None); + assert_eq!(feed(state, Event::Tick, 10_000).1, Action::None); +} + +// ---- every order of events ------------------------------------------------------ + +/// What can happen next, each at a time after the last. +#[derive(Clone, Copy, Debug)] +enum Sym { + Low, + AtCeiling, + Huge, + Repeat, + Result, + Exit(Option), + Eof, + Soon, + Bound, + Grace, +} + +const ALPHABET: [Sym; 11] = [ + Sym::Low, + Sym::AtCeiling, + Sym::Huge, + Sym::Repeat, + Sym::Result, + Sym::Exit(Some(0)), + Sym::Exit(None), + Sym::Eof, + Sym::Soon, + Sym::Bound, + Sym::Grace, +]; + +/// Which signals the operating system lets through. +#[derive(Clone, Copy, Debug)] +enum Policy { + All, + Nothing, + NoKill, +} + +/// The facts of the history, kept apart from `State`: what the invariants are asked of. +#[derive(Default)] +struct Oracle { + ids: Vec, + flush: Option<(i64, u64)>, + flushes: u32, + closes: u32, + boundary: bool, + exceeded: bool, + result_seen: bool, + exited: Option, + eof: bool, + signals: Vec<(Stop, i64)>, + finished: bool, +} + +fn check_step(oracle: &mut Oracle, event: &Event, now: i64, action: &Action) { + assert!(!oracle.finished || *action == Action::None, "an action after the run ended: {action:?} on {event:?}"); + let flush_before = oracle.flush; + let (was_result, was_exited) = (oracle.result_seen, oracle.exited.is_some()); + match event { + Event::Request { id, prefix } => { + if !oracle.ids.contains(id) { + oracle.ids.push(id.clone()); + if oracle.boundary + && let Some((_, base)) = oracle.flush + && base.checked_add(FLUSH_HEADROOM_TOKENS).is_some_and(|limit| *prefix >= limit) + { + oracle.exceeded = true; + } + } + } + Event::Result => { + if oracle.flush.is_some() { + oracle.boundary = true; + } + oracle.result_seen = true; + } + Event::ChildExited(_) => { + oracle.exited.get_or_insert(now); + } + Event::StdoutEof => oracle.eof = true, + Event::Tick | Event::Signalled { .. } => {} + } + match action { + Action::None => {} + Action::WriteFlush => { + oracle.flushes += 1; + let Event::Request { prefix, .. } = event else { panic!("a flush on {event:?}") }; + assert!(*prefix >= CEILING_TOKENS && !was_result && !was_exited && flush_before.is_none(), "flush at {prefix} after {was_result} {was_exited}"); + oracle.flush = Some((now, *prefix)); + } + Action::CloseStdin => { + oracle.closes += 1; + assert!(matches!(event, Event::Result) && !was_result && !was_exited); + } + Action::Signal(how) => { + let (flushed_at, _) = flush_before.expect("a signal before the flush was written"); + assert!(oracle.exited.is_none(), "a signal to a child that already exited"); + match oracle.signals.last() { + None => { + assert_eq!(*how, Stop::Interrupt); + assert!(now >= flushed_at + GRACE || oracle.exceeded, "interrupt before the flush had its turn: now {now}, flush {flushed_at}, exceeded {}", oracle.exceeded); + assert!(now > flushed_at || oracle.exceeded, "interrupt in the pass that wrote the flush"); + } + Some((last, sent)) => { + assert_eq!(*how, last.next(), "escalation out of order"); + assert_ne!(*last, Stop::Kill, "a signal after the kill"); + assert!(now >= sent + ESC, "escalated after {} s, bound {ESC}", now - sent); + } + } + oracle.signals.push((*how, now)); + } + Action::Finish(outcome) => { + oracle.finished = true; + let SelfRunOutcome::Ended { cause, code, .. } = outcome else { panic!("{outcome:?}") }; + match oracle.exited { + Some(at) => assert!(oracle.eof || now >= at + EXIT_DRAIN_S, "finished before the stream or the drain"), + None => { + let (last, sent) = *oracle.signals.last().expect("finished with the child alive and no signal sent"); + assert!(last == Stop::Kill && now >= sent + ESC, "finished with the child alive"); + assert_eq!(*code, None); + assert!(matches!(cause, Cause::Signalled(Stop::Kill) | Cause::Unstoppable), "{cause:?}"); + } + } + if matches!(cause, Cause::Own) { + assert!(oracle.flush.is_none() && oracle.signals.is_empty()); + } + if matches!(cause, Cause::FlushRequested) { + assert!(oracle.flush.is_some()); + } + } + } + assert!(oracle.flushes <= 1 && oracle.closes <= 1); + assert!(oracle.signals.len() <= 3); +} + +fn play(symbols: &[Sym], policy: Policy) { + let mut state = State::default(); + let mut oracle = Oracle::default(); + let mut now = 0i64; + let mut unique = 0u32; + let mut events: Vec = Vec::new(); + for symbol in symbols { + let (event, dt) = match symbol { + Sym::Low => (req_new(&mut unique, 1_000), 1), + Sym::AtCeiling => (req_new(&mut unique, CEILING_TOKENS), 1), + Sym::Huge => (req_new(&mut unique, LIMIT + 400_000), 1), + Sym::Repeat => (req("u1", CEILING_TOKENS + 7), 1), + Sym::Result => (Event::Result, 1), + Sym::Exit(code) => (Event::ChildExited(*code), 1), + Sym::Eof => (Event::StdoutEof, 1), + Sym::Soon => (Event::Tick, 1), + Sym::Bound => (Event::Tick, ESC + 1), + Sym::Grace => (Event::Tick, GRACE + 1), + }; + now += dt; + events.push(event.clone()); + let mut next = Some(event); + while let Some(event) = next.take() { + let (after, action) = decide(state, event.clone(), t(now)); + state = after; + check_step(&mut oracle, &event, now, &action); + if let Action::Signal(how) = action { + let delivered = match policy { + Policy::All => true, + Policy::Nothing => false, + Policy::NoKill => how != Stop::Kill, + }; + next = Some(Event::Signalled { how, delivered }); + } + } + } + // No spinning: a deadline that has come is never answered with nothing. + if let Some(deadline) = next_deadline(&state) { + let at = deadline.max(t(now)); + let (_, action) = decide(state.clone(), Event::Tick, at); + assert_ne!(action, Action::None, "a due deadline gave no action: {symbols:?} {policy:?}"); + } + // Once the child has exited and its stream has ended, the run is over, whatever came before. + for event in [Event::ChildExited(Some(0)), Event::StdoutEof] { + now += 1; + let mut next = Some(event); + while let Some(event) = next.take() { + let (after, action) = decide(state, event.clone(), t(now)); + state = after; + check_step(&mut oracle, &event, now, &action); + if let Action::Signal(how) = action { + next = Some(Event::Signalled { how, delivered: true }); + } + } + } + assert!(oracle.finished, "no end after the exit and the end of the stream: {symbols:?} {policy:?} {events:?}"); +} + +fn req_new(unique: &mut u32, prefix: u64) -> Event { + *unique += 1; + req(&format!("u{unique}"), prefix) +} + +fn enumerate(length: usize, symbols: &mut Vec, policy: Policy) { + if symbols.len() == length { + play(symbols, policy); + return; + } + for symbol in ALPHABET { + symbols.push(symbol); + enumerate(length, symbols, policy); + symbols.pop(); + } +} + +#[test] +fn every_order_of_up_to_five_events_keeps_the_invariants_under_every_policy_of_delivery() { + for policy in [Policy::All, Policy::Nothing, Policy::NoKill] { + for length in 0..=5 { + enumerate(length, &mut Vec::new(), policy); + } + } +} + +#[test] +fn a_long_random_order_of_events_keeps_the_invariants_and_always_ends_once_the_child_is_gone() { + // xorshift: the same orders every run. + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for round in 0..40_000 { + let length = 6 + (next() % 30) as usize; + let symbols: Vec = (0..length).map(|_| ALPHABET[(next() % ALPHABET.len() as u64) as usize]).collect(); + play(&symbols, [Policy::All, Policy::Nothing, Policy::NoKill][round % 3]); + } +} diff --git a/crates/mind-body/src/self_run/tests.rs b/crates/mind-body/src/self_run/tests.rs new file mode 100644 index 0000000..5399d34 --- /dev/null +++ b/crates/mind-body/src/self_run/tests.rs @@ -0,0 +1,1087 @@ +//! The actuator's tests: a real mind over a temporary directory holds her run, +//! a scripted `Child` plays the stream and a settable clock plays time. The +//! stream lines are built from Claude Code 2.1.268's own records (the two +//! fixture files beside this one), varying only ids and usage numbers. The real +//! `ClaudeProcess` is driven through a fake `claude` script on Unix. + +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::rc::Rc; + +use chrono::Duration as ChronoDuration; +use eureka_pipeline::{PipelineDocument, PipelineKind as K, PipelineRef, RunOperator, RunTurn, Slug}; +use huginn_mind::{PipelineAdmissionOutcome, PipelineSelectionPage}; + +use super::*; +use crate::testkit::*; + +const RECORDS: &str = include_str!("claude-2.1.268-assistant-usage-records.jsonl"); +const PROBE: &str = include_str!("claude-2.1.268-stream-json-probe.jsonl"); + +// Pinned against Python's uuid.uuid5(uuid.NAMESPACE_OID, ), not against the code under test. +const SESSION_OF_MIND_RUN: &str = "00cb82a4-c0ac-5ad4-83e2-8e685589fb67"; +const SESSION_OF_RUN_A: &str = "d6db8da1-224c-5430-91b5-17aa4df8ae7a"; +const SESSION_OF_RUN_B: &str = "546bec88-f7a9-5fa0-bf07-0fada54f071b"; + +fn foreign_run(id: &str) -> PipelineRef { + PipelineRef { kind: K::Run, id: id.into() } +} + +/// The first real assistant record with its id and usage replaced. +fn request(id: &str, input: u64, read: u64, write: u64) -> String { + let mut event: Value = serde_json::from_str(RECORDS.lines().next().unwrap()).unwrap(); + event["message"]["id"] = json!(id); + event["message"]["usage"]["input_tokens"] = json!(input); + event["message"]["usage"]["cache_read_input_tokens"] = json!(read); + event["message"]["usage"]["cache_creation_input_tokens"] = json!(write); + event.to_string() +} + +/// A request whose whole prefix is `prefix`, under a message id of its own. +fn at(id: &str, prefix: u64) -> String { + request(id, 0, prefix, 0) +} + +fn subagent(id: &str, prefix: u64) -> String { + let mut event: Value = serde_json::from_str(&at(id, prefix)).unwrap(); + event["parent_tool_use_id"] = json!("toolu_0001"); + event.to_string() +} + +fn result_line() -> String { + PROBE.lines().nth(2).unwrap().to_string() +} + +fn init_line() -> String { + PROBE.lines().next().unwrap().to_string() +} + +fn self_run(label: &str, budget: &str) -> PipelineDocument { + let PipelineDocument::Run(mut document) = run(label, RunTurn::SelfRun, RunOperator::Mind, &[]) else { unreachable!() }; + document.budget_usd = budget.into(); + PipelineDocument::Run(document) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Event { + Wrote(String), + InputClosed, + /// A signal was attempted, delivered or not. + Stopped(Stop), + Asked(StdDuration), +} + +#[derive(Clone)] +enum Step { + Line(String), + /// Time passes with no output: the clock moves and the read times out. + Idle(i64), + /// The clock moves, then this line arrives. + After(i64, String), + /// The child is reaped with this code; its stream stays open. + Exit(Option), + /// Its stdout ends; the child is not reaped yet. + Eof, +} + +use Step::{Eof, Exit, Idle, Line as Out}; + +struct Scripted { + steps: VecDeque, + log: Rc>>, + clock: Rc, + code: Option, + /// The weakest signal that ends it; weaker ones are ignored. + dies_on: Option, + /// Signals that cannot be delivered (no `kill` program, say). + refuses: Vec, + /// When the steps run out it neither exits nor closes its stream: time just passes. + hangs: bool, + exited: bool, + eof: bool, + /// How many times it was asked: a loop that never ends is a failure, not a hang. + asked: u32, +} + +impl Child for Scripted { + fn write_line(&mut self, line: &str) { + self.log.borrow_mut().push(Event::Wrote(line.to_string())); + } + + fn close_input(&mut self) { + self.log.borrow_mut().push(Event::InputClosed); + } + + fn next(&mut self, timeout: StdDuration) -> Read { + self.log.borrow_mut().push(Event::Asked(timeout)); + self.asked += 1; + assert!(self.asked < 500, "the loop never ended"); + match self.steps.pop_front() { + Some(Step::Line(line)) => Read::Line(line), + Some(Step::Idle(seconds)) => { + self.clock.0.set(self.clock.0.get() + ChronoDuration::seconds(seconds)); + Read::Timeout + } + Some(Step::After(seconds, line)) => { + self.clock.0.set(self.clock.0.get() + ChronoDuration::seconds(seconds)); + Read::Line(line) + } + Some(Step::Exit(code)) => { + self.exited = true; + Read::Exited(code) + } + Some(Step::Eof) => { + self.eof = true; + Read::Eof + } + // The stream is over: the child is reaped, then its stdout ends. + None if self.hangs => { + self.clock.0.set(self.clock.0.get() + ChronoDuration::seconds(timeout.as_secs() as i64)); + Read::Timeout + } + None if !self.exited => { + self.exited = true; + Read::Exited(self.code) + } + None if !self.eof => { + self.eof = true; + Read::Eof + } + None => panic!("the loop kept waiting on a child that ended"), + } + } + + fn stop(&mut self, how: Stop) -> bool { + self.log.borrow_mut().push(Event::Stopped(how)); + if self.refuses.contains(&how) { + return false; + } + if self.dies_on.is_some_and(|weakest| how >= weakest) { + self.steps.clear(); + self.hangs = false; + } + true + } +} + +struct Fake { + child: RefCell>, + spawns: RefCell>>, + refuse: bool, +} + +impl Spawner for Fake { + fn spawn(&self, args: &[String]) -> Result> { + self.spawns.borrow_mut().push(args.to_vec()); + if self.refuse { + return Err(anyhow!("refused")); + } + Ok(Box::new(self.child.borrow_mut().take().expect("spawned twice"))) + } +} + +/// The mind as the actuator sees it, over a `TestMind`: another instance's name, +/// doubled run headers, and a count of admissions it must never receive. +struct Seen<'a> { + inner: &'a TestMind, + instance: Slug, + doubled: bool, + admits: Cell, +} + +impl<'a> Seen<'a> { + fn of(inner: &'a TestMind) -> Self { + Self { inner, instance: inner.instance().clone(), doubled: false, admits: Cell::new(0) } + } +} + +impl MindPort for Seen<'_> { + fn instance(&self) -> &Slug { + &self.instance + } + + fn query(&self, selection: &Selection) -> Result { + let mut page = self.inner.query(selection)?; + if self.doubled + && let PipelinePageItems::Headers(items) = &mut page.items + { + let again = items.clone(); + items.extend(again); + } + Ok(page) + } + + fn admit(&self, _agent: &str, _session: &str, _documents: Vec) -> Result { + self.admits.set(self.admits.get() + 1); + Err(anyhow!("the actuator admits nothing")) + } +} + +struct Ran { + outcome: Result, + out: String, + events: Vec, + spawns: Vec>, + admits: usize, +} + +impl Ran { + fn ended(&self) -> (Cause, Option, u64, u64) { + match self.outcome.as_ref().unwrap() { + SelfRunOutcome::Ended { cause, code, requests, prefix } => (*cause, *code, *requests, *prefix), + other => panic!("not ended: {other:?}"), + } + } + + /// The text of every user line written to the child, in order. + fn written(&self) -> Vec { + self.events + .iter() + .filter_map(|event| match event { + Event::Wrote(line) => { + let line: Value = serde_json::from_str(line).unwrap(); + assert_eq!((line["type"].as_str(), line["message"]["role"].as_str()), (Some("user"), Some("user")), "{line}"); + Some(line["message"]["content"].as_str().unwrap().to_string()) + } + _ => None, + }) + .collect() + } + + fn count(&self, wanted: &Event) -> usize { + self.events.iter().filter(|event| *event == wanted).count() + } + + fn asked(&self) -> Vec { + self.events + .iter() + .filter_map(|event| match event { + Event::Asked(timeout) => Some(timeout.as_secs()), + _ => None, + }) + .collect() + } +} + +/// `dies_on_interrupt`: the child ends at the first signal, else it ignores all. +fn carry_with(seen: &Seen, steps: Vec, code: Option, dies_on_interrupt: bool) -> Ran { + carry_signalled(seen, steps, code, dies_on_interrupt.then_some(Stop::Interrupt), &[]) +} + +fn carry_signalled(seen: &Seen, steps: Vec, code: Option, dies_on: Option, refuses: &[Stop]) -> Ran { + carry_scripted(seen, steps, code, dies_on, refuses, false) +} + +fn carry_scripted(seen: &Seen, steps: Vec, code: Option, dies_on: Option, refuses: &[Stop], hangs: bool) -> Ran { + let log = Rc::new(RefCell::new(Vec::new())); + let clock = Rc::new(Fixed(Cell::new(at_start()))); + let fake = Fake { + child: RefCell::new(Some(Scripted { steps: steps.into(), log: log.clone(), clock: clock.clone(), code, dies_on, refuses: refuses.to_vec(), hangs, exited: false, eof: false, asked: 0 })), + spawns: RefCell::new(Vec::new()), + refuse: false, + }; + let mut out = Vec::new(); + let outcome = carry(seen, &*clock, &fake, &mut out); + let events = log.borrow().clone(); + let spawns = fake.spawns.borrow().clone(); + Ran { outcome, out: String::from_utf8(out).unwrap(), events, spawns, admits: seen.admits.get() } +} + +fn at_start() -> chrono::DateTime { + crate::testkit::at(0) +} + +/// A mind holding her one open Self run `selfrun-a` with budget 5. +fn with_run() -> TestMind { + let mind = TestMind::seeded(); + mind.committed(vec![self_run("selfrun-a", "5")]); + mind +} + +fn go(steps: Vec) -> Ran { + let mind = with_run(); + carry_with(&Seen::of(&mind), steps, Some(0), false) +} + +type Seed = Box; + +fn flushes(ran: &Ran) -> usize { + ran.written().iter().filter(|text| text.as_str() == FLUSH_MESSAGE).count() +} + +// ---- who is carried ----------------------------------------------------- + +#[test] +fn without_exactly_one_open_self_run_of_hers_it_is_no_run_and_no_child_is_spawned() { + let cases: Vec<(&str, Seed)> = vec![ + ("no run at all", Box::new(|_| {})), + ("a Self run the operator operates", Box::new(|mind| mind.committed(vec![run("op", RunTurn::SelfRun, RunOperator::Operator, &[])]))), + ("her Persona turn", Box::new(|mind| mind.committed(vec![run("persona", RunTurn::PersonaTurn, RunOperator::Mind, &[])]))), + ( + "a Self run of hers that is closed", + Box::new(|mind| { + mind.committed(vec![self_run("selfrun-a", "5")]); + mind.committed(vec![close(run_ref("selfrun-a"), recorded())]); + }), + ), + ]; + for (name, seed) in cases { + let mind = TestMind::seeded(); + seed(&mind); + let ran = carry_with(&Seen::of(&mind), vec![Out(result_line())], Some(0), false); + assert_eq!(ran.outcome.unwrap(), SelfRunOutcome::NoRun, "{name}"); + assert!(ran.spawns.is_empty() && ran.events.is_empty() && ran.out.is_empty(), "{name}"); + } + + // Another instance's mind holds none of this instance's runs. + let mind = with_run(); + let mut other = Seen::of(&mind); + other.instance = Slug("another".into()); + assert_eq!(carry_with(&other, vec![], Some(0), false).outcome.unwrap(), SelfRunOutcome::NoRun); + + // Two open runs are not one: nothing is spawned for either. + let mut two = Seen::of(&mind); + two.doubled = true; + let ran = carry_with(&two, vec![], Some(0), false); + assert_eq!(ran.outcome.unwrap(), SelfRunOutcome::NoRun); + assert!(ran.spawns.is_empty()); +} + +#[test] +fn a_mind_that_cannot_be_read_or_a_child_that_cannot_start_is_an_error_not_an_outcome() { + let mind = with_run(); + let clock = Rc::new(Fixed(Cell::new(at_start()))); + let refusing = Fake { child: RefCell::new(None), spawns: RefCell::new(Vec::new()), refuse: true }; + assert!(carry(&Seen::of(&mind), &*clock, &refusing, &mut Vec::new()).is_err()); + assert_eq!(refusing.spawns.borrow().len(), 1, "it tried exactly once"); + + struct Down; + impl MindPort for Down { + fn instance(&self) -> &Slug { + static INSTANCE: std::sync::OnceLock = std::sync::OnceLock::new(); + INSTANCE.get_or_init(|| Slug("yggdrasil".into())) + } + fn query(&self, _selection: &Selection) -> Result { + Err(anyhow!("down")) + } + fn admit(&self, _agent: &str, _session: &str, _documents: Vec) -> Result { + unreachable!() + } + } + let never = Fake { child: RefCell::new(None), spawns: RefCell::new(Vec::new()), refuse: false }; + assert!(carry(&Down, &*clock, &never, &mut Vec::new()).is_err()); + assert!(never.spawns.borrow().is_empty()); +} + +// ---- what is started ------------------------------------------------------ + +#[test] +fn claude_args_is_exactly_the_fixed_list_for_a_session_and_a_budget() { + let session = Uuid::parse_str(SESSION_OF_RUN_A).unwrap(); + let args = claude_args(session, "12.25"); + assert_eq!( + args, + [ + "-p", + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--verbose", + "--session-id", + SESSION_OF_RUN_A, + "--max-budget-usd", + "12.25", + "--max-turns", + "500", + "--permission-prompts", + "none", + ] + ); + // Those belong to the run measure or to the unit's managed settings. + for forbidden in ["--resume", "--continue", "--fork-session", "--model", "--permission-mode", "--dangerously-skip-permissions", "--settings", "--system-prompt"] { + assert!(!args.iter().any(|arg| arg.starts_with(forbidden) || arg.contains("dangerously") || arg == "bypassPermissions"), "{forbidden}"); + } + assert_eq!(MAX_TURNS, 500); +} + +#[test] +fn the_session_id_is_uuid_v5_of_the_run_id_and_differs_per_run() { + assert_eq!(session_id(&foreign_run("eureka:run:mind-20261022T120000000Z")).to_string(), SESSION_OF_MIND_RUN); + assert_eq!(session_id(&run_ref("selfrun-a")).to_string(), SESSION_OF_RUN_A); + assert_eq!(session_id(&run_ref("selfrun-b")).to_string(), SESSION_OF_RUN_B); +} + +#[test] +fn the_child_is_started_for_her_run_with_the_runs_own_budget_verbatim() { + // Trailing zeros are significant: "10" is ten dollars, not one. + for budget in ["0.5", "5", "12.25", "10", "100"] { + let mind = TestMind::seeded(); + mind.committed(vec![self_run("selfrun-b", budget)]); + let ran = carry_with(&Seen::of(&mind), vec![Out(result_line())], Some(0), false); + assert_eq!(ran.spawns.len(), 1, "{budget}"); + assert_eq!(ran.spawns[0], claude_args(Uuid::parse_str(SESSION_OF_RUN_B).unwrap(), budget), "{budget}"); + } +} + +#[test] +fn the_start_message_is_the_first_thing_written_and_names_the_skill_the_instance_and_the_run() { + let ran = go(vec![Out(result_line())]); + assert!(matches!(&ran.events[0], Event::Wrote(_)), "the start precedes the first read: {:?}", ran.events[0]); + let written = ran.written(); + assert_eq!(written[0], start_message(&Slug("yggdrasil".into()), &run_ref("selfrun-a"))); + for needle in ["mind-self", "yggdrasil", "yggdrasil:run:selfrun-a", AGENT] { + assert!(written[0].contains(needle), "{needle}: {}", written[0]); + } + assert_eq!(written.len(), 1, "no flush without the ceiling"); +} + +// ---- what is read ----------------------------------------------------------- + +#[test] +fn read_usage_sums_input_cache_read_and_cache_write_and_reads_absent_fields_as_zero() { + assert_eq!(read_usage(&request("m1", 1, 20, 300)), Usage::Request { id: "m1".into(), prefix: 321 }); + // Each term alone, so dropping or doubling any one is seen. + assert_eq!(read_usage(&request("m", 7, 0, 0)), Usage::Request { id: "m".into(), prefix: 7 }); + assert_eq!(read_usage(&request("m", 0, 7, 0)), Usage::Request { id: "m".into(), prefix: 7 }); + assert_eq!(read_usage(&request("m", 0, 0, 7)), Usage::Request { id: "m".into(), prefix: 7 }); + for absent in ["input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"] { + let mut event: Value = serde_json::from_str(&request("m", 1, 20, 300)).unwrap(); + event["message"]["usage"].as_object_mut().unwrap().remove(absent); + let want = 321 - [("input_tokens", 1), ("cache_read_input_tokens", 20), ("cache_creation_input_tokens", 300)].iter().find(|(name, _)| *name == absent).unwrap().1; + assert_eq!(read_usage(&event.to_string()), Usage::Request { id: "m".into(), prefix: want }, "{absent} absent"); + } + let mut no_usage: Value = serde_json::from_str(&request("m", 1, 1, 1)).unwrap(); + no_usage["message"].as_object_mut().unwrap().remove("usage"); + assert_eq!(read_usage(&no_usage.to_string()), Usage::Request { id: "m".into(), prefix: 0 }); +} + +#[test] +fn read_usage_classifies_the_owners_real_records() { + let sums = [84210, 100751, 117174, 118336, 126746, 127250, 129853]; + let mut seen = Vec::new(); + for line in RECORDS.lines() { + let Usage::Request { id, prefix } = read_usage(line) else { panic!("an assistant record of the main thread is a request: {line}") }; + if !seen.iter().any(|(known, _)| *known == id) { + seen.push((id, prefix)); + } + } + assert_eq!(seen.iter().map(|(_, prefix)| *prefix).collect::>(), sums); + assert_eq!(read_usage(&result_line()), Usage::Result); + assert_eq!(read_usage(&init_line()), Usage::Other); + assert_eq!(read_usage(&subagent("m", 5)), Usage::Other, "a subagent's request is not the main thread's"); + let mut no_id: Value = serde_json::from_str(&at("m", 5)).unwrap(); + no_id["message"].as_object_mut().unwrap().remove("id"); + assert_eq!(read_usage(&no_id.to_string()), Usage::Other); + let mut null_parent: Value = serde_json::from_str(&at("m", 5)).unwrap(); + null_parent["parent_tool_use_id"] = Value::Null; + assert_eq!(read_usage(&null_parent.to_string()), Usage::Request { id: "m".into(), prefix: 5 }); + let mut no_parent: Value = serde_json::from_str(&at("m", 5)).unwrap(); + no_parent.as_object_mut().unwrap().remove("parent_tool_use_id"); + assert_eq!(read_usage(&no_parent.to_string()), Usage::Request { id: "m".into(), prefix: 5 }); + for unreadable in ["", "not json", "[1,2]", "\"assistant\"", "12", "null", "{\"type\":"] { + assert_eq!(read_usage(unreadable), Usage::Unreadable, "{unreadable:?}"); + } +} + +#[test] +fn records_that_share_a_message_id_count_once_and_subagent_records_never_move_the_prefix() { + let mut steps: Vec = RECORDS.lines().map(|line| Out(line.to_string())).collect(); + steps.insert(3, Out(subagent("msg_sub", 999_999))); + let ran = go(steps); + assert_eq!( + ran.out, + "request 1 prefix 84210\nrequest 2 prefix 100751\nrequest 3 prefix 117174\nrequest 4 prefix 118336\nrequest 5 prefix 126746\nrequest 6 prefix 127250\nrequest 7 prefix 129853\n" + ); + let (cause, _, requests, prefix) = ran.ended(); + assert_eq!((cause, requests, prefix), (Cause::Own, 7, 129_853), "the newest prefix, 129853, not a subagent's 999999"); + assert_eq!(flushes(&ran), 0); +} + +#[test] +fn the_prefix_is_the_newest_requests_not_the_largest() { + let ran = go(vec![Out(at("m1", 200_000)), Out(at("m2", 150_000))]); + assert_eq!(ran.ended().3, 150_000); +} + +// ---- the flush -------------------------------------------------------------- + +#[test] +fn below_the_ceiling_no_flush_is_written_and_at_it_exactly_one() { + let below = go(vec![Out(at("m1", CEILING_TOKENS - 1))]); + assert_eq!(flushes(&below), 0); + assert_eq!(below.ended().0, Cause::Own); + + let at_it = go(vec![Out(at("m1", CEILING_TOKENS - 1)), Out(at("m2", CEILING_TOKENS)), Out(at("m3", CEILING_TOKENS + 10_000))]); + assert_eq!(flushes(&at_it), 1, "{:?}", at_it.written()); + assert_eq!(at_it.written().len(), 2, "the start and the flush"); + assert_eq!(at_it.ended().0, Cause::FlushRequested); + assert_eq!(CEILING_TOKENS, 300_000); +} + +#[test] +fn a_result_before_the_ceiling_means_no_flush_ever() { + let ran = go(vec![Out(at("m1", 10)), Out(result_line()), Out(at("m2", CEILING_TOKENS + 1))]); + assert_eq!(flushes(&ran), 0); + assert_eq!(ran.ended().0, Cause::Own); + assert_eq!(ran.count(&Event::Stopped(Stop::Interrupt)), 0); +} + +#[test] +fn stdin_closes_once_at_the_first_result_flush_or_no_flush_and_nothing_is_written_after() { + let plain = go(vec![Out(at("m1", 10)), Out(result_line()), Out(result_line())]); + assert_eq!(plain.count(&Event::InputClosed), 1); + let closed_at = plain.events.iter().position(|event| *event == Event::InputClosed).unwrap(); + assert!(plain.events[closed_at..].iter().all(|event| !matches!(event, Event::Wrote(_))), "no input after the close"); + assert!(plain.events[..closed_at].iter().any(|event| matches!(event, Event::Wrote(_))), "the start went before"); + + let flushed = go(vec![Out(at("m1", CEILING_TOKENS)), Out(result_line())]); + assert_eq!(flushed.count(&Event::InputClosed), 1); + let closed_at = flushed.events.iter().position(|event| *event == Event::InputClosed).unwrap(); + assert_eq!(flushed.events[..closed_at].iter().filter(|event| matches!(event, Event::Wrote(_))).count(), 2, "start and flush before the close"); + assert!(flushed.events[closed_at..].iter().all(|event| !matches!(event, Event::Wrote(_)))); + + let never = go(vec![Out(at("m1", 10))]); + assert_eq!(never.count(&Event::InputClosed), 0, "no result, no close"); +} + +// ---- the interrupt ------------------------------------------------------------ + +#[test] +fn after_the_flush_the_grace_interrupts_exactly_once_and_not_a_second_early() { + let grace = FLUSH_GRACE_S; + // Just short of the grace: no interrupt, and the child ended on its own. + let early = go(vec![Out(at("m1", CEILING_TOKENS)), Idle(grace - 1)]); + assert_eq!(early.count(&Event::Stopped(Stop::Interrupt)), 0); + assert_eq!(early.ended().0, Cause::FlushRequested); + + // At the grace, and long past it: one interrupt. + let on_time = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Idle(grace), Idle(grace), Idle(grace)], Some(130), true); + assert_eq!(on_time.count(&Event::Stopped(Stop::Interrupt)), 1); + assert_eq!(on_time.ended().0, Cause::Signalled(Stop::Interrupt)); + + // Exactly at the grace is the grace: the interrupt comes then, not on the next wait. + let exact = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Idle(grace)], Some(130), true); + assert_eq!(exact.ended().0, Cause::Signalled(Stop::Interrupt)); + + // A child that ignores it is interrupted once and still waited for. + let stubborn = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Idle(grace), Idle(grace), Idle(grace)], Some(0), false); + assert_eq!(stubborn.count(&Event::Stopped(Stop::Interrupt)), 1); + assert_eq!(stubborn.ended().1, Some(0), "its own end is waited for"); + assert_eq!(FLUSH_GRACE_S, 900); +} + +#[test] +fn the_wait_for_the_child_never_passes_the_grace_deadline_and_never_spins_after_the_interrupt() { + let ran = go(vec![Out(at("m1", CEILING_TOKENS)), Idle(840), Idle(59), Idle(1)]); + // 60 until the grace is near, the one second left, then the escalation's own wait, then the drain after the child's exit. + assert_eq!(ran.asked(), [60, 60, 60, 1, 60, 2], "{:?}", ran.asked()); +} + +#[test] +fn the_headroom_counts_the_requests_after_the_first_result_that_follows_the_flush_and_one_short_of_it_does_not_interrupt() { + let hard = CEILING_TOKENS + FLUSH_HEADROOM_TOKENS; + let short = go(vec![Out(at("m1", CEILING_TOKENS)), Out(result_line()), Out(at("m2", hard - 1))]); + assert_eq!(short.count(&Event::Stopped(Stop::Interrupt)), 0); + assert_eq!(short.ended().0, Cause::FlushRequested); + + let at_hard = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Out(result_line()), Out(at("m2", hard)), Out(at("m3", hard + 1))], Some(130), true); + assert_eq!(at_hard.count(&Event::Stopped(Stop::Interrupt)), 1); + assert_eq!(at_hard.ended().0, Cause::Signalled(Stop::Interrupt)); + assert_eq!(FLUSH_HEADROOM_TOKENS, 60_000); + + // Before that result the running turn's own requests are not the flush's turn: however far they grow, no interrupt. + let same_turn = go(vec![Out(at("m1", CEILING_TOKENS)), Out(at("m2", hard)), Out(at("m3", hard + 500_000))]); + assert_eq!(same_turn.count(&Event::Stopped(Stop::Interrupt)), 0); +} + +#[test] +fn a_jump_past_the_ceiling_plus_the_headroom_writes_the_flush_and_the_running_turn_cannot_use_up_the_flushs_turn() { + let jump = carry_with( + &Seen::of(&with_run()), + vec![Out(at("m1", 290_000)), Out(at("m2", 400_000)), Out(at("m3", 470_000)), Out(result_line()), Out(at("m4", 459_999)), Out(at("m5", 460_000)), Out(at("m6", 470_000))], + Some(130), + true, + ); + let kinds: Vec<&str> = jump + .events + .iter() + .filter_map(|event| match event { + Event::Wrote(line) if line.contains("context ceiling") => Some("flush"), + Event::Stopped(_) => Some("interrupt"), + Event::InputClosed => Some("closed"), + Event::Asked(_) => Some("read"), + _ => None, + }) + .collect(); + // Reads: m1, m2 (the jump, which writes the flush), m3 (the running turn's 470k, past the headroom: no interrupt). + assert_eq!(kinds[..5], ["read", "read", "flush", "read", "read"], "{kinds:?}"); + // The headroom is measured from the prefix the flush was asked at (400k) and counts the requests after the + // first result only: 459_999 does not interrupt; 460_000 does, once, and m6 is never read. + assert_eq!(jump.count(&Event::Stopped(Stop::Interrupt)), 1, "{kinds:?}"); + assert_eq!(jump.out, "request 1 prefix 290000\nrequest 2 prefix 400000\nrequest 3 prefix 470000\nrequest 4 prefix 459999\nrequest 5 prefix 460000\n"); + assert_eq!(jump.ended().0, Cause::Signalled(Stop::Interrupt)); + assert_eq!(flushes(&jump), 1); +} + +#[test] +fn a_child_that_ends_before_the_grace_is_never_interrupted() { + let ran = go(vec![Out(at("m1", CEILING_TOKENS)), Out(result_line())]); + assert_eq!(ran.count(&Event::Stopped(Stop::Interrupt)), 0); + assert_eq!(ran.ended().0, Cause::FlushRequested); +} + +#[test] +fn the_grace_is_the_full_grace_from_the_moment_the_flush_was_asked_whichever_request_that_was() { + let grace = FLUSH_GRACE_S; + // Five requests, time passing before the ceiling, then the flush on the sixth. + let before: Vec = vec![Out(at("m1", 10)), Idle(500), Out(at("m2", 20)), Out(at("m3", 30)), Out(at("m4", 40)), Out(at("m5", 50)), Out(at("m6", CEILING_TOKENS))]; + let early = carry_with(&Seen::of(&with_run()), [before.clone(), vec![Idle(grace - 1)]].concat(), Some(0), true); + assert_eq!((flushes(&early), early.count(&Event::Stopped(Stop::Interrupt))), (1, 0), "one second short of the grace, flush on request 6"); + let on_time = carry_with(&Seen::of(&with_run()), [before, vec![Idle(grace - 1), Idle(1)]].concat(), Some(0), true); + assert_eq!(on_time.count(&Event::Stopped(Stop::Interrupt)), 1, "the grace is the same after six requests as after one"); +} + +#[test] +fn the_message_ids_of_a_long_run_are_all_remembered() { + let ids: Vec = (1..=12).map(|n| format!("m{n}")).collect(); + let mut steps: Vec = ids.iter().map(|id| Out(at(id, 1_000))).collect(); + // Every earlier request seen again, the oldest and the newest among them: none counts twice. + steps.extend(ids.iter().map(|id| Out(at(id, 2_000)))); + let ran = go(steps); + let (_, _, requests, prefix) = ran.ended(); + assert_eq!((requests, prefix), (12, 1_000)); + assert_eq!(ran.out.lines().count(), 12); +} + +#[test] +fn a_usage_sum_that_overflows_is_at_or_over_the_ceiling_not_a_panic_or_a_wrap() { + let line = request("m", u64::MAX, 1, 0); + assert_eq!(read_usage(&line), Usage::Request { id: "m".into(), prefix: u64::MAX }); + assert_eq!(read_usage(&request("m", u64::MAX, u64::MAX, u64::MAX)), Usage::Request { id: "m".into(), prefix: u64::MAX }); + assert_eq!(read_usage(&request("m", 1, u64::MAX, 1)), Usage::Request { id: "m".into(), prefix: u64::MAX }); + // Through the actuator: the flush is asked for, and the headroom beyond a saturated prefix does not overflow + // either, and the pass that wrote the flush does not interrupt. + let ran = go(vec![Out(request("m1", u64::MAX, 7, 0)), Out(request("m2", 0, 0, 0))]); + assert_eq!(flushes(&ran), 1); + assert_eq!(ran.count(&Event::Stopped(Stop::Interrupt)), 0); +} + +#[test] +fn a_result_before_any_request_closes_the_input_once_and_the_run_ends_with_a_typed_outcome() { + // The shape of an API or authentication failure: the child reports an error result and no request ever came. + let ran = carry_with(&Seen::of(&with_run()), vec![Out(result_line())], Some(1), false); + assert_eq!(ran.count(&Event::InputClosed), 1); + assert_eq!(ran.ended(), (Cause::Own, Some(1), 0, 0)); + assert_eq!(ran.outcome.as_ref().unwrap().exit_status(), 1); + assert_eq!(ran.count(&Event::Stopped(Stop::Interrupt)), 0); +} + +#[test] +fn without_a_flush_nothing_is_ever_signalled_however_large_the_prefix_grows() { + let ran = go(vec![Out(at("m1", 10)), Out(result_line()), Out(at("m2", 900_000)), Idle(100_000)]); + assert_eq!(ran.events.iter().filter(|event| matches!(event, Event::Stopped(_))).count(), 0); + assert_eq!(ran.ended().0, Cause::Own); +} + +#[test] +fn a_child_that_ignores_a_signal_gets_the_next_one_after_the_bound_and_the_outcome_names_the_one_that_ended_it() { + let hung = |dies_on: Option, refuses: &[Stop], rest: Vec| { + carry_signalled(&Seen::of(&with_run()), [vec![Out(at("m1", CEILING_TOKENS)), Idle(FLUSH_GRACE_S)], rest].concat(), Some(0), dies_on, refuses) + }; + let stops = |ran: &Ran| -> Vec { ran.events.iter().filter(|event| matches!(event, Event::Stopped(_))).cloned().collect() }; + let after = ESCALATE_AFTER_S; + + // Ignores SIGINT and SIGTERM: each next signal waits out the bound, SIGKILL ends it. + let killed = hung(Some(Stop::Kill), &[], vec![Idle(after - 1), Idle(1), Idle(after - 1), Idle(1), Idle(after)]); + assert_eq!(stops(&killed), [Event::Stopped(Stop::Interrupt), Event::Stopped(Stop::Terminate), Event::Stopped(Stop::Kill)]); + assert_eq!(killed.ended().0, Cause::Signalled(Stop::Kill)); + + // One second short of the bound there is no second signal. + let waiting = hung(None, &[], vec![Idle(after - 1)]); + assert_eq!(stops(&waiting), [Event::Stopped(Stop::Interrupt)]); + + // SIGTERM ends it: the outcome says terminated, and nothing stronger is sent. + let terminated = hung(Some(Stop::Terminate), &[], vec![Idle(after), Idle(after)]); + assert_eq!(stops(&terminated), [Event::Stopped(Stop::Interrupt), Event::Stopped(Stop::Terminate)]); + assert_eq!(terminated.outcome.as_ref().unwrap().to_string(), "ended: terminated code 0 requests 1 prefix 300000"); +} + +#[test] +fn a_signal_that_could_not_be_delivered_is_never_reported_as_sent() { + let hung = |refuses: &[Stop], rest: Vec| { + carry_signalled(&Seen::of(&with_run()), [vec![Out(at("m1", CEILING_TOKENS)), Idle(FLUSH_GRACE_S)], rest].concat(), Some(0), None, refuses) + }; + // SIGINT is refused and the child ends by itself afterwards: the flush was requested, nothing interrupted it. + let refused = hung(&[Stop::Interrupt], vec![]); + assert_eq!(refused.count(&Event::Stopped(Stop::Interrupt)), 1, "it was attempted"); + assert_eq!(refused.ended().0, Cause::FlushRequested); + + // SIGINT delivered but ignored, SIGTERM refused, the child ends on its own: the strongest delivered is SIGINT. + let partly = hung(&[Stop::Terminate], vec![Idle(ESCALATE_AFTER_S)]); + assert_eq!(partly.ended().0, Cause::Signalled(Stop::Interrupt)); + + // Not even SIGKILL is delivered and the child never ends: after the bound the run is over without it, and the label says so. + let stuck = carry_scripted(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Idle(FLUSH_GRACE_S)], Some(0), None, &[Stop::Interrupt, Stop::Terminate, Stop::Kill], true); + assert_eq!(stuck.ended(), (Cause::Unstoppable, None, 1, CEILING_TOKENS)); + assert_eq!(stuck.events.iter().filter(|event| matches!(event, Event::Stopped(_))).count(), 3); + assert_eq!(stuck.outcome.unwrap().exit_status(), 1); +} + +/// A clock that counts its reads. +struct Counting(Fixed, Cell); + +impl Clock for Counting { + fn now(&self) -> chrono::DateTime { + self.1.set(self.1.get() + 1); + self.0.now() + } +} + +#[test] +fn the_clock_is_read_once_per_event_so_a_step_of_the_wall_clock_cannot_split_a_pass() { + let mind = with_run(); + let log = Rc::new(RefCell::new(Vec::new())); + let clock = Rc::new(Counting(Fixed(Cell::new(at_start())), Cell::new(0))); + // The child's reads move the clock by far more than the grace, as a stepped wall clock would. + struct Stepped(Rc); + impl Clock for Stepped { + fn now(&self) -> chrono::DateTime { + let now = self.0.now(); + self.0.0.0.set(now + ChronoDuration::seconds(FLUSH_GRACE_S + 1)); + now + } + } + let steps: VecDeque = vec![Out(at("m1", CEILING_TOKENS)), Out(at("m2", CEILING_TOKENS + 1)), Idle(0)].into(); + let child = Scripted { steps, log: log.clone(), clock: Rc::new(Fixed(Cell::new(at_start()))), code: Some(0), dies_on: None, refuses: vec![], hangs: false, exited: false, eof: false, asked: 0 }; + let fake = Fake { child: RefCell::new(Some(child)), spawns: RefCell::new(Vec::new()), refuse: false }; + let outcome = carry(&Seen::of(&mind), &Stepped(clock.clone()), &fake, &mut Vec::new()).unwrap(); + let events = log.borrow().clone(); + let reads = events.iter().filter(|event| matches!(event, Event::Asked(_))).count() as u32; + // One read before the first wait, then one per fact heard: never a second read inside a pass. + assert_eq!(clock.1.get(), 1 + reads, "{events:?}"); + // The flush was written on the first request at time 0; the very next pass sees the stepped clock. + // The flush pass itself has no interrupt, whatever the step: the interrupt is on a later event. + let flush_at = events.iter().position(|event| matches!(event, Event::Wrote(line) if line.contains("context ceiling"))).unwrap(); + let stop_at = events.iter().position(|event| matches!(event, Event::Stopped(_))).unwrap(); + assert!(events[flush_at..stop_at].iter().any(|event| matches!(event, Event::Asked(_))), "a read between the flush and the interrupt: {events:?}"); + assert_eq!(outcome.exit_status(), 0); +} + +#[test] +fn a_deadline_that_came_during_a_line_is_answered_without_waiting_for_the_next_poll() { + // The result arrives after the grace has run out: the input closes, and the interrupt is due at once. + let ran = carry_scripted(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Step::After(FLUSH_GRACE_S + 5, result_line())], Some(130), Some(Stop::Interrupt), &[], true); + assert_eq!(ran.count(&Event::Stopped(Stop::Interrupt)), 1); + assert_eq!(ran.count(&Event::InputClosed), 1); + let asks = ran.asked(); + assert_eq!(asks[2], 0, "the wait after the result: the deadline is past, not a poll away: {asks:?}"); +} + +#[test] +fn a_child_that_exited_is_never_signalled_however_long_its_stream_stays_open() { + // Flush asked for, the child exits, then the grace and more pass with its stream still open. + let ran = carry_scripted(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Exit(Some(0)), Idle(1)], Some(0), None, &[], true); + assert_eq!(ran.events.iter().filter(|event| matches!(event, Event::Stopped(_))).count(), 0); + assert_eq!(ran.ended(), (Cause::FlushRequested, Some(0), 1, CEILING_TOKENS)); + // The stream ending first and the exit after: the same end. + let eof_first = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", 10)), Eof], Some(0), false); + assert_eq!(eof_first.ended(), (Cause::Own, Some(0), 1, 10)); +} + +// ---- how it ends -------------------------------------------------------------- + +#[test] +fn the_outcome_names_how_it_ended_and_the_exit_status_follows_the_childs_code() { + let own = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", 10)), Out(result_line())], Some(0), false); + assert_eq!(own.outcome.as_ref().unwrap(), &SelfRunOutcome::Ended { cause: Cause::Own, code: Some(0), requests: 1, prefix: 10 }); + assert_eq!(own.outcome.as_ref().unwrap().to_string(), "ended: own code 0 requests 1 prefix 10"); + assert_eq!(own.outcome.unwrap().exit_status(), 0); + + let flushed = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Out(result_line())], Some(0), false); + assert_eq!(flushed.outcome.as_ref().unwrap().to_string(), "ended: flush-requested code 0 requests 1 prefix 300000"); + assert_eq!(flushed.outcome.unwrap().exit_status(), 0); + + let nonzero = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", 10))], Some(2), false); + assert_eq!(nonzero.outcome.as_ref().unwrap().to_string(), "ended: own code 2 requests 1 prefix 10"); + assert_eq!(nonzero.outcome.unwrap().exit_status(), 1); + + let signalled = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Idle(FLUSH_GRACE_S)], None, true); + assert_eq!(signalled.outcome.as_ref().unwrap().to_string(), "ended: interrupted signal requests 1 prefix 300000"); + assert_eq!(signalled.outcome.unwrap().exit_status(), 1, "a child killed by a signal has no code"); + + assert_eq!(SelfRunOutcome::NoRun.exit_status(), 3); + assert_eq!(SelfRunOutcome::NoRun.to_string(), "no run: she has no single open Self run"); +} + +#[test] +fn unreadable_and_foreign_lines_are_skipped_and_never_printed() { + let canary = "canary-stream-line-8c41"; + let mut foreign: Value = serde_json::from_str(&init_line()).unwrap(); + foreign["cwd"] = json!(canary); + foreign["type"] = json!(canary); + let ran = go(vec![ + Out(format!("not json {canary}")), + Out(foreign.to_string()), + Out(format!("[\"{canary}\"]")), + Out(at("m1", 10)), + Out(result_line()), + ]); + assert_eq!(ran.out, "request 1 prefix 10\n"); + let outcome = ran.outcome.as_ref().unwrap().to_string(); + assert!(!ran.out.contains(canary) && !outcome.contains(canary)); + assert!(!ran.written().iter().any(|text| text.contains(canary)), "nothing from the stream is written back"); +} + +#[test] +fn the_actuator_admits_nothing_to_the_mind_through_a_whole_flushed_run() { + let mind = with_run(); + let seen = Seen::of(&mind); + let ran = carry_with(&seen, vec![Out(at("m1", CEILING_TOKENS)), Out(result_line())], Some(0), false); + assert_eq!(ran.admits, 0); + assert!(ran.outcome.is_ok()); + // And the run is exactly as she left it: still in force until her flush, or the next launch, closes it. + assert_eq!(mind.ids(K::Resolution), Vec::::new()); +} + +// ---- the real child ----------------------------------------------------------- + +#[cfg(unix)] +mod process { + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + + use super::*; + use crate::launch::SystemClock; + + /// A fake `claude`: the shell script `body`, in a directory that outlives the child. + fn script(dir: &tempfile::TempDir, body: &str) -> PathBuf { + let path = dir.path().join("claude"); + std::fs::write(&path, format!("#!/bin/bash\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + /// Spawns it, retrying the one error a freshly written executable can give + /// while another test's fork still holds its write handle. + fn start(program: &Path, args: &[String]) -> Box { + for _ in 0..50 { + match spawn_program(program, args) { + Ok(child) => return child, + Err(error) if format!("{error:#}").contains("busy") => std::thread::sleep(StdDuration::from_millis(100)), + Err(error) => panic!("{error:#}"), + } + } + panic!("the script stayed busy"); + } + + /// Everything the child gives until it has been reaped and its stdout has ended. + fn drain(child: &mut dyn Child) -> (Vec, Option) { + let (mut lines, mut code, mut eof) = (Vec::new(), None, false); + loop { + match child.next(StdDuration::from_secs(20)) { + Read::Line(line) => lines.push(line), + Read::Eof => eof = true, + Read::Exited(status) => code = Some(status), + Read::Timeout => panic!("the fake claude hung"), + } + if let (true, Some(status)) = (eof, code) { + return (lines, status); + } + } + } + + /// Whether a process of this id still exists. + fn alive(pid: &str) -> bool { + Command::new("kill").args(["-0", pid]).stderr(Stdio::null()).status().unwrap().success() + } + + #[test] + fn the_child_gets_its_arguments_and_lines_in_order_and_ends_when_its_input_closes() { + let dir = tempfile::tempdir().unwrap(); + let program = script(&dir, "echo \"args: $*\"\nwhile read line; do echo \"got: $line\"; done\necho bye\nexit 7"); + let mut child = start(&program, &["-p".to_string(), "--max-turns".to_string(), "500".to_string()]); + child.write_line("first"); + child.write_line("second"); + child.close_input(); + assert_eq!(drain(&mut *child), (["args: -p --max-turns 500", "got: first", "got: second", "bye"].map(String::from).to_vec(), Some(7))); + } + + #[test] + fn a_silent_child_times_out_instead_of_blocking_and_a_signalled_child_has_no_code() { + let dir = tempfile::tempdir().unwrap(); + let program = script(&dir, "kill -9 $$"); + let mut child = start(&program, &[]); + assert_eq!(drain(&mut *child), (Vec::new(), None)); + + let quiet = script(&dir, "exec sleep 5"); + let mut child = start(&quiet, &[]); + assert_eq!(child.next(StdDuration::from_millis(100)), Read::Timeout); + child.stop(Stop::Interrupt); + assert_eq!(drain(&mut *child), (Vec::new(), None), "SIGINT ended the unprepared child"); + } + + #[test] + fn interrupt_is_sigint_the_childs_own_handler_can_catch() { + let dir = tempfile::tempdir().unwrap(); + let program = script(&dir, "trap 'echo interrupted; exit 130' INT\necho ready\nwhile :; do sleep 0.05; done"); + let mut child = start(&program, &[]); + assert_eq!(child.next(StdDuration::from_secs(20)), Read::Line("ready".into())); + child.stop(Stop::Interrupt); + assert_eq!(drain(&mut *child), (vec!["interrupted".to_string()], Some(130))); + } + + #[test] + fn a_program_that_cannot_start_is_an_error_that_names_no_argument() { + let canary = "canary-argument-6b0d"; + let error = spawn_program(Path::new("/nonexistent/claude"), &[canary.to_string()]).err().unwrap(); + assert!(!format!("{error:#}").contains(canary) && !format!("{error:#}").contains("nonexistent"), "{error:#}"); + } + + /// A read that times out here means the stream stopped, never a wait worth 60 s: + /// fail at once instead of hanging the suite. + struct Prompt(Box); + impl Child for Prompt { + fn write_line(&mut self, line: &str) { + self.0.write_line(line) + } + fn close_input(&mut self) { + self.0.close_input() + } + fn next(&mut self, timeout: StdDuration) -> Read { + match self.0.next(StdDuration::from_secs(20).min(timeout)) { + Read::Timeout => panic!("the fake claude went quiet"), + read => read, + } + } + fn stop(&mut self, how: Stop) -> bool { + self.0.stop(how) + } + } + + struct Spawn(PathBuf); + impl Spawner for Spawn { + fn spawn(&self, args: &[String]) -> Result> { + Ok(Box::new(Prompt(start(&self.0, args)))) + } + } + + /// The real child as it is: a read may time out, which is how the drain after an exit is waited. + /// A run that has not ended by 15 s is a failure, not a hang. + struct Bounded(Box, std::time::Instant); + impl Child for Bounded { + fn write_line(&mut self, line: &str) { + self.0.write_line(line) + } + fn close_input(&mut self) { + self.0.close_input() + } + fn next(&mut self, timeout: StdDuration) -> Read { + assert!(self.1.elapsed() < StdDuration::from_secs(15), "the run did not end"); + self.0.next(StdDuration::from_secs(1).min(timeout)) + } + fn stop(&mut self, how: Stop) -> bool { + self.0.stop(how) + } + } + + struct Patient(PathBuf); + impl Spawner for Patient { + fn spawn(&self, args: &[String]) -> Result> { + Ok(Box::new(Bounded(start(&self.0, args), std::time::Instant::now()))) + } + } + + #[test] + fn a_whole_run_through_the_real_child_flushes_at_the_ceiling_and_ends_on_the_result() { + let dir = tempfile::tempdir().unwrap(); + let lines = [at("m1", 10), at("m2", CEILING_TOKENS)].join("\n"); + let result = result_line(); + // Prints two requests, waits for the flush on stdin, then the result, as a stand-in for a Self that obeys. + let body = format!("cat <<'EOF'\n{lines}\nEOF\nread -t 5 start\nread -t 5 flush\ncat <<'EOF'\n{result}\nEOF\nwhile read more; do :; done\nexit 0"); + let program = script(&dir, &body); + let mind = with_run(); + let mut out = Vec::new(); + let outcome = carry(&Seen::of(&mind), &SystemClock, &Spawn(program), &mut out).unwrap(); + assert_eq!(outcome, SelfRunOutcome::Ended { cause: Cause::FlushRequested, code: Some(0), requests: 2, prefix: 300_000 }); + assert_eq!(String::from_utf8(out).unwrap(), "request 1 prefix 10\nrequest 2 prefix 300000\n"); + } + + #[test] + fn a_failure_result_before_any_request_closes_stdin_and_ends_the_run_promptly_through_the_real_child() { + let dir = tempfile::tempdir().unwrap(); + let result = result_line(); + // An authentication or API failure: the error result is the first thing it prints, then it + // waits for its input to end, as `claude -p` over stream-json does, and exits 1. + let program = script(&dir, &format!("read -t 5 start\ncat <<'EOF'\n{result}\nEOF\nwhile read more; do :; done\nexit 1")); + let started = std::time::Instant::now(); + let mut out = Vec::new(); + let outcome = carry(&Seen::of(&with_run()), &SystemClock, &Spawn(program), &mut out).unwrap(); + assert_eq!(outcome, SelfRunOutcome::Ended { cause: Cause::Own, code: Some(1), requests: 0, prefix: 0 }); + assert_eq!(outcome.exit_status(), 1); + assert!(out.is_empty()); + assert!(started.elapsed() < StdDuration::from_secs(20), "it ended promptly"); + } + + /// The Yggdrasil probe shape: the child prints its result and exits, and a background + /// process it started still holds its stdout, so the stream never ends. The run is over + /// when the child is, and the actuator signals nothing: the child is gone. + #[test] + fn a_child_that_exits_while_a_descendant_holds_its_stdout_ends_the_run_without_a_signal() { + for flushed in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let pids = dir.path().join("holder.pid"); + let (result, ceiling) = (result_line(), at("m1", CEILING_TOKENS)); + let before = if flushed { format!("cat <<'EOF'\n{ceiling}\nEOF\nread -t 5 start\nread -t 5 flush\n") } else { "read -t 5 start\n".to_string() }; + let program = script(&dir, &format!("{before}cat <<'EOF'\n{result}\nEOF\nsleep 30 &\necho $! > {}\nexit 0", pids.display())); + let started = std::time::Instant::now(); + let mut out = Vec::new(); + let outcome = carry(&Seen::of(&with_run()), &SystemClock, &Patient(program), &mut out).unwrap(); + let holder = std::fs::read_to_string(&pids).unwrap().trim().to_string(); + let cause = if flushed { Cause::FlushRequested } else { Cause::Own }; + let (requests, prefix) = if flushed { (1, CEILING_TOKENS) } else { (0, 0) }; + assert_eq!(outcome, SelfRunOutcome::Ended { cause, code: Some(0), requests, prefix }, "flushed: {flushed}"); + assert!(alive(&holder), "the actuator signals no one after the child is gone"); + assert!(started.elapsed() < StdDuration::from_secs(20), "it ended on the child's exit: {:?}", started.elapsed()); + Command::new("kill").arg(&holder).status().unwrap(); + } + } + + #[test] + fn each_signal_reaches_a_child_that_ignores_the_weaker_ones_and_one_for_a_gone_child_is_not_delivered() { + let dir = tempfile::tempdir().unwrap(); + let program = script(&dir, "trap '' INT TERM\necho ready\nwhile :; do sleep 0.05; done"); + let mut child = start(&program, &[]); + assert_eq!(child.next(StdDuration::from_secs(20)), Read::Line("ready".into())); + assert!(child.stop(Stop::Interrupt) && child.stop(Stop::Terminate), "delivered, and ignored"); + assert_eq!(child.next(StdDuration::from_millis(300)), Read::Timeout, "still running"); + assert!(child.stop(Stop::Kill)); + assert_eq!(drain(&mut *child), (Vec::new(), None), "SIGKILL leaves no code"); + + let quick = script(&dir, "exit 0"); + let mut gone = start(&quick, &[]); + assert_eq!(drain(&mut *gone), (Vec::new(), Some(0))); + assert!(!gone.stop(Stop::Interrupt), "no such process"); + } + + #[test] + fn a_signal_reaches_the_whole_process_group_the_child_started() { + // Not SIGINT: a background job of a non-interactive shell ignores it, which is the shell's rule, not ours. + for how in [Stop::Terminate, Stop::Kill] { + let dir = tempfile::tempdir().unwrap(); + // The child starts a descendant of its own and waits for it. + let program = script(&dir, "sleep 300 &\necho $!\nwait"); + let mut child = start(&program, &[]); + let Read::Line(holder) = child.next(StdDuration::from_secs(20)) else { panic!("no pid") }; + assert!(alive(&holder)); + assert!(child.stop(how), "{how:?} delivered to the group"); + drain(&mut *child); + let mut waited = 0; + while alive(&holder) && waited < 100 { + std::thread::sleep(StdDuration::from_millis(50)); + waited += 1; + } + assert!(!alive(&holder), "{how:?} reached the descendant too"); + } + } +} diff --git a/crates/mind-body/tests/cli.rs b/crates/mind-body/tests/cli.rs index c5228e4..8616bd6 100644 --- a/crates/mind-body/tests/cli.rs +++ b/crates/mind-body/tests/cli.rs @@ -408,3 +408,49 @@ fn a_partial_stop_names_each_units_state_and_keeps_the_brake_held_with_exit_2() assert!(!String::from_utf8_lossy(&output.stdout).contains("stopped")); assert!(shown(root.path()).contains("BrakeHeld")); } + +// ---- the arguments every organ binary shares ---- + +mod shared_arguments { + use mind_body::cli::instance_and_endpoint; + + const USAGE: &str = "usage: test-organ --instance "; + + fn parse(args: &[&str], env: Option<&str>) -> anyhow::Result<(eureka_pipeline::Slug, std::net::SocketAddr)> { + let args: Vec = args.iter().map(|a| a.to_string()).collect(); + instance_and_endpoint(&args, env.map(str::to_string), USAGE) + } + + #[test] + fn the_flag_names_the_endpoint_and_wins_over_the_environment_which_is_the_fallback() { + let (instance, address) = parse(&["--instance", "yggdrasil", "--endpoint", "rudp://10.77.0.1:17872"], Some("rudp://127.0.0.1:1")).unwrap(); + assert_eq!((instance.0.as_str(), address.to_string().as_str()), ("yggdrasil", "10.77.0.1:17872")); + let (_, address) = parse(&["--endpoint", "rudp://10.77.0.1:17872", "--instance", "yggdrasil"], None).unwrap(); + assert_eq!(address.to_string(), "10.77.0.1:17872", "flag order does not matter"); + let (_, address) = parse(&["--instance", "yggdrasil"], Some("rudp://127.0.0.1:9")).unwrap(); + assert_eq!(address.to_string(), "127.0.0.1:9"); + } + + #[test] + fn every_refusal_is_fixed_text_that_echoes_no_value() { + let canary = "canary-value-91e7"; + let cases: Vec<(Vec<&str>, Option<&str>, &str)> = vec![ + (vec![], Some("rudp://127.0.0.1:9"), USAGE), + (vec!["--instance"], None, USAGE), + (vec!["--instance", "a", "--instance", canary], Some("rudp://127.0.0.1:9"), USAGE), + (vec!["--instance", "a", "--endpoint", "rudp://127.0.0.1:9", "--endpoint", canary], None, USAGE), + (vec!["--instance", "a", canary], Some("rudp://127.0.0.1:9"), USAGE), + (vec!["--endpoint", "rudp://127.0.0.1:9"], None, USAGE), + (vec!["--instance", "Canary/../x"], Some("rudp://127.0.0.1:9"), "the instance is not a slug"), + (vec!["--instance", "a"], None, "no daemon endpoint: --endpoint or HUGINN_ENDPOINT"), + (vec!["--instance", "a", "--endpoint", canary], None, "the endpoint is not rudp://:"), + (vec!["--instance", "a", "--endpoint", "http://127.0.0.1:9"], None, "the endpoint is not rudp://:"), + (vec!["--instance", "a", "--endpoint", "rudp://not-an-address"], None, "the endpoint is not rudp://:"), + (vec!["--instance", "a"], Some(canary), "the endpoint is not rudp://:"), + ]; + for (args, env, message) in cases { + let error = parse(&args, env).unwrap_err(); + assert_eq!(format!("{error:#}"), message, "{args:?} {env:?}"); + } + } +} diff --git a/crates/mind-body/tests/public_surface.txt b/crates/mind-body/tests/public_surface.txt index 405b5e6..354f7ee 100644 --- a/crates/mind-body/tests/public_surface.txt +++ b/crates/mind-body/tests/public_surface.txt @@ -2,7 +2,9 @@ mind_body: mod cli mind_body: mod control mind_body: mod launch mind_body: mod queue +mind_body: mod self_run mind_body: mod wake +mind_body::cli: fn instance_and_endpoint (args : & [String] , env_endpoint : Option < String > , usage : & str) -> Result < (Slug , SocketAddr) > mind_body::cli: fn operator_name (sudo_user : Option < String > , user : Option < String >) -> String mind_body::cli: fn run (args : & [String] , root : & Path , set_by : & str , now : DateTime < Utc > , out : & mut impl Write , stop_units : & mut dyn FnMut (& Slug) -> Result < () > ,) -> Result < () > mind_body::control: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum HeldReason { BrakeAbsent , BrakeHeld , DialAbsent , DialOutOfBounds , Undecodable , } @@ -71,6 +73,34 @@ mind_body::queue: impl MindPort for HuginnMind < '_ > mind_body::queue: impl< 'a > HuginnMind < 'a > mind_body::queue: pub trait MindPort { fn instance (& self) -> & Slug ; fn query (& self , selection : & Selection) -> Result < PipelineSelectionPage > ; fn admit (& self , agent : & str , session : & str , documents : Vec < PipelineDocument >) -> Result < PipelineAdmissionOutcome > ; } mind_body::queue: struct HuginnMind< 'a > pub-fields[] +mind_body::self_run: # [derive (Clone , Copy , Debug , PartialEq , Eq , PartialOrd , Ord)] pub enum Stop { Interrupt , Terminate , Kill , } +mind_body::self_run: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Cause { Own , FlushRequested , Signalled (Stop) , Unstoppable , } +mind_body::self_run: # [derive (Clone , Debug , PartialEq , Eq)] pub enum Read { Line (String) , Timeout , Eof , Exited (Option < i32 >) , } +mind_body::self_run: # [derive (Clone , Debug , PartialEq , Eq)] pub enum SelfRunOutcome { NoRun , Ended { cause : Cause , code : Option < i32 > , requests : u64 , prefix : u64 } , } +mind_body::self_run: # [derive (Clone , Debug , PartialEq , Eq)] pub enum Usage { Request { id : String , prefix : u64 } , Result , Other , Unreadable , } +mind_body::self_run: const AGENT : & str +mind_body::self_run: const CEILING_TOKENS : u64 +mind_body::self_run: const ESCALATE_AFTER_S : i64 +mind_body::self_run: const EXIT_DRAIN_S : i64 +mind_body::self_run: const FLUSH_GRACE_S : i64 +mind_body::self_run: const FLUSH_HEADROOM_TOKENS : u64 +mind_body::self_run: const FLUSH_MESSAGE : & str +mind_body::self_run: const MAX_TURNS : u32 +mind_body::self_run: const SESSION_NAMESPACE : Uuid +mind_body::self_run: fn carry (mind : & dyn MindPort , clock : & dyn Clock , spawner : & dyn Spawner , out : & mut dyn Write) -> Result < SelfRunOutcome > +mind_body::self_run: fn claude_args (session : Uuid , budget_usd : & str) -> Vec < String > +mind_body::self_run: fn read_usage (line : & str) -> Usage +mind_body::self_run: fn session_id (run : & PipelineRef) -> Uuid +mind_body::self_run: fn start_message (instance : & Slug , run : & PipelineRef) -> String +mind_body::self_run: impl Child for ClaudeProcess +mind_body::self_run: impl SelfRunOutcome +mind_body::self_run: impl SelfRunOutcome: fn exit_status (& self) -> i32 +mind_body::self_run: impl Spawner for ClaudeSpawner +mind_body::self_run: impl Stop +mind_body::self_run: impl fmt :: Display for SelfRunOutcome +mind_body::self_run: pub trait Child { fn write_line (& mut self , line : & str) ; fn close_input (& mut self) ; fn next (& mut self , timeout : StdDuration) -> Read ; fn stop (& mut self , how : Stop) -> bool ; } +mind_body::self_run: pub trait Spawner { fn spawn (& self , args : & [String]) -> Result < Box < dyn Child > > ; } +mind_body::self_run: struct ClaudeSpawner pub-fields[] mind_body::wake: # [derive (Clone , Debug , PartialEq , Eq)] pub enum WakeOutcome { Held { reason : HeldReason } , NotYet { until : DateTime < Utc > } , Busy { run : PipelineRef } , Launched { run : PipelineRef } , Refused (Declined) , } mind_body::wake: const AGENT : & str mind_body::wake: fn cycle (ports : & Ports , control : & impl ControlSource) -> Result < WakeOutcome > diff --git a/crates/mind-body/tests/self_run.rs b/crates/mind-body/tests/self_run.rs new file mode 100644 index 0000000..9c6d1b2 --- /dev/null +++ b/crates/mind-body/tests/self_run.rs @@ -0,0 +1,281 @@ +//! The `mind-self-run` binary as a process: its arguments, its exit codes and +//! what it never echoes. The carrying itself is tested in the library against a +//! real mind and a scripted child. One test, `#[ignore]`d outside a container, +//! puts the real binary, the real daemon and a fake `claude` first on `PATH` +//! together, which is where the real child's argument list and stream can be +//! seen end to end. + +// The test hands the binary its environment, and reads the one variable the +// harness gives it. That is test plumbing: it reaches no library code. +#![allow(clippy::disallowed_methods, reason = "the suite sets the binary's environment")] + +use std::process::{Command, Output}; + +const NO_SUCH_INSTANCE: &str = "self-run-test-no-such-instance"; + +fn self_run(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_mind-self-run")).args(args).env_remove("HUGINN_ENDPOINT").output().unwrap() +} + +fn self_run_with_env_endpoint(args: &[&str], endpoint: &str) -> Output { + Command::new(env!("CARGO_BIN_EXE_mind-self-run")).args(args).env("HUGINN_ENDPOINT", endpoint).output().unwrap() +} + +fn all_output(output: &Output) -> String { + format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)) +} + +#[test] +fn without_an_instance_or_with_a_stray_argument_it_exits_2_with_the_usage_and_echoes_nothing() { + let canary = "canary-argument-3f9a"; + for args in [ + vec![], + vec!["--endpoint", "rudp://127.0.0.1:9"], + vec!["--instance", NO_SUCH_INSTANCE, canary], + vec!["--instance", NO_SUCH_INSTANCE, "--instance", canary], + vec![canary], + vec!["--resume", canary], + ] { + let output = self_run(&args); + assert_eq!(output.status.code(), Some(2), "{args:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("usage: mind-self-run"), "{args:?}: {stderr}"); + assert!(!all_output(&output).contains(canary) && output.stdout.is_empty(), "{args:?}"); + } +} + +#[test] +fn an_instance_that_is_not_a_slug_is_exit_2_and_is_not_echoed() { + let canary = "Canary/../Instance-5e2b"; + let output = self_run(&["--instance", canary, "--endpoint", "rudp://127.0.0.1:9"]); + assert_eq!(output.status.code(), Some(2)); + assert!(!all_output(&output).contains("anary"), "{}", all_output(&output)); + assert!(output.stdout.is_empty()); +} + +#[test] +fn a_malformed_endpoint_or_none_is_exit_2_and_the_endpoint_is_not_echoed() { + let canary = "canary-endpoint-77c1"; + let bad = self_run(&["--instance", NO_SUCH_INSTANCE, "--endpoint", canary]); + assert_eq!(bad.status.code(), Some(2)); + assert!(!all_output(&bad).contains(canary) && bad.stdout.is_empty()); + let none = self_run(&["--instance", NO_SUCH_INSTANCE]); + assert_eq!(none.status.code(), Some(2)); + assert!(none.stdout.is_empty()); + + let from_env = self_run_with_env_endpoint(&["--instance", NO_SUCH_INSTANCE], "canary-env-endpoint-4d02"); + assert_eq!(from_env.status.code(), Some(2)); + assert!(!all_output(&from_env).contains("canary-env-endpoint-4d02") && from_env.stdout.is_empty()); +} + +#[test] +fn a_mind_that_cannot_be_reached_is_exit_2_before_any_child_and_names_no_value() { + // Port 9 on loopback answers no one; PATH is empty, so a child could not start either. + let output = Command::new(env!("CARGO_BIN_EXE_mind-self-run")) + .args(["--instance", NO_SUCH_INSTANCE, "--endpoint", "rudp://127.0.0.1:9"]) + .env("PATH", "") + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2), "{}", all_output(&output)); + assert!(output.stdout.is_empty()); + assert!(!all_output(&output).contains("127.0.0.1"), "{}", all_output(&output)); +} + +#[cfg(unix)] +mod lane { + use std::net::UdpSocket; + use std::path::Path; + use std::process::{Child, Command, Output, Stdio}; + use std::time::Duration; + + use chrono::Utc; + use eureka_pipeline::{ + Date, DocRef, Label, PipelineCampaign, PipelineDocument, PipelineInstance, PipelineRun, PipelineStewardship, PipelineTarget, RunOperator, RunTurn, Sha, + Slug, TargetInvariant, + }; + use huginn_mind::{Faculty, Mind, PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelineProvenance}; + use mind_body::self_run::FLUSH_MESSAGE; + + use super::all_output; + use tempfile::TempDir; + + const INSTANCE: &str = "yggdrasil"; + const CAMPAIGN: &str = "eureka-body"; + const REPO: &str = "GameCult/Huginn"; + // Pinned against Python's uuid.uuid5(uuid.NAMESPACE_OID, "yggdrasil:run:selfrun-lane"). + const LANE_SESSION: &str = "6bbfe181-08ee-5c29-8e32-0ebe48ec4b33"; + + fn doc_ref() -> DocRef { + DocRef { path: "docs/eureka-body-target.md".into(), start_line: 1, end_line: 9, commit: Sha("df7507b".into()) } + } + + /// The identity, stewardship, campaign and target a run cites, and her one + /// open Self run with budget 7.25. + fn seed() -> Vec { + let date = || Date("2026-10-22".into()); + vec![ + PipelineDocument::Instance(PipelineInstance { instance: Slug(INSTANCE.into()), display_name: "test mind".into(), created_at: date(), host: "test".into() }), + PipelineDocument::Stewardship(PipelineStewardship { instance: Slug(INSTANCE.into()), repo: REPO.into(), sequence: 1, assigned_on: date(), note: "assigned".into() }), + PipelineDocument::Campaign(PipelineCampaign { + slug: Slug(CAMPAIGN.into()), + title: "Eureka body".into(), + repos: vec![REPO.into()], + working_branch: "main".into(), + target_doc: doc_ref(), + }), + PipelineDocument::Target(PipelineTarget { + campaign: Slug(CAMPAIGN.into()), + revision: 1, + invariants: vec![TargetInvariant { label: "run-is-the-grant".into(), statement: "A run is the grant.".into() }], + not_in_scope: vec![], + canonical_implementations: vec![], + doc: doc_ref(), + }), + PipelineDocument::Run(PipelineRun { + instance: Slug(INSTANCE.into()), + label: Label("selfrun-lane".into()), + operated_by: RunOperator::Mind, + turn: RunTurn::SelfRun, + host: "test".into(), + started_on: date(), + budget_usd: "7.25".into(), + claims: vec![], + campaigns: vec![Slug(CAMPAIGN.into())], + }), + ] + } + + /// The daemon, ended with the test whether it passes or not. + struct Daemon(Child); + + impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + /// The daemon over a store seeded with `documents`, and the endpoint it listens on. + /// It needs a built `huginn-daemon` beside the binary (`cargo build -p huginn-daemon`). + fn serve(documents: Vec) -> (Daemon, TempDir, String) { + let slug = Slug(INSTANCE.into()); + let state = TempDir::new().unwrap(); + { + let mut mind = Mind::open(state.path(), &slug).unwrap(); + let batch = PipelineAdmissionBatch { + instance: slug.clone(), + provenance: PipelineProvenance { faculty: Faculty::Hands, agent: "test".into(), session: "test".into(), tool: "test".into() }, + documents, + }; + assert!(matches!(mind.admit(batch, Utc::now()), PipelineAdmissionOutcome::Committed { .. })); + } + let port = UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + let exe = Path::new(env!("CARGO_BIN_EXE_mind-self-run")); + let daemon_exe = exe.with_file_name("huginn-daemon"); + assert!(daemon_exe.exists(), "build huginn-daemon first: {}", daemon_exe.display()); + let daemon = Daemon( + Command::new(&daemon_exe) + .args(["--state-root", state.path().to_str().unwrap(), "--instance", INSTANCE, "--bind", &format!("127.0.0.1:{port}")]) + .args(["--qdrant-url", "http://127.0.0.1:1", "--ollama-url", "http://127.0.0.1:1", "--embedding-model", "x"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + (daemon, state, format!("rudp://127.0.0.1:{port}")) + } + + /// The binary with `bin` first on `PATH`, until the daemon answers (exit 2 before that + /// has started nothing). `SELF_RUN_ENV_CANARY` is a variable of the unit's environment. + fn ask(endpoint: &str, bin: &Path) -> Output { + let exe = Path::new(env!("CARGO_BIN_EXE_mind-self-run")); + let path = format!("{}:/usr/bin:/bin", bin.display()); + for _ in 0..40 { + let attempt = Command::new(exe) + .args(["--instance", INSTANCE, "--endpoint", endpoint]) + .env("PATH", &path) + .env("SELF_RUN_ENV_CANARY", ENV_CANARY) + .output() + .unwrap(); + if attempt.status.code() != Some(2) { + return attempt; + } + std::thread::sleep(Duration::from_millis(500)); + } + panic!("the daemon never answered"); + } + + const ENV_CANARY: &str = "canary-unit-environment-b94e"; + + /// Writes an executable `claude` into `bin` and returns the path of the record it keeps. + fn fake_claude(bin: &Path, body: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let record = bin.join("record"); + let script = format!("#!/bin/bash\necho \"$@\" > {record}\n{body}\n", record = record.display()); + let claude = bin.join("claude"); + std::fs::write(&claude, script).unwrap(); + std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + record + } + + /// The real binary against the real daemon, with a fake `claude` first on `PATH`. + /// The fake prints two requests (the second reaches the ceiling), waits for the start + /// and the flush on stdin, records its argument list, what it read and the unit + /// variable it inherited, prints a result and exits 0. The bin finds her run, starts + /// the child for it, asks for the flush and exits 0 without echoing the inherited + /// variable. It runs in the container lane only; `cargo test` and `cargo mutants` + /// skip it. Run it with the README's lane command. + #[test] + #[ignore = "container only: a built huginn-daemon beside the binary, and a POSIX shell"] + fn the_real_bin_finds_her_run_starts_claude_for_it_asks_for_the_flush_and_exits_0() { + let (daemon, _state, endpoint) = serve(seed()); + let bin = TempDir::new().unwrap(); + let request = |id: &str, tokens: u64| { + format!( + r#"{{"type":"assistant","parent_tool_use_id":null,"message":{{"id":"{id}","usage":{{"input_tokens":1,"cache_read_input_tokens":{tokens},"cache_creation_input_tokens":0}}}}}}"# + ) + }; + let body = format!( + "echo journal-line >&2 +echo '{first}'\necho '{second}'\nread -t 5 start\nread -t 5 flush\necho \"$start\" >> {record}\necho \"$flush\" >> {record}\necho \"$SELF_RUN_ENV_CANARY\" >> {record}\necho '{{\"type\":\"result\"}}'\nwhile read more; do :; done\nexit 0", + first = request("msg_1", 100), + second = request("msg_2", 299_999), + record = bin.path().join("record").display(), + ); + let record = fake_claude(bin.path(), &body); + let output = ask(&endpoint, bin.path()); + drop(daemon); + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + assert_eq!(output.status.code(), Some(0), "{stdout}\n{}", String::from_utf8_lossy(&output.stderr)); + assert_eq!(stdout, "request 1 prefix 101\nrequest 2 prefix 300000\nended: flush-requested code 0 requests 2 prefix 300000\n"); + assert!(!all_output(&output).contains(ENV_CANARY), "the unit's environment is the child's, not the bin's output"); + assert!(String::from_utf8_lossy(&output.stderr).contains("journal-line"), "the child's stderr is the unit's journal"); + + let recorded = std::fs::read_to_string(&record).unwrap(); + let mut lines = recorded.lines(); + let argv = lines.next().unwrap(); + assert!(argv.contains(&format!("--session-id {LANE_SESSION}")) && argv.contains("--max-budget-usd 7.25"), "{argv}"); + assert!(argv.starts_with("-p --input-format stream-json"), "{argv}"); + assert!(lines.next().unwrap().contains("yggdrasil:run:selfrun-lane"), "the start message names her run"); + let flush = lines.next().unwrap(); + assert!(flush.contains(FLUSH_MESSAGE) && flush.contains("\"type\":\"user\""), "the flush went to the child's stdin: {flush}"); + assert_eq!(lines.next().unwrap(), ENV_CANARY, "the child inherits the unit's environment"); + } + + /// With no Self run of hers in the mind the bin starts no child and exits 3. + #[test] + #[ignore = "container only: a built huginn-daemon beside the binary, and a POSIX shell"] + fn the_real_bin_with_no_self_run_of_hers_exits_3_and_never_starts_claude() { + let mut documents = seed(); + documents.pop(); + let (daemon, _state, endpoint) = serve(documents); + let bin = TempDir::new().unwrap(); + let record = fake_claude(bin.path(), "exit 0"); + let output = ask(&endpoint, bin.path()); + drop(daemon); + assert_eq!(output.status.code(), Some(3), "{}", all_output(&output)); + assert_eq!(String::from_utf8_lossy(&output.stdout), "no run: she has no single open Self run\n"); + assert!(!record.exists(), "claude was started"); + } +} From f8eaf50a0393f899544ea8ffcd6e069f61e17581 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:38:20 +0200 Subject: [PATCH 002/333] campaign-repos: a campaign's repo set changes by target revision; one line_of decides supersession lineage (squash of eureka-substrate/campaign-repos c0a1f2c..4442e1a) target.repos additive at epoch v2; Docs::repos_in_force owns membership; line_of owns lineage (targets per campaign, cut_specs per campaign+cut, stewardships per instance+repo, rulings and follow_ups global); rival_in_force owns one-assignment-in-force. Soul s5 holds (eureka-substrate:verdict:cut-campaign-repos.s5). Co-Authored-By: Claude Opus 5.5 --- crates/eureka-pipeline/golden/envelopes.txt | 2 + crates/eureka-pipeline/src/lib.rs | 75 +++- crates/huginn-mind/src/admission.rs | 383 +++++++++++++++++- crates/huginn-mind/src/docs.rs | 145 ++++++- crates/huginn-mind/src/fixtures.rs | 1 + crates/huginn-mind/src/refusal.rs | 1 + crates/mind-body/src/testkit.rs | 1 + crates/mind-body/tests/wake.rs | 1 + .../epiphany.pipeline.target.v2.schema.json | 13 + .../huginn.mind_request.v1.schema.json | 8 + .../huginn.mind_response.v1.schema.json | 9 + 11 files changed, 596 insertions(+), 43 deletions(-) diff --git a/crates/eureka-pipeline/golden/envelopes.txt b/crates/eureka-pipeline/golden/envelopes.txt index a5e6fc8..cbd66ac 100644 --- a/crates/eureka-pipeline/golden/envelopes.txt +++ b/crates/eureka-pipeline/golden/envelopes.txt @@ -10,6 +10,7 @@ # Epiphany ef956865 modulo that rename (verdict ...s2). Never regenerate to make # a failing test pass: a diff here is a wire change and needs its own ruling. # cut_spec.reads-and-symbol and finding.location-symbol were appended after 44c010c added CodeLocation.symbol and PipelineCutSpec.reads (ruling context-pack-read-anchors); no earlier line changed. +# target.repos was appended after 9298371 added PipelineTarget.repos (ruling campaign-repo-set-home); no earlier line changed. sample.campaign eureka-state:campaign:self epiphany.pipeline.campaign.v2 EpiphanyPipelineCampaignDocument 9185a4736c7567ac657572656b612d7374617465a57469746c65b5457572656b6120706970656c696e65207374617465a57265706f7391b147616d6543756c742f4570697068616e79ae776f726b696e675f6272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465aa7461726765745f646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864 sample.target eureka-state:target:r2 epiphany.pipeline.target.v2 EpiphanyPipelineTargetDocument 9186a863616d706169676eac657572656b612d7374617465a87265766973696f6e02aa696e76617269616e74739182a56c6162656cab6d696e642d61646d697473a973746174656d656e74b64f6e6c792061646d697373696f6e207772697465732eac6e6f745f696e5f73636f706591ac4576652062726f7773696e67b963616e6f6e6963616c5f696d706c656d656e746174696f6e7391a743756c744c6962a3646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864 sample.question eureka-state:question:Q1 epiphany.pipeline.question.v2 EpiphanyPipelineQuestionDocument 9189a863616d706169676eac657572656b612d7374617465a56c6162656ca25131a57469746c65b357686f206f776e73207468652073746174653fa87175657374696f6eb357686f206f776e73207468652073746174653fa76f7074696f6e739282a56c6162656ca141a474657874ab616e20696e7374616e636582a56c6162656ca142a474657874a661207265706fab7265636f6d6d656e646564a141a7646570656e647391a6437574203361a97261697365645f696e82a46b696e64a743757453706563a26964bf657572656b612d73746174653a6375745f737065633a6375742d33612e7231a861736b65645f6f6eaa323032362d30392d3135 @@ -43,3 +44,4 @@ finding.severity-low eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding ruling.authority-mind eureka-state:ruling:R8 epiphany.pipeline.ruling.v2 EpiphanyPipelineRulingDocument 918aa863616d706169676eac657572656b612d7374617465a56c6162656ca25238a57469746c65b9416e20696e7374616e6365206f776e7320697473206d696e64a7616e7377657273c0a663686f696365c0a672756c696e67ba416e20696e7374616e6365206f776e7320697473206d696e642eae6f70657261746f725f71756f7465c0a872756c65645f6f6eaa323032362d30392d3135aa707265636564656e74739185a47265706fb147616d6543756c742f4165746865726961a6636f6d6d6974d92861616161616161616161616161616161616161616161616161616161616161616161616161616161a46b696e64a652756c696e67a26964b363756c7463616368653a72756c696e673a5231ae7061796c6f61645f736861323536d94062626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262a9617574686f72697479a44d696e64 cut_spec.reads-and-symbol eureka-state:cut_spec:cut-3a.r1 epiphany.pipeline.cut_spec.v2 EpiphanyPipelineCutSpecDocument 91de0013a863616d706169676eac657572656b612d7374617465a3637574a23361a87265766973696f6e01a57469746c65b2506970656c696e6520646f63756d656e7473a47265706fb147616d6543756c742f4570697068616e79a66272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465a462617365a83566393832323864aa646570656e64735f6f6e91a132a5666972737491ae526561642074686520737065632ea764656c657465739183a470617468a66f6c642e7273a56c696e657303a46e6f7465a464656164ab6b656570735f6d6f76657391ac636f6d6d6974206f776e6572a46164647391b5706970656c696e655f646f63756d656e74732e7273ac66696c655f6368616e6765739182a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab76616c75655f7479706573a66368616e6765a3616464a572656164739282a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e650aa8656e645f6c696e6514a673796d626f6caf506970656c696e6543757453706563a3776879b274686520646f63756d656e7420736861706582a86c6f636174696f6e83a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e651ea8656e645f6c696e65c0a3776879a86f6e65206c696e65ad617574686f726974795f6d617087a56f776e6572a4636f7265a6696e7075747391af747970656420646f63756d656e7473a76f75747075747391a9656e76656c6f706573ad646572697665645f737461746591ac64657269766564206b657973b1666f7262696464656e5f7772697465727391a34d4350ac7368617265645f706174687391a961646d697373696f6ead64656c6574696f6e5f6c696e65a36e2f61ac766572696669636174696f6e84a66275696c647391ab636172676f20636865636ba574657374739182a46e616d65a46b657973a470696e73af6f6e652064657269766564206b6579a86e656761746976659182a77061747465726ea75665633c75383ea573636f7065a9646f63756d656e7473a86f70657261746f7291a46e6f6e65a8657374696d61746588ab6c696e65735f6164646564cd0384ad6c696e65735f72656d6f76656400b2646570656e64656e636965735f616464656491a8736368656d617273b4646570656e64656e636965735f72656d6f76656490ad666f726d6174735f616464656491b66570697068616e792e706970656c696e652e2a2e7631af666f726d6174735f72656d6f76656490ad746172676574735f616464656490af746172676574735f72656d6f76656490a772756c696e677391b6657572656b612d73746174653a72756c696e673a5238a97175657374696f6e7391b8657572656b612d73746174653a7175657374696f6e3a5131 finding.location-symbol eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a448696768a5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739184a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab2631313533303930343938b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564 +target.repos eureka-state:target:r2 epiphany.pipeline.target.v2 EpiphanyPipelineTargetDocument 9187a863616d706169676eac657572656b612d7374617465a87265766973696f6e02aa696e76617269616e74739182a56c6162656cab6d696e642d61646d697473a973746174656d656e74b64f6e6c792061646d697373696f6e207772697465732eac6e6f745f696e5f73636f706591ac4576652062726f7773696e67b963616e6f6e6963616c5f696d706c656d656e746174696f6e7391a743756c744c6962a57265706f7392b147616d6543756c742f4570697068616e79af47616d6543756c742f487567696e6ea3646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864 diff --git a/crates/eureka-pipeline/src/lib.rs b/crates/eureka-pipeline/src/lib.rs index 07c6857..d19d192 100644 --- a/crates/eureka-pipeline/src/lib.rs +++ b/crates/eureka-pipeline/src/lib.rs @@ -491,16 +491,17 @@ macro_rules! unit_enums { )*}; } -/// RS-L closing fix 4: `campaign.repos` refuses two entries naming the same +/// RS-L closing fix 4: a repo list refuses two entries naming the same /// repository by identity, not by raw spelling, so `GameCult/Epiphany` and /// `gamecult/epiphany` in one list refuse exactly as one entry twice would. -/// This is a cross-element invariant on `PipelineCampaign` alone, not a -/// per-type format rule any other `value_types!` struct shares, so it is -/// wired in as that struct's one `=> extra` hook rather than folded into -/// `Bounded for OrgRepo` or the list bound in `bounded_field!`. -fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<(), PipelineRefusal> { +/// This is a cross-element invariant on the lists alone, not a per-type format +/// rule any other `value_types!` struct shares, so it is wired in as the +/// `=> extra` hook of the two structs that carry a repo list +/// (`PipelineCampaign.repos` and `PipelineTarget.repos`) rather than folded +/// into `Bounded for OrgRepo` or the list bound in `bounded_field!`. +fn repos_are_distinct(repos: &[OrgRepo], at: &str) -> Result<(), PipelineRefusal> { let mut seen = std::collections::HashSet::new(); - for repo in &campaign.repos { + for repo in repos { if !seen.insert(repo.identity()) { return Err(PipelineRefusal::DuplicateRepo { field: format!("{at}.repos"), @@ -511,6 +512,14 @@ fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result< Ok(()) } +fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<(), PipelineRefusal> { + repos_are_distinct(&campaign.repos, at) +} + +fn target_repos_are_distinct(target: &PipelineTarget, at: &str) -> Result<(), PipelineRefusal> { + repos_are_distinct(&target.repos, at) +} + /// The most lines one read anchor may span. const READ_WINDOW_MAX_LINES: u32 = 100; @@ -625,10 +634,12 @@ value_types! { } pub struct PipelineCampaign { slug: Slug, title: Title, repos: Vec[8], working_branch: Short, target_doc: DocRef } => campaign_repos_are_distinct + /// `repos`, when non-empty, is the campaign's whole repo set from this + /// revision on; empty keeps the previous set (ruling campaign-repo-set-home). pub struct PipelineTarget { campaign: Slug, revision: u32, invariants: Vec[32], not_in_scope: Vec[32], - canonical_implementations: Vec[16], doc: DocRef, - } + canonical_implementations: Vec[16], repos: Vec[16] = absent, doc: DocRef, + } => target_repos_are_distinct pub struct PipelineQuestion { campaign: Slug, label: Label, title: Title, question: Para, options: Vec[8], recommended: Label, depends: Vec[8], raised_in: Option, asked_on: Date, @@ -1238,7 +1249,7 @@ mod tests { (D::Target(PipelineTarget { campaign: slug(CAMPAIGN), revision: 2, invariants: vec![TargetInvariant { label: l("mind-admits"), statement: "Only admission writes.".into() }], - not_in_scope: vec!["Eve browsing".into()], canonical_implementations: vec!["CultLib".into()], doc: doc_ref(), + not_in_scope: vec!["Eve browsing".into()], canonical_implementations: vec!["CultLib".into()], repos: vec![], doc: doc_ref(), }), format!("{CAMPAIGN}:target:r2")), (D::Question(PipelineQuestion { campaign: slug(CAMPAIGN), label: l("Q1"), title: t("Who owns the state?"), question: "Who owns the state?".into(), @@ -1506,6 +1517,9 @@ mod tests { let mut f = finding_sample(); f.locations[0].symbol = Some("&1153090498".into()); out.push(("finding.location-symbol".into(), D::Finding(f))); + let PipelineDocument::Target(mut target) = samples().remove(1).0 else { unreachable!() }; + target.repos = vec![OrgRepo("GameCult/Epiphany".into()), OrgRepo("GameCult/Huginn".into())]; + out.push(("target.repos".into(), D::Target(target))); out } @@ -3163,6 +3177,47 @@ mod tests { assert_eq!(PipelineDocument::Finding(finding).validate(), Ok(())); } + #[test] + fn target_repos_absent_decode_and_round_trip() -> Result<()> { + // The MCP door: a JSON target with no `repos` key decodes to an empty list. + let PipelineDocument::Target(sample) = samples().remove(1).0 else { unreachable!() }; + let mut json = serde_json::to_value(PipelineDocument::Target(sample.clone()))?; + assert!(json["value"].get("repos").is_none(), "an empty repos is not written"); + let PipelineDocument::Target(decoded) = serde_json::from_value::(json.clone())? else { unreachable!() }; + assert!(decoded.repos.is_empty()); + assert!(!rmp_serde::to_vec_named(&PipelineDocument::Target(sample.clone()))?.windows(5).any(|window| window == b"repos")); + + let mut named = sample; + named.repos = vec![OrgRepo("GameCult/Epiphany".into()), OrgRepo("GameCult/Huginn".into())]; + let document = PipelineDocument::Target(named); + document.validate()?; + json = serde_json::to_value(&document)?; + assert_eq!(serde_json::from_value::(json)?, document); + let packed = rmp_serde::to_vec_named(&document)?; + assert_eq!(rmp_serde::from_slice::(&packed)?, document); + assert!(packed.windows(5).any(|window| window == b"repos"), "the repos key is written"); + Ok(()) + } + + #[test] + fn target_repos_bounded_and_distinct() { + let with_repos = |repos: Vec| { + let PipelineDocument::Target(mut target) = samples().remove(1).0 else { unreachable!() }; + target.repos = repos; + PipelineDocument::Target(target).validate() + }; + let distinct = |count: usize| (0..count).map(|index| OrgRepo(format!("GameCult/Repo{index}"))).collect::>(); + assert_eq!(with_repos(distinct(16)), Ok(())); + assert_eq!( + with_repos(distinct(17)), + Err(PipelineRefusal::FieldBound { field: "target.repos".into(), limit: 16, actual: 17 }) + ); + assert_eq!( + with_repos(vec![OrgRepo("GameCult/Huginn".into()), OrgRepo("gamecult/huginn".into())]), + Err(PipelineRefusal::DuplicateRepo { field: "target.repos".into(), repo: "gamecult/huginn".into() }) + ); + } + /// The marker needs an `Absent` spelling; the two impls say what absent is. #[test] fn absent_is_none_and_empty() { diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 8d55f90..96b39f9 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -39,7 +39,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::docs::{CitationRole, Docs, Staged, citations, kind_of_id, outcome_citations}; +use crate::docs::{CitationRole, Docs, Staged, citations, kind_of_id, line_of, outcome_citations}; use crate::mind::{HuginnMindEpoch, Mind, unavailable}; use crate::persona::{self, HuginnPersonaEntry, PersonaPutOutcome}; use crate::receipt::{self, CommitOutcome, PipelineProvenance}; @@ -456,6 +456,15 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { D::Target(target) => { let predecessor = D::Target(eureka_pipeline::PipelineTarget { revision: target.revision.wrapping_sub(1), ..target.clone() }); revision_rule(docs, K::Target, key, target.revision, &predecessor)?; + // A revision past the first may not empty the repo set its predecessor + // named: empty means "keep the previous set", and that would silently + // narrow it to the campaign's opening set (ruling campaign-repo-set-home). + if target.repos.is_empty() && target.revision > 1 { + let named = matches!(docs.find(K::Target, &pipeline_key(&predecessor)?), Some(D::Target(previous)) if !previous.repos.is_empty()); + if named { + return Err(MindRefusal::EmptyRepos { campaign: key.into() }); + } + } unique_labels("target.invariants", target.invariants.iter().map(|invariant| invariant.label.0.as_str())) } D::Question(question) => { @@ -492,7 +501,7 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { let Some(campaign) = campaign else { return Err(MindRefusal::MissingReference { kind: K::Campaign, id: spec.campaign.0.clone() }); }; - if !campaign.repos.contains(&spec.repo) { + if !docs.repos_in_force(campaign).contains(&spec.repo) { return Err(MindRefusal::RepoNotInCampaign { repo: spec.repo.0.clone() }); } for ruling in &spec.rulings { @@ -589,7 +598,7 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { actual: stewardship.sequence, }); } - if docs.stewardships_of(mind, &stewardship.repo, None).iter().any(|(other, _)| *other != key) { + if docs.rival_in_force(key, &staged.document).is_some() { return Err(MindRefusal::AlreadyStewarded { repo: stewardship.repo.0.clone() }); } Ok(()) @@ -761,11 +770,16 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M && 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) + && let Some(rival) = docs.rival_in_force(&reinstating.subject.id.0, base) { + // The repo already has a steward, earlier or later: the admission + // refusal for the same fact, from the same owner. + if let D::Stewardship(base) = base { + return Err(MindRefusal::AlreadyStewarded { repo: base.repo.0.clone() }); + } return Err(MindRefusal::WouldReinstateOverLater { subject: reinstating.subject.id.0.clone(), - later: later.into(), + later: rival.into(), }); } // Withdrawing a run's closure puts the run back in force, and a run in @@ -785,11 +799,12 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M } } 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(()) }; + ResolutionOutcome::Superseded { by } => { + let Some(subject) = docs.find(subject_kind, subject_id) else { return Ok(()) }; + let line = line_of(subject); for supersessor in by { - if let Some(D::CutSpec(spec)) = docs.find(K::CutSpec, &supersessor.id.0) - && spec.cut != subject.cut + if let Some(next) = docs.find(subject_kind, &supersessor.id.0) + && (line.is_none() || line != line_of(next)) { return Err(incompatible()); } @@ -1541,7 +1556,8 @@ mod tests { /// 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. + /// first assignment back in force beside the second: the repo already has + /// a steward. #[test] fn a_withdrawal_does_not_reinstate_a_stewardship_over_a_later_one() { let mut mind = seeded(); @@ -1549,12 +1565,11 @@ mod tests { let D::HandOff(mut back) = hand_off(OTHER_INSTANCE, INSTANCE, REPO, &[]) else { panic!() }; back.handed_on = eureka_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() } + MindRefusal::AlreadyStewarded { repo: REPO.into() } ); let docs = Docs::from_image(mind.envelopes()).unwrap(); let in_force = docs.stewardships_of(&slug(INSTANCE), &repo(REPO), None).iter().map(|(key, _)| *key).collect::>(); @@ -1672,6 +1687,33 @@ mod tests { assert_eq!(in_force, vec![stewardship_key(1)]); } + /// One assignment stands per repo, whichever record a withdrawal would put + /// back: reinstating the later of two withdrawn assignments while the + /// earlier is back in force is refused, and the next assignment then meets + /// exactly the one that stands. + #[test] + fn a_withdrawal_does_not_reinstate_a_stewardship_beside_an_earlier_one() { + let mut mind = seeded(); + let n1 = stewardship_key(1); + let n2 = stewardship_key(2); + let first = format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n1.n1"); + let second = format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n2.n1"); + committed(admit(&mut mind, vec![resolution(r(K::Stewardship, &n1), withdrawn())])); + committed(admit(&mut mind, vec![stewardship_n(INSTANCE, REPO, 2)])); + committed(admit(&mut mind, vec![resolution(r(K::Stewardship, &n2), withdrawn())])); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &first), withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &second), withdrawn())])), + MindRefusal::AlreadyStewarded { repo: REPO.into() } + ); + 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![n1], "the reinstated assignment stands alone"); + assert_eq!( + refusal(admit(&mut mind, vec![stewardship_n(INSTANCE, REPO, 3)])), + MindRefusal::AlreadyStewarded { repo: REPO.into() } + ); + } + /// 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. @@ -1685,7 +1727,7 @@ mod tests { 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) } + MindRefusal::AlreadyStewarded { repo: REPO.into() } ); } @@ -2704,6 +2746,321 @@ mod tests { assert_eq!(refusal(admit(&mut mind, vec![D::CutSpec(spec)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() }); } + /// A target revision naming repos, and the supersession of its predecessor. + fn repo_revision(revision: u32, repos: &[&str]) -> Vec { + let D::Target(mut named) = target(revision, &[INVARIANT]) else { panic!() }; + named.repos = repos.iter().map(|name| repo(name)).collect(); + let previous = r(K::Target, &id("target", &format!("r{}", revision - 1))); + let this = r(K::Target, &id("target", &format!("r{revision}"))); + vec![D::Target(named), resolution(previous, superseded(&[this]))] + } + + fn spec_on(cut: &str, repo_name: &str) -> D { + let mut spec = cut_spec(cut, 1); + spec.repo = repo(repo_name); + D::CutSpec(spec) + } + + #[test] + fn a_target_revision_adds_a_repo_a_spec_may_name() { + let mut mind = seeded(); + assert_eq!(refusal(admit(&mut mind, vec![spec_on("1", OTHER_REPO)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() }); + committed(admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO]))); + committed(admit(&mut mind, vec![spec_on("1", OTHER_REPO)])); + + // The same revision, supersession and spec in one batch. + let mut fresh = seeded(); + let mut batch = repo_revision(2, &[REPO, OTHER_REPO]); + batch.push(spec_on("1", OTHER_REPO)); + committed(admit(&mut fresh, batch)); + } + + #[test] + fn a_target_revision_removes_a_repo() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![spec_on("1", REPO)])); + committed(admit(&mut mind, repo_revision(2, &[OTHER_REPO]))); + let spec_id = id("cut_spec", "cut-1.r1"); + let spec_view = mind.view(&r(K::CutSpec, &spec_id)).expect("a valid ref").expect("the spec is held"); + assert_eq!(spec_view.status, crate::query::PipelineStatus::InForce, "spec 1 r1 stays in force"); + committed(admit(&mut mind, vec![D::CutReport(cut_report("1", 1))])); + committed(admit(&mut mind, vec![resolution(r(K::CutSpec, &spec_id), withdrawn())])); + + let mut second = cut_spec("1", 2); + second.repo = repo(REPO); + let revised = id("cut_spec", "cut-1.r2"); + assert_eq!( + refusal(admit(&mut mind, vec![D::CutSpec(second), resolution(r(K::CutSpec, &spec_id), superseded(&[r(K::CutSpec, &revised)]))])), + MindRefusal::RepoNotInCampaign { repo: REPO.into() } + ); + committed(admit(&mut mind, vec![spec_on("2", OTHER_REPO)])); + } + + #[test] + fn a_target_revision_cannot_silently_drop_its_repos() { + let mut mind = seeded(); + committed(admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO]))); + assert_eq!( + refusal(admit(&mut mind, repo_revision(3, &[]))), + MindRefusal::EmptyRepos { campaign: id("target", "r3") } + ); + // Nothing was written: the restating revision takes the same key. + committed(admit(&mut mind, repo_revision(3, &[REPO, OTHER_REPO]))); + // An empty revision whose predecessor named none keeps the campaign's set. + let mut plain = seeded(); + committed(admit(&mut plain, repo_revision(2, &[]))); + } + + #[test] + fn a_repo_revision_replays_idempotently() { + let mut mind = seeded(); + let (first, _) = committed(admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO]))); + assert_eq!( + admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO])), + PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: first } + ); + committed(admit(&mut mind, vec![spec_on("1", OTHER_REPO)])); + } + + /// Another campaign, opened with `repos`, and its own target revision 1 naming `named`. + fn other_campaign(repos: &[&str], named: &[&str]) -> Vec { + let D::Campaign(mut campaign) = campaign(repos) else { panic!("a campaign") }; + campaign.slug = "other-campaign".into(); + let D::Target(mut target) = target(1, &[INVARIANT]) else { panic!("a target") }; + target.campaign = "other-campaign".into(); + target.repos = named.iter().map(|name| repo(name)).collect(); + vec![D::Campaign(campaign), D::Target(target)] + } + + #[test] + fn another_campaigns_target_does_not_widen_membership() { + let mut mind = seeded(); + committed(admit(&mut mind, other_campaign(&[REPO], &[REPO, OTHER_REPO]))); + assert_eq!(refusal(admit(&mut mind, vec![spec_on("1", OTHER_REPO)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() }); + } + + #[test] + fn a_target_is_superseded_only_within_its_campaign() { + let mut mind = seeded(); + committed(admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO]))); + committed(admit(&mut mind, other_campaign(&[REPO], &[]))); + let foreign = r(K::Target, "other-campaign:target:r1"); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Target, &id("target", "r2")), superseded(&[foreign]))])), + MindRefusal::IncompatibleResolution { subject_kind: K::Target, outcome: "Superseded".into() } + ); + // Nothing was written: r2 still names the repos, and its own revision still lands. + committed(admit(&mut mind, vec![spec_on("1", OTHER_REPO)])); + committed(admit(&mut mind, repo_revision(3, &[REPO, OTHER_REPO]))); + } + + /// A target of the first campaign (`CAMPAIGN`) at `revision`, naming no repos. + fn own_target(revision: u32) -> D { + target(revision, &[INVARIANT]) + } + + const FOREIGN_TARGET: &str = "other-campaign:target:r1"; + + fn supersession_refused(outcome: PipelineAdmissionOutcome, subject_kind: K) -> bool { + refusal(outcome) == MindRefusal::IncompatibleResolution { subject_kind, outcome: "Superseded".into() } + } + + #[test] + fn a_foreign_target_in_the_same_batch_does_not_supersede() { + let mut mind = seeded(); + let mut batch = other_campaign(&[REPO], &[]); + batch.push(resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, FOREIGN_TARGET)]))); + assert!(supersession_refused(admit(&mut mind, batch), K::Target)); + } + + #[test] + fn every_supersessor_of_a_target_is_checked_whichever_is_foreign() { + for foreign_first in [false, true] { + let mut mind = seeded(); + committed(admit(&mut mind, other_campaign(&[REPO], &[]))); + let own = r(K::Target, &id("target", "r2")); + let foreign = r(K::Target, FOREIGN_TARGET); + let by = if foreign_first { [foreign, own] } else { [own, foreign] }; + let batch = vec![own_target(2), resolution(r(K::Target, &id("target", "r1")), superseded(&by))]; + assert!(supersession_refused(admit(&mut mind, batch), K::Target), "foreign first: {foreign_first}"); + } + } + + #[test] + fn a_new_campaigns_target_in_its_own_batch_is_not_closed_by_a_foreign_one() { + let mut mind = seeded(); + let mut batch = other_campaign(&[REPO], &[]); + batch.push(resolution(r(K::Target, FOREIGN_TARGET), superseded(&[r(K::Target, &id("target", "r1"))]))); + assert!(supersession_refused(admit(&mut mind, batch), K::Target)); + } + + #[test] + fn a_cut_spec_is_superseded_only_within_its_campaign() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![spec_on("1", REPO)])); + let mut batch = other_campaign(&[REPO], &[]); + let D::CutSpec(mut foreign) = spec_on("1", REPO) else { panic!("a spec") }; + foreign.campaign = "other-campaign".into(); + batch.push(D::CutSpec(foreign)); + committed(admit(&mut mind, batch)); + let lone = vec![resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), superseded(&[r(K::CutSpec, "other-campaign:cut_spec:cut-1.r1")]))]; + assert!(supersession_refused(admit(&mut mind, lone), K::CutSpec)); + // Nothing was written: this campaign's own revision still lands. + let D::CutSpec(mut next) = spec_on("1", REPO) else { panic!("a spec") }; + next.revision = 2; + let this = r(K::CutSpec, &id("cut_spec", "cut-1.r2")); + committed(admit(&mut mind, vec![D::CutSpec(next), resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), superseded(&[this]))])); + } + + #[test] + fn a_ruling_is_superseded_by_a_ruling_of_any_campaign() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::Ruling(ruling("old"))])); + let mut batch = other_campaign(&[REPO], &[]); + let mut foreign = ruling("new"); + foreign.campaign = "other-campaign".into(); + batch.push(D::Ruling(foreign)); + batch.push(resolution(r(K::Ruling, &id("ruling", "old")), superseded(&[r(K::Ruling, "other-campaign:ruling:new")]))); + committed(admit(&mut mind, batch)); + } + + /// The slug that shares its dash prefix with `CAMPAIGN` (`eureka-state`). + const RIVAL: &str = "eureka-substrate"; + + /// Revision `revision` of the rival campaign's target, with its campaign when it is + /// the first and the supersession of its predecessor when it is not. + fn rival_revision(revision: u32) -> Vec { + let D::Target(mut named) = target(revision, &[INVARIANT]) else { panic!("a target") }; + named.campaign = RIVAL.into(); + let mut batch = Vec::new(); + if revision == 1 { + let D::Campaign(mut opened) = campaign(&[REPO]) else { panic!("a campaign") }; + opened.slug = RIVAL.into(); + batch.push(D::Campaign(opened)); + } + batch.push(D::Target(named)); + if revision > 1 { + let previous = r(K::Target, &format!("{RIVAL}:target:r{}", revision - 1)); + batch.push(resolution(previous, superseded(&[r(K::Target, &format!("{RIVAL}:target:r{revision}"))]))); + } + batch + } + + /// Both campaigns at revision 3, so each one's target in force is above revision 1. + fn both_campaigns_at_revision_three() -> Mind { + let mut mind = seeded(); + for revision in 2..=3 { + committed(admit(&mut mind, repo_revision(revision, &[REPO]))); + } + for revision in 1..=3 { + committed(admit(&mut mind, rival_revision(revision))); + } + mind + } + + #[test] + fn a_target_is_not_superseded_by_a_campaign_sharing_its_prefix_or_a_higher_revision() { + for (subject, supersessor) in [ + (id("target", "r3"), format!("{RIVAL}:target:r3")), + (format!("{RIVAL}:target:r3"), id("target", "r3")), + ] { + let mut mind = both_campaigns_at_revision_three(); + let lone = vec![resolution(r(K::Target, &subject), superseded(&[r(K::Target, &supersessor)]))]; + assert!(supersession_refused(admit(&mut mind, lone), K::Target), "{subject} by {supersessor}"); + } + // A foreign revision above the subject's is no likelier to pass. + let mut mind = seeded(); + committed(admit(&mut mind, repo_revision(2, &[REPO]))); + for revision in 1..=4 { + committed(admit(&mut mind, rival_revision(revision))); + } + let lone = vec![resolution(r(K::Target, &id("target", "r2")), superseded(&[r(K::Target, &format!("{RIVAL}:target:r4"))]))]; + assert!(supersession_refused(admit(&mut mind, lone), K::Target)); + } + + #[test] + fn a_stewardship_is_superseded_only_on_its_own_instance_and_repo_line() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![stewardship(INSTANCE, OTHER_REPO)])); + let epiphany = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1"); + let huginn = format!("{INSTANCE}:stewardship:gamecult_-huginn.n1"); + let lone = vec![resolution(r(K::Stewardship, &epiphany), superseded(&[r(K::Stewardship, &huginn)]))]; + assert!(supersession_refused(admit(&mut mind, lone), K::Stewardship)); + // Nothing was written: the repo's own next assignment still supersedes it. + let next = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n2"); + committed(admit(&mut mind, vec![ + stewardship_n(INSTANCE, REPO, 2), + resolution(r(K::Stewardship, &epiphany), superseded(&[r(K::Stewardship, &next)])), + ])); + } + + /// Re-homing work is how Self routes it (ruling `supersession-lineage-3`): + /// a gap follow-up of one campaign is superseded by its successor in the + /// campaign that owns the work now, and the typed edge survives. + #[test] + fn a_follow_up_is_superseded_by_one_re_homed_to_another_campaign() { + let mut mind = seeded(); + let source = r(K::Target, &id("target", "r1")); + committed(admit(&mut mind, vec![follow_up("gap-session-id", source.clone())])); + let mut batch = rival_revision(1); + let D::FollowUp(mut foreign) = follow_up("gap-session-id", source) else { panic!("a follow-up") }; + foreign.campaign = RIVAL.into(); + batch.push(D::FollowUp(foreign)); + batch.push(resolution( + r(K::FollowUp, &id("follow_up", "gap-session-id")), + superseded(&[r(K::FollowUp, &format!("{RIVAL}:follow_up:gap-session-id"))]), + )); + committed(admit(&mut mind, batch)); + let docs = docs_of(&mind); + assert!(!docs.in_force(K::FollowUp, &id("follow_up", "gap-session-id"))); + assert!(docs.in_force(K::FollowUp, &format!("{RIVAL}:follow_up:gap-session-id"))); + } + + #[test] + fn supersession_on_its_own_line_commits_for_every_kind() { + let source = r(K::Target, &id("target", "r1")); + let mut mind = seeded(); + committed(admit(&mut mind, repo_revision(2, &[REPO]))); + let mut mind = seeded(); + committed(admit(&mut mind, vec![spec_on("1", REPO)])); + let D::CutSpec(mut next) = spec_on("1", REPO) else { panic!("a spec") }; + next.revision = 2; + committed(admit(&mut mind, vec![ + D::CutSpec(next), + resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), superseded(&[r(K::CutSpec, &id("cut_spec", "cut-1.r2"))])), + ])); + let mut mind = seeded(); + committed(admit(&mut mind, vec![follow_up("old", source.clone())])); + committed(admit(&mut mind, vec![ + follow_up("new", source), + resolution(r(K::FollowUp, &id("follow_up", "old")), superseded(&[r(K::FollowUp, &id("follow_up", "new"))])), + ])); + let mut mind = seeded(); + let epiphany = format!("{INSTANCE}:stewardship:gamecult_-epiphany"); + committed(admit(&mut mind, vec![ + stewardship_n(INSTANCE, REPO, 2), + resolution(r(K::Stewardship, &format!("{epiphany}.n1")), superseded(&[r(K::Stewardship, &format!("{epiphany}.n2"))])), + ])); + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::Ruling(ruling("old"))])); + committed(admit(&mut mind, vec![ + D::Ruling(ruling("new")), + resolution(r(K::Ruling, &id("ruling", "old")), superseded(&[r(K::Ruling, &id("ruling", "new"))])), + ])); + } + + #[test] + fn a_ruling_of_one_campaign_is_superseded_by_a_ruling_of_a_prefix_sharing_campaign() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::Ruling(ruling("old"))])); + let mut batch = rival_revision(1); + let mut foreign = ruling("new"); + foreign.campaign = RIVAL.into(); + batch.push(D::Ruling(foreign)); + batch.push(resolution(r(K::Ruling, &id("ruling", "old")), superseded(&[r(K::Ruling, &format!("{RIVAL}:ruling:new"))]))); + committed(admit(&mut mind, batch)); + } + #[test] fn a_hand_off_derives_this_minds_side_only() { let key = format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.gamecult_-epiphany.2026-09-16"); diff --git a/crates/huginn-mind/src/docs.rs b/crates/huginn-mind/src/docs.rs index 2427338..f7af836 100644 --- a/crates/huginn-mind/src/docs.rs +++ b/crates/huginn-mind/src/docs.rs @@ -7,7 +7,7 @@ use cultcache_rs::CultCacheEnvelope; use eureka_pipeline::{ - OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, PipelineRun, PipelineStewardship, ResolutionOutcome, + Label, OrgRepo, PipelineCampaign, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, PipelineRun, PipelineStewardship, ResolutionOutcome, RunOperator, Short, Slug, }; use schemars::JsonSchema; @@ -182,6 +182,23 @@ impl Docs { }) } + /// The repos a campaign's cuts may name: the repo set of the highest-revision + /// in-force target of `campaign.slug` that names one, else the campaign's own + /// opening set. The one owner of membership (ruling campaign-repo-set-home). + pub(crate) fn repos_in_force<'a>(&'a self, campaign: &'a PipelineCampaign) -> &'a [OrgRepo] { + self.of_kind(PipelineKind::Target) + .filter_map(|(key, document)| match document { + PipelineDocument::Target(target) + if target.campaign == campaign.slug && !target.repos.is_empty() && self.in_force(PipelineKind::Target, key) => + { + Some(target) + } + _ => None, + }) + .max_by_key(|target| target.revision) + .map_or(campaign.repos.as_slice(), |target| target.repos.as_slice()) + } + /// 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)> { @@ -207,12 +224,17 @@ impl Docs { .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> { + /// The in-force record, other than `base_key`, that cannot stand beside + /// `base` on its line: the one question "may this record be in force" + /// asks, whether `base` is being admitted (a stewardship's + /// `AlreadyStewarded`) or put back in force by a withdrawal. A line holds + /// one stewardship at a time, so any other stands in its way; a revision + /// is only displaced by a later one. + pub(crate) fn rival_in_force(&self, base_key: &str, 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) + self.of_kind(kind) + .find(|(key, other)| *key != base_key && rivals(base, other) && self.in_force(kind, key)) + .map(|(key, _)| key) } /// The in-force run, other than `run_key`, that holds `claim`: the one @@ -282,22 +304,57 @@ impl Docs { } } -/// 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 { +/// The line a record belongs to (rulings `supersession-lineage-2` and `-3`): the one place +/// lineage is decided. A record supersedes, and is reinstated over, only a record +/// of its own line. `None` is a kind the resolution matrix never lets be +/// superseded; the match names every kind, so a new kind fails to compile until +/// its line is stated. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Lineage<'a> { + /// A target: the campaign. + Campaign(&'a Slug), + /// A cut spec: the campaign and the cut. + Cut(&'a Slug, &'a Label), + /// A stewardship: the instance and the repo. + Assignment(&'a Slug, &'a OrgRepo), + /// A ruling or a follow-up: operator directions and re-homed work cut + /// across campaigns. + Global, +} + +pub(crate) fn line_of(document: &PipelineDocument) -> Option> { use PipelineDocument as D; + match document { + D::Target(target) => Some(Lineage::Campaign(&target.campaign)), + D::CutSpec(spec) => Some(Lineage::Cut(&spec.campaign, &spec.cut)), + D::Stewardship(stewardship) => Some(Lineage::Assignment(&stewardship.instance, &stewardship.repo)), + D::Ruling(_) | D::FollowUp(_) => Some(Lineage::Global), + D::Campaign(_) + | D::Question(_) + | D::CutReport(_) + | D::Verdict(_) + | D::Finding(_) + | D::Resolution(_) + | D::Instance(_) + | D::HandOff(_) + | D::Run(_) => None, + } +} + +/// Whether `other` cannot stand in force beside `base`, being on `base`'s line: +/// any other stewardship of an instance and repo, a later revision of a target +/// or cut spec. A resolution is never a base here: the cap in `resolution_rule` +/// refuses the withdrawal that would reinstate one first. +fn rivals(base: &PipelineDocument, other: &PipelineDocument) -> bool { + use PipelineDocument as D; + let Some(line) = line_of(base) else { return false }; + if line_of(other) != Some(line) { + return false; + } 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 - } + (D::Stewardship(_), D::Stewardship(_)) => true, + (D::Target(base), D::Target(other)) => other.revision > base.revision, + (D::CutSpec(base), D::CutSpec(other)) => other.revision > base.revision, _ => false, } } @@ -484,3 +541,51 @@ pub(crate) fn citations(document: &PipelineDocument) -> Vec<(CitationRole, Pipel } edges } + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::*; + use PipelineDocument as D; + use PipelineKind as K; + + fn held(kind: K, key: String, document: D) -> Held { + Held { kind, key, document } + } + + fn named(campaign: &str, revision: u32, repos: &[&str]) -> (String, D) { + let D::Target(mut target) = target(revision, &[INVARIANT]) else { panic!("a target") }; + target.campaign = campaign.into(); + target.repos = repos.iter().map(|name| repo(name)).collect(); + (format!("{campaign}:target:r{revision}"), D::Target(target)) + } + + /// Membership is the highest-revision target of this campaign that is in + /// force and names repos. The image is built by hand because admission + /// cannot reach it: a revision above 1 supersedes its predecessor in its + /// own batch, a target is superseded only within its campaign, and an + /// empty revision after a repo-naming one is refused, so through admission + /// a closed repo-naming target is always older than the in-force one. + /// + /// Taking the lowest revision instead of the highest changes nothing there + /// either (one target per campaign is in force), so that mutation is + /// equivalent and not pinned. + #[test] + fn repos_in_force_is_this_campaigns_in_force_naming_target() { + let D::Campaign(this) = campaign(&[REPO]) else { panic!("a campaign") }; + let (in_force_key, in_force) = named(CAMPAIGN, 1, &[REPO, OTHER_REPO]); + let (closed_key, closed) = named(CAMPAIGN, 2, &["GameCult/Closed"]); + let (foreign_key, foreign) = named("other-campaign", 3, &["GameCult/Foreign"]); + let closing = resolution(r(K::Target, &closed_key), superseded(&[r(K::Target, &in_force_key)])); + let docs = Docs { + image: vec![ + held(K::Target, in_force_key, in_force), + held(K::Target, closed_key, closed), + held(K::Target, foreign_key, foreign), + held(K::Resolution, id("resolution", "target.r2.n1"), closing), + ], + batch: Vec::new(), + }; + assert_eq!(docs.repos_in_force(&this), [repo(REPO), repo(OTHER_REPO)], "neither the closed higher revision nor another campaign's higher target decides"); + } +} diff --git a/crates/huginn-mind/src/fixtures.rs b/crates/huginn-mind/src/fixtures.rs index 86cb827..77dbd45 100644 --- a/crates/huginn-mind/src/fixtures.rs +++ b/crates/huginn-mind/src/fixtures.rs @@ -155,6 +155,7 @@ pub(crate) fn target(revision: u32, invariants: &[&str]) -> PipelineDocument { .collect(), not_in_scope: vec![], canonical_implementations: vec![], + repos: vec![], doc: doc_ref(), }) } diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index e85113a..150f652 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -41,6 +41,7 @@ pub enum MindRefusal { InvalidOptions { question: String }, InvalidChoice { ruling: String, choice: String }, QuoteWithoutOperator { ruling: String }, + /// `campaign` names the refused document: a campaign, or a target revision that empties its predecessor's repos. EmptyRepos { campaign: String }, RepoNotInCampaign { repo: String }, UnknownDependency { cut: String }, diff --git a/crates/mind-body/src/testkit.rs b/crates/mind-body/src/testkit.rs index d00db01..2c27bf6 100644 --- a/crates/mind-body/src/testkit.rs +++ b/crates/mind-body/src/testkit.rs @@ -214,6 +214,7 @@ fn target() -> PipelineDocument { invariants: vec![TargetInvariant { label: "run-is-the-grant".into(), statement: "A run is the grant.".into() }], not_in_scope: vec![], canonical_implementations: vec![], + repos: vec![], doc: doc_ref(), }) } diff --git a/crates/mind-body/tests/wake.rs b/crates/mind-body/tests/wake.rs index c535391..58ffe12 100644 --- a/crates/mind-body/tests/wake.rs +++ b/crates/mind-body/tests/wake.rs @@ -116,6 +116,7 @@ fn seed() -> Vec { invariants: vec![TargetInvariant { label: "run-is-the-grant".into(), statement: "A run is the grant.".into() }], not_in_scope: vec![], canonical_implementations: vec![], + repos: vec![], doc: doc_ref(), }), ] diff --git a/schemas/cultnet/epiphany.pipeline.target.v2.schema.json b/schemas/cultnet/epiphany.pipeline.target.v2.schema.json index 32a5ab3..ee7fd68 100644 --- a/schemas/cultnet/epiphany.pipeline.target.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.target.v2.schema.json @@ -37,6 +37,11 @@ "maxLength": 1000, "type": "string" }, + "OrgRepo": { + "description": "A GitHub repository as `Org/Repo`, at most 140 UTF-8 bytes (39 + 1 +\n100). The owner is 1 to 39 `[A-Za-z0-9-]` bytes with no leading or\ntrailing hyphen; the repo is 1 to 100 `[A-Za-z0-9._-]` bytes, is never\n`.` or `..`, and does not end in `.git`, ASCII-case-insensitively.\nIdentity is case-insensitive: `GameCult/Epiphany` and `gamecult/epiphany`\nare the same repository.", + "maxLength": 140, + "type": "string" + }, "Sha": { "description": "A git commit id: 7-40 lowercase hex characters.", "maxLength": 40, @@ -70,6 +75,7 @@ }, "$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.target.v2.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "`repos`, when non-empty, is the campaign's whole repo set from this\nrevision on; empty keeps the previous set (ruling campaign-repo-set-home).", "properties": { "campaign": { "$ref": "#/$defs/Slug" @@ -98,6 +104,13 @@ "maxItems": 32, "type": "array" }, + "repos": { + "items": { + "$ref": "#/$defs/OrgRepo" + }, + "maxItems": 16, + "type": "array" + }, "revision": { "format": "uint32", "minimum": 0, diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index c075586..77ee834 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -1542,6 +1542,7 @@ ] }, "PipelineTarget": { + "description": "`repos`, when non-empty, is the campaign's whole repo set from this\nrevision on; empty keeps the previous set (ruling campaign-repo-set-home).", "type": "object", "properties": { "campaign": { @@ -1571,6 +1572,13 @@ }, "maxItems": 32 }, + "repos": { + "type": "array", + "items": { + "$ref": "#/$defs/OrgRepo" + }, + "maxItems": 16 + }, "revision": { "type": "integer", "format": "uint32", diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index 4fd5482..28d1fa2 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -1152,6 +1152,7 @@ ] }, { + "description": "`campaign` names the refused document: a campaign, or a target revision that empties its predecessor's repos.", "type": "object", "properties": { "EmptyRepos": { @@ -3801,6 +3802,7 @@ ] }, "PipelineTarget": { + "description": "`repos`, when non-empty, is the campaign's whole repo set from this\nrevision on; empty keeps the previous set (ruling campaign-repo-set-home).", "type": "object", "properties": { "campaign": { @@ -3830,6 +3832,13 @@ }, "maxItems": 32 }, + "repos": { + "type": "array", + "items": { + "$ref": "#/$defs/OrgRepo" + }, + "maxItems": 16 + }, "revision": { "type": "integer", "format": "uint32", From 98355ac5df4604e90245f1fab8adf9e181636979 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:41:21 +0200 Subject: [PATCH 003/333] mind-body self_run test: PipelineTarget gains repos (merge adaptation for campaign-repos) Co-Authored-By: Claude Opus 5.5 --- crates/mind-body/tests/self_run.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/mind-body/tests/self_run.rs b/crates/mind-body/tests/self_run.rs index 9c6d1b2..390b4d4 100644 --- a/crates/mind-body/tests/self_run.rs +++ b/crates/mind-body/tests/self_run.rs @@ -130,6 +130,7 @@ mod lane { not_in_scope: vec![], canonical_implementations: vec![], doc: doc_ref(), + repos: vec![], }), PipelineDocument::Run(PipelineRun { instance: Slug(INSTANCE.into()), From 6066ef019586fc6d9f5c2c934a3272fcab58ec3a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 19:29:56 +0200 Subject: [PATCH 004/333] eureka-substrate map: recall at admission (body facts R-B1..R-B9, cut sequence) Imagination pass for cut recall-admit (session self-2026-10-23-eureka-b): hook input fields, digest stability, latency, fail-open rationale, and the re-scoped trigger order with the prompt hook last. Co-Authored-By: Claude Opus 5.5 --- docs/eureka-substrate-map.md | 99 ++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/docs/eureka-substrate-map.md b/docs/eureka-substrate-map.md index 911f452..c2a529e 100644 --- a/docs/eureka-substrate-map.md +++ b/docs/eureka-substrate-map.md @@ -381,3 +381,102 @@ mind-launch in admission.rs, docs.rs and the response schema, so whichever merges second rebases and regenerates the schema) -> skill-campaign-repos (Eureka campaign-state.md). huginn-cli's campaigns recipe, if it prints repos, prints the opening set until it reads the target too. + +## Recall at admission (Imagination, 2026-10-23) + +Serves `eureka-substrate:ruling:recall-triggers-rescoped` (order of work) and +`eureka-substrate:ruling:admit-near-duplicate-deny-once` (the design, ruled). +Spec: `eureka-substrate:cut_spec:cut-recall-admit.r1`, GameCult/Eureka at +`614caf8` (main). Prior-art facts: Eureka `docs/research/recall-triggers-prior-art.md` +on branch `eureka-substrate/recall-triggers-research` (PR #28); the claims this +cut rests on were re-checked below rather than taken from it. + +### Body facts (probes, 2026-10-23, Starfire) + +- R-B1. Harness version. `claude --version` on PATH (npm) is 2.1.268; the + harness running these sessions is the desktop's bundled claude-code 2.1.308 + (`CLAUDE_CODE_EXECPATH`). Hooks in `~/.claude/settings.json` are read by + 2.1.308. Facts about hook behaviour must name 2.1.308, not 2.1.268. +- R-B2. Headless probe blocked. `claude -p` from a scratch project with a + throwaway `.claude/settings.json` failed on both binaries: "Failed to + authenticate: OAuth session expired and could not be refreshed". No live hook + run was possible; the facts below are source reads of the 2.1.308 bundle + (strings in `claude.exe`) plus the docs. The live checks moved to the spec's + `verification.operator`. +- R-B3. Hook input (source read, 2.1.308, the common-input builder). Fields: + `session_id` = the session's id (the same value inside subagents: the + subagent's tool context carries the parent session), `transcript_path`, `cwd`, + `scratchpad_dir` (present when the scratchpad feature is on; derived from the + session id, so shared by root and subagents), `prompt_id`, `permission_mode`, + `agent_id` and `agent_type` (the field's own description: present only when + the hook fires from within a subagent), `effort`. PreToolUse adds + `tool_name`, `tool_input`, `tool_use_id`, and `mcp_server` for MCP tools. +- R-B4. Deny reaches the model (source read, 2.1.308): a PreToolUse + `permissionDecision: "deny"` becomes the call's block message, + `permissionDecisionReason ?? "Blocked by hook"`. Docs agree ("For deny, shown + to Claude"). Not observed live (R-B2). +- R-B5. Subagents (docs, verbatim in the PR #28 facts; source agrees by the + `agent_id` description): settings-file tool hooks fire for a subagent's tool + calls. So one user-settings entry covers Self, Hands, Soul and Imagination + admits. Not observed live (R-B2). +- R-B6. Matcher: `mcp__eureka-state__admit` is all letters, digits, `_` and + `-`, so it is an exact match (docs' matcher rule); `mcp__eureka-state__.*` is + a regex and would also fire on view and query. +- R-B7. Digest stability: the hook sees `tool_input` as parsed JSON, so a + digest over sorted-key canonical JSON is independent of key order and + whitespace in the model's call. An "identical retry" is identical in value, + which is what the ruling asks of the agent. +- R-B8. Latency (live mind, through `tools/mind.py` at `614caf8`): whoami 0.14 s + (spawn and handshake are cheap); one semantic query 2.17-2.27 s, flat from a + 45-byte to a 1000-byte text; three queries in one binding session 6.49 s (no + saving: the cost is server-side); three concurrent `mind.call`s 4.62 s wall, + six 8.26 s (partly serialised, about 1.2 s per extra query). Bound: at most 3 + checked documents, concurrent, deadline 10 s, hook timeout 15 s. +- R-B9. For the next cut (not specified): whether PreToolUse `updatedInput` on + the Agent tool reaches the subagent's prompt on 2.1.308 is unproven. The + bundle validates `updatedInput` generically; no Agent-specific handling was + found by string search, which proves nothing either way. GitHub #39814 + (ignored on 2.1.85+, closed not planned) stands unrefuted. SubagentStart's + input carries `agent_id` and `agent_type` and no prompt (source read); its + `additionalContext` reaching the subagent is documented, not observed. + +### Model page: the held-once record + +| Question | Answer | +|---|---| +| What names it | A digest: sha256 of canonical `tool_input` plus `agent_id` (empty at root). Injective up to hash collision; two agents sending the same batch get different digests, so each is shown the neighbours once. | +| Where it lives | `/recall-admit-digests` when `scratchpad_dir` is a directory, else a temp file keyed by a hash of `session_id`. Session-scoped; it dies with the scratchpad. | +| Lifecycle | Appended when a batch is held; read on the next admit; never edited or replayed. A batch changed after the hold has a new digest and is held again. | +| Who decides | `tools/recall.py admit` alone. It never allows or asks (that would bypass the operator's permission rules); acceptance stays Huginn admission's. If it cannot record the digest it does not hold the call, because a hold it cannot remember would hold every retry. | + +### Rationale + +- Why every batch with a ruling or question is held once: the semantic page + carries no score (`gap-semantic-page-no-score`), so "near" cannot be + measured, and nearest-neighbour search always returns k results. The ruled + design accepts one round trip per such batch and leaves the judgement to the + agent. Exclusions (own id, batch ids, the answered question, superseded + subjects) keep the list from naming what the batch already handles. +- Query text is the document's title: focused one-topic texts retrieve well and + long multi-topic ones do not (`gap-semantic-one-text-per-query`). +- Fail open: an admit hook that holds when Huginn is down would hold every + admission; and the admit itself fails on Unavailable anyway. A failure prints + one fixed sentence as `additionalContext` (it lands next to the tool result) + so the agent knows the batch went unchecked; it never echoes an input. +- The hook is bound to the MCP tool name. An admit path that bypasses it (the + `huginn` CLI cuts) must carry the check: `eureka-substrate:follow_up:admit-check-follows-admit-path`. + +### Cut sequence + +1. recall-admit (Eureka): `cut-recall-admit.r1`. +2. recall-dispatch (Eureka, or brief text): the in-force records nearest a + subagent brief reach the subagent. First a live probe on the harness + version (R-B9): Agent `updatedInput` vs a SubagentStart hook (no prompt in + its input, so it needs the brief from elsewhere) vs Self putting the recall + into the brief it writes. Needs `claude -p` authenticated (R-B2). +3. recall-session-start (Eureka): a SessionStart hook recalls against the + area's handoff and open campaigns. +4. The prompt hook (`recall.py prompt`, landed, uninstalled) is installed last, + if at all. The retrieval-quality gaps (`gap-semantic-page-no-score`, + `gap-semantic-one-text-per-query`, `gap-persona-state-not-indexed`) come + before cuts 2-4. From 0b8ae4f425a778c7cc84559bd6c8138141c02614 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 19:35:21 +0200 Subject: [PATCH 005/333] eureka-substrate map: R-B10 live hook probes on claude-code 2.1.308 Admit deny reaches the model (prefixed "hook error"); SubagentStart context and the admit hook both reach subagents; PreToolUse updatedInput on Agent does rewrite the subagent's prompt (GitHub #39814 does not hold here). Co-Authored-By: Claude Opus 5.5 --- docs/eureka-substrate-map.md | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/eureka-substrate-map.md b/docs/eureka-substrate-map.md index c2a529e..7a80ab1 100644 --- a/docs/eureka-substrate-map.md +++ b/docs/eureka-substrate-map.md @@ -439,6 +439,27 @@ cut rests on were re-checked below rather than taken from it. (ignored on 2.1.85+, closed not planned) stands unrefuted. SubagentStart's input carries `agent_id` and `agent_type` and no prompt (source read); its `additionalContext` reaching the subagent is documented, not observed. +- R-B10 (Self, 2026-10-23, live, after the operator re-authenticated the CLI): + three `claude -p --model haiku` runs on the desktop's claude-code 2.1.308 + (`...\Claude-Data\...\claude-code\2.1.308\...\claude.exe`), from the scratch + project `scratchpad\imagination-recall-admit\hookprobe` (its project + `.claude/settings.json`, hook `hook.py` that denies every admit). + (a) Root admit: the deny reached the model as the tool's error text, + `PreToolUse:mcp__eureka-state__admit hook error: `. + The prefix says "hook error", so the reason's first words must make clear the + hold is not a failure. Hook input keys: cwd, effort, hook_event_name, + mcp_server, permission_mode, prompt_id, scratchpad_dir, session_id, + tool_input, tool_name, tool_use_id, transcript_path (no agent_id at root). + (b) Subagent: SubagentStart fired with agent_id and agent_type, and its + `additionalContext` reached the subagent (it quoted the injected word). The + subagent's admit fired the same project-settings PreToolUse hook with + agent_id and agent_type, and the deny reached it. + (c) Agent `updatedInput`: a PreToolUse hook on `Agent` returning + `permissionDecision: allow` with `updatedInput` (prompt replaced) changed + what the subagent received: it answered the injected prompt, not the + original. GitHub #39814 does not hold on 2.1.308. Caveat: `allow` also + auto-approves the spawn, so a dispatch hook must decide whether it may + return allow (see cut 2). ### Model page: the held-once record @@ -470,10 +491,12 @@ cut rests on were re-checked below rather than taken from it. 1. recall-admit (Eureka): `cut-recall-admit.r1`. 2. recall-dispatch (Eureka, or brief text): the in-force records nearest a - subagent brief reach the subagent. First a live probe on the harness - version (R-B9): Agent `updatedInput` vs a SubagentStart hook (no prompt in - its input, so it needs the brief from elsewhere) vs Self putting the recall - into the brief it writes. Needs `claude -p` authenticated (R-B2). + subagent brief reach the subagent. R-B10(c): a PreToolUse `Agent` hook's + `updatedInput` reaches the subagent's prompt on 2.1.308, and SubagentStart + context reaches it too (R-B10(b)). Open for the map: whether returning + `allow` (required for `updatedInput`) is acceptable when it bypasses the + spawn's permission prompt, or whether `ask`, or a SubagentStart pairing, + is used instead. 3. recall-session-start (Eureka): a SessionStart hook recalls against the area's handoff and open campaigns. 4. The prompt hook (`recall.py prompt`, landed, uninstalled) is installed last, From 075d37d74886c1c3562974fb9fa3d7b3cf08b9a3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 19:43:51 +0100 Subject: [PATCH 006/333] eureka-substrate map: semantic retrieval quality (body facts S-B1..S-B8, one-cut rationale, cut sequence) Co-Authored-By: Claude Opus 5.5 --- docs/eureka-substrate-map.md | 217 ++++++++++++++++++++++++++++++++++- 1 file changed, 216 insertions(+), 1 deletion(-) diff --git a/docs/eureka-substrate-map.md b/docs/eureka-substrate-map.md index 7a80ab1..e9c61c4 100644 --- a/docs/eureka-substrate-map.md +++ b/docs/eureka-substrate-map.md @@ -502,4 +502,219 @@ cut rests on were re-checked below rather than taken from it. 4. The prompt hook (`recall.py prompt`, landed, uninstalled) is installed last, if at all. The retrieval-quality gaps (`gap-semantic-page-no-score`, `gap-semantic-one-text-per-query`, `gap-persona-state-not-indexed`) come - before cuts 2-4. + before cuts 2-4. They are mapped in "Semantic retrieval quality" below. + +## Semantic retrieval quality (Imagination, 2026-10-25) + +Serves `eureka-substrate:ruling:recall-triggers-rescoped`, which puts the +retrieval-quality gaps before any further recall trigger. Gaps: +`gap-semantic-topk-before-filter` and `gap-semantic-page-no-score` are one +cut, `cut-semantic-members-scores`. `gap-semantic-one-text-per-query` is next +in the sequence. Huginn is at `0b8ae4f` (origin/main, checkout clean on main). + +### Body facts (probes and source reads, 2026-10-25) + +- S-B1 (source read, `0b8ae4f`). The search path. `Daemon::begin_search` + (`crates/huginn-daemon/src/daemon.rs:176-185`) runs `Mind::check_semantic`, + then calls `IndexSink::search(text, top_k)`. The worker's `Projector::search` + (`crates/huginn-daemon/src/index/mod.rs:576-606`) embeds the text and asks + `VectorIndex::search` for `min(top_k * 4, OVERSAMPLE_MAX = 200)` points with no + filter (`:595`). `QdrantIndex::search` (`index/qdrant.rs:141-157`) posts + `points/search` with `vector`, `limit` and `with_payload: [doc_id, kind]`, and + no `filter` or `params`. `Daemon::finish` (`daemon.rs:206-221`) hands the hits to + `Mind::rank` (`crates/huginn-mind/src/query.rs:536-594`). `rank` drops the + hits the mind does not hold, cuts to the best 200 by score, narrows the + selection's `keys` to the candidates, evaluates the selection at head, sorts + by score and cuts to `min(limit, top_k)`. The selection's filters (kind, + root, `in_force`, hops) therefore apply only to the oversampled window: the + window is cut before membership is decided. The scores live in a local map + (`:558-575`), and `Self::page` (`:597-620`) builds `PipelineSelectionPage` + (`:274-284`: `matched, as_of, next, items, edges`) without them. +- S-B2 (live probe through the eureka-state MCP tool, as_of 3919). Root + `eureka-substrate` has 39 in-force rulings. A semantic query over them + returned 3 rows at top_k 15 and 0 rows at top_k 4. Over its 77 in-force + questions and follow-ups, top_k 15 returned 4. Over the 1,686 in-force + rulings, questions and follow-ups of every root, top_k 10 returned 10. The 4x + over-fetch is enough only when members make up more than about a quarter of + the nearest points. +- S-B3 (live probe, `tools/mind.py` from Starfire). A semantic query takes 2.5 + to 2.7 s end to end, whatever the selection (narrow, mid, no filter) and + top_k (4 to 200). `whoami` takes 0.14 s. Embedding dominates the time. +- S-B4 (live probe, Yggdrasil `127.0.0.1:6333`). Qdrant 1.17.1, collection + `huginn_mind_eureka`: 11,210 points (the mind holds 11,218 documents), 1,024 + dimensions, Cosine, `on_disk` vectors. `indexed_vectors_count` is 0 over 8 + segments, and `hnsw_config.full_scan_threshold` and `indexing_threshold` are + both 10000 (KB). No segment has reached the indexing threshold, so no HNSW + graph exists and every search is a brute-force scan. An unfiltered limit-200 + search returned the same ids in the same order with and without + `params.exact`. The payload schema is empty, so there is no payload index. +- S-B5 (live probe, same collection, median of 5 calls against one stored + vector): + + | Search | ms | request bytes | response bytes | + |---|---|---|---| + | unfiltered, limit 200 | 3.3-3.6 | 14k | 41k | + | unfiltered, limit 11,210 (every point) | 42-45 | 14k | 2.07M | + | `has_id` 200 ids, limit 50 | 1.3 | 22k | 10k | + | `has_id` 3,000 ids, limit 50 | 3.7 | 134k | 10k | + | `has_id` 11,210 ids, limit 50 (exact: 9.5) | 9.0-10.5 | 462k | 10k | + + Each filtered top 50 was compared with a brute-force full ranking restricted + to the same ids. The score lists were equal in every case, and the id sets + were equal except at tied scores (`0.58452` twice, `0.67256` twice), where + identical texts traded places at the window's edge. A `has_id` filter is + therefore exact up to ties. +- S-B6 (source read, CultLib `8fc74c7`, the `cultnet-rs` that Huginn pins). + `select` (`packages/cultnet-rs/src/selection.rs:1742`) evaluates schemas, + keys, fields and both hops over the full row set. It returns at most + `LIMIT_MAX` = 200 rows per call, paged by cursor (`:1814-1830`). `matches` + (`:1630`) is the single-row fast path and asserts that the selection has no + hop. No entry point returns every match unpaged. `Reader::at` + (`query.rs:364-375`) reads the mind as of any receipt, so a cursor walk at + one `as_of` is exact. +- S-B7 (source read). Consumers of the page shape: + - `huginn-mind` `wire.rs`: `HuginnMindResponse::Query` with + `derive(... Eq ...)` at `:122`, and a round-trip fixture at `:273-279`. + - The published `schemas/cultnet/huginn.mind_response.v1.schema.json`. Its + `PipelineSelectionPage` is at `:3665-3703`, pinned byte for byte by + `published_wire_schemas_match_derivation` (`wire.rs:332`). + - The eureka-state MCP server (`crates/eureka-state/src/main.rs:283-293`), + which passes the page through as JSON. + - The `huginn` CLI (`src/bin/huginn/main.rs:228-269`), which prints headers + and `matched`, and its tests' page literals (`tests/cli.rs:167-168`, + `:521-527`). + - `mind-body` `queue.rs:66`, which makes plain queries only. + - In the Eureka repo (`6d7cf60`), `tools/recall.py`. It reads only + `answer["items"]["Documents"]` (`:148-150`, `:174-176`) and ignores + other keys. + Both doors ignore unknown keys (gamecult-ops `runbooks/huginn-yggdrasil.md`, + Rollback), and serde reads a missing `Option` field as `None`. +- S-B8 (live probe, ssh Yggdrasil; gamecult-ops `b9e0109`). The live release is + `68c6dd7`, six first-parent merges behind `0b8ae4f`, among them `35dda44` + (mind-self-run) and `f8eaf50` (campaign-repos). Huginn on Yggdrasil is an + interim hand-installed systemd unit, not an Idunn target: there is no + recipe, binding or brake. The deploy owner is the runbook's Upgrade + procedure, steps 1-9, daemon first and then client. Its Verify section has no + semantic-search check. No `huginn.exe` is installed on Starfire. The live + client is `C:\Users\Meta\.eureka\bin\eureka-state.exe`. + +### Model page + +No persistent kind changes. The page is a wire answer and is never stored, so +`stored-documents-valid` is untouched. The Qdrant collection keeps its points, +payload and metadata. Only the search request changes. + +### Rationale + +- **One cut, not two.** Both gaps belong to the semantic page, and one function + decides both: `Mind::rank` and the page it builds. The membership fix changes + what `rank` receives and what `matched` means. The score is one more column + of the rows `rank` already orders by score. Two cuts would edit the same + function, page struct, derive line and schema file twice in a row. + `gap-semantic-one-text-per-query` is a different decision (how a query + becomes vectors, and fusion). It edits `SemanticQuery`, `Projector::search` + and the `IndexSink` signature, all of which this cut also changes, so it is + the next cut rather than a sibling. +- **The mind owns membership and the index only scores.** Before anything is + embedded, the mind computes the selection's whole match set (`members`) at + one snapshot. The index scores exactly those points and returns the nearest + `top_k`. `rank` reads the same snapshot, so the page is exact as of one + `as_of`, as a cursor walk is. This is pre-filtering. The established systems + have converged on it because post-filtering fails the way S-B2 shows. The + sources below are from their documentation as known in June 2026 and were + not re-fetched for this map. Confidence is high on the mechanisms and medium + on the exact defaults. + - pgvector: with an HNSW or IVFFlat index, `WHERE` filters after the index + scan, so a filtered query returns fewer than `LIMIT` rows. 0.8.0 (2024) + added iterative index scans (`hnsw.iterative_scan` = `strict_order` or + `relaxed_order`, bounded by `hnsw.max_scan_tuples`, default 20,000), which + keep scanning until enough rows pass. Without an index, a scan with `WHERE` + is exact. + - Qdrant: filtering happens during search, not after it. The query planner + estimates a filter's cardinality from payload indexes. Below + `full_scan_threshold` it scores the filtered points exactly. Above it, it + uses filterable HNSW, which adds extra edges per indexed payload value. + `has_id` filters on the primary id, and `params.exact` forces a full scan. + - Weaviate: the filter builds an allow-list before the vector search. Below + `flatSearchCutoff` (default 40,000) it brute-forces the allow-list. Since + 1.27 it can use ACORN on HNSW for restrictive filters. + - Milvus: a scalar filter becomes a bitset that the ANN search consults + while it walks the index. + - Lucene 9.1+ (and Elasticsearch and OpenSearch kNN `filter`): the filter is + evaluated first. If it matches fewer documents than the graph search would + visit, the search is exact over the matches. Otherwise HNSW is explored + accepting only filtered documents, so k results come back whenever k + exist. + + The shared lesson: build the allow-list first, and brute-force it when it is + small. Here the whole corpus is 11k points, already scanned by brute force + (S-B4) and below every system's cutoff. The simplest exact mechanism is + therefore the right one: send the member ids as a `has_id` filter with + `params.exact: true` and `limit = top_k`. At today's size this costs at most + about 10 ms (S-B5), against a 2.5 s query (S-B3). +- **Rejected alternatives.** + - Iterative over-fetch (the pgvector 0.8 shape). It is still approximate + under its scan bound, and it needs a loop that crosses between the worker + and the mind several times per query. It also leaves the index deciding + which candidates the mind ever sees. + - Scoring every point (limit = collection size). This is exact, but it + sends 2 MB per query (S-B5), and `rank` would still have to page past 200 + candidates. + - Qdrant payload filters on `kind` and `root`. `in_force` and the hops cannot + be expressed there, because the payload carries no status by design + (`index/mod.rs:57-61`). Payload filters would be a second, partial judge + of membership. + - The `matches` fast path for hop-free selections (S-B6), rejected for now. + It would be a second membership path beside `select`. Keep one walk. +- **Membership comes from a cursor walk.** It is enumerated by walking `select` + at one `as_of`, because the substrate pages at 200 (S-B6). That costs one + `select` per 200 members, at most 57 at today's size. If the live timing in + the spec's verification shows the walk matters, the fix is an unpaged + evaluation entry in CultLib's `cultnet-rs`, not a second judge here. +- **`matched` counts members.** On a semantic page `matched` counts the + selection's matches at `as_of`, as it does on a plain page, so a caller sees + "39 matched, 15 shown". Before this cut it counted the candidates that + passed. +- **The score is a parallel field.** It is `scores: Option>` on + `PipelineSelectionPage`: one score per item, in order, present exactly when + the answer is semantic. + - This follows the page's `edges` convention ("present exactly when the + selection follows a hop"). It also keeps the score off + `PipelineDocumentView`, which `view` returns too. + - The value is Qdrant's cosine similarity as returned: higher is nearer, at + most 1. + - The field is additive and never stored. An old client ignores it, and a + new client reads `None` from an old daemon. Rolling back by flipping + `current` stays safe (runbook Rollback, the additive-field class). + - `f32` is not `Eq`, so `Eq` leaves the page and response derives. +- **Known limit: ties at the window's edge.** Members with tied scores (from + identical texts, S-B5) are cut in the index's order, not by the mind's + ordinal tie-break, because the mind sees only the window. Accepted, because + ties need identical text. +- **Known limit: exact search is linear.** `params.exact` makes every search + linear in the number of members: about 10 ms at 11k points. Revisit when the + collection nears 100k points or a filtered search passes about 50 ms. Then + Qdrant's own planner is the next mechanism. +- **`recall.py`'s compensation becomes correct.** Once this cut is deployed, + `+len(excluded)` compensates the right set. The ids it excludes (the answered + question, superseded subjects) are in-force members, so asking for + `k + len(excluded)` members leaves at least `k` after exclusion whenever + that many exist. Before the cut it compensated for exclusions while the + shortfall came from non-members. + +### Cut sequence + +1. semantic-members-scores (Huginn): `cut-semantic-members-scores.r2`. +2. ops-semantic-verify (gamecult-ops, lands with the deploy of 1): the + runbook's Verify gains a semantic check, that a narrow selection returns + `min(top_k, matched)` rows, each with a score. It waits on 1's verdict, + because it states 1's behaviour. +3. semantic-many-texts (Huginn): `SemanticQuery` takes several texts, embeds + them in one batch call and fuses them by reciprocal rank inside the organ + (`gap-semantic-one-text-per-query`). It waits on 1, because it edits the + same `SemanticQuery`, `Projector::search` and `IndexSink::search`. +4. recall-score-threshold (Eureka): `recall.py` drops rows below a score floor + instead of keeping a fixed top_k, and the `huginn` CLI prints each row's + score. It waits on 1 being deployed. +5. `gap-persona-state-not-indexed` stays with the persona-state upgrade + (`huginn-upgrade-daemon-first`). From d7b1b1d1163b9af27c6423605e4b3d261c1cebad Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 19:51:29 +0100 Subject: [PATCH 007/333] A semantic query returns up to top_k members of its selection, with scores The mind decides membership (Mind::members) before the index is asked; the index scores exactly the members (Qdrant has_id + exact) and the page carries one cosine score per row. Deletes OVERSAMPLE_MAX, the x4 oversample, the unfiltered VectorIndex::search contract and check_semantic. Not yet compiled when committed: the first Yggdrasil run follows. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/src/main.rs | 2 +- crates/eureka-state/tests/cli.rs | 3 +- crates/eureka-state/tests/common/mod.rs | 2 +- crates/huginn-daemon/src/daemon.rs | 48 ++- crates/huginn-daemon/src/index/fakes.rs | 10 +- crates/huginn-daemon/src/index/mod.rs | 39 ++- crates/huginn-daemon/src/index/qdrant.rs | 11 +- crates/huginn-daemon/src/index/tests.rs | 84 +++-- crates/huginn-daemon/src/semantic_tests.rs | 74 +++- .../huginn-daemon/tests/live_index_smoke.rs | 3 +- crates/huginn-mind/src/lib.rs | 2 +- crates/huginn-mind/src/query.rs | 325 +++++++++++++----- crates/huginn-mind/src/wire.rs | 30 +- .../huginn.mind_response.v1.schema.json | 12 +- 14 files changed, 478 insertions(+), 167 deletions(-) diff --git a/crates/eureka-state/src/main.rs b/crates/eureka-state/src/main.rs index dcf65c6..7f9c7b7 100644 --- a/crates/eureka-state/src/main.rs +++ b/crates/eureka-state/src/main.rs @@ -281,7 +281,7 @@ impl EurekaState { } #[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." + 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. A semantic answer returns at most top_k members of the selection, nearest first, with scores (one cosine similarity per row, in order)." )] async fn query(&self, Parameters(input): Parameters) -> CallToolResult { self.answer(move |instance| HuginnMindRequest::Query { diff --git a/crates/eureka-state/tests/cli.rs b/crates/eureka-state/tests/cli.rs index 6dc606e..f2feb80 100644 --- a/crates/eureka-state/tests/cli.rs +++ b/crates/eureka-state/tests/cli.rs @@ -165,7 +165,7 @@ fn ruling(quote: Option<&str>) -> PipelineDocument { } fn empty_page() -> PipelineSelectionPage { - PipelineSelectionPage { matched: 0, as_of: 1, next: None, items: PipelinePageItems::Headers(vec![]), edges: None } + PipelineSelectionPage { matched: 0, as_of: 1, next: None, items: PipelinePageItems::Headers(vec![]), edges: None, scores: None } } fn answer(operation: &str, response: &HuginnMindResponse) -> cultnet_rs::CultNetMessage { @@ -524,6 +524,7 @@ fn committing(stored: Vec) -> Server { next: None, items: PipelinePageItems::Documents(stored.clone()), edges: None, + scores: None, }; answer("query", &HuginnMindResponse::Query(page)) } diff --git a/crates/eureka-state/tests/common/mod.rs b/crates/eureka-state/tests/common/mod.rs index cb09085..b2e2be8 100644 --- a/crates/eureka-state/tests/common/mod.rs +++ b/crates/eureka-state/tests/common/mod.rs @@ -89,7 +89,7 @@ impl IndexSink for Inert { fn status(&self) -> IndexStatus { IndexStatus::Current } - fn search(&mut self, _text: &str, _top_k: u32) -> Result { + fn search(&mut self, _text: &str, _top_k: u32, _among: Vec) -> Result { Err("this index cannot search".into()) } fn search_deadline(&self) -> Duration { diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index afe0185..d3a1861 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -6,7 +6,7 @@ use chrono::{DateTime, Utc}; use cultnet_rs::Selection; use huginn_mind::eureka_pipeline::{PipelineRef, Slug}; use huginn_mind::wire::{HuginnMindRequest, HuginnMindResponse, IndexStatus}; -use huginn_mind::{Mind, MindRefusal, MindStore, PipelineAdmissionOutcome, SemanticQuery}; +use huginn_mind::{Members, 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 @@ -22,9 +22,11 @@ use huginn_mind::{Mind, MindRefusal, MindStore, PipelineAdmissionOutcome, Semant 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; + /// Starts a search for the documents nearest `text`: the index scores only + /// `among`, the member document ids the mind chose, and answers the + /// nearest first, at most `top_k`. `Err` says why the index cannot be + /// asked now. + fn search(&mut self, text: &str, top_k: u32, among: Vec) -> 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; @@ -53,6 +55,7 @@ pub struct Search { pub ticket: SearchTicket, selection: Selection, semantic: SemanticQuery, + members: Members, } /// What a request came to: an answer, or a search the index has yet to @@ -171,15 +174,26 @@ impl> Daemon { 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. + /// A semantic query starts only if the mind accepts it, and the mind + /// decides its members before the index is asked anything. A selection + /// with no members is answered at once: nothing is embedded and the index + /// is not called. Otherwise the index scores those members and the answer + /// waits in a `Search`. 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); + let members = match self.mind.members(&selection, &semantic) { + Ok(members) => members, + Err(refusal) => return refused(refusal), + }; + if members.ids.is_empty() { + return match self.mind.rank(&selection, &semantic, &members, &[]) { + Ok(ranked) => Handled::Answered(HuginnMindResponse::Query(ranked.page)), + Err(refusal) => refused(refusal), + }; } - match self.index.search(&semantic.text.0, semantic.top_k) { - Ok(ticket) => Handled::Searching(Search { ticket, selection, semantic }), + let among = members.ids.iter().map(|id| id.id.0.clone()).collect(); + match self.index.search(&semantic.text.0, semantic.top_k, among) { + Ok(ticket) => Handled::Searching(Search { ticket, selection, semantic, members }), Err(detail) => refused(MindRefusal::Unavailable { detail }), } } @@ -199,16 +213,16 @@ impl> Daemon { 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 + /// The answer to a search: the index's scores for the members ranked + /// through the mind (`Mind::rank` answers as of the snapshot the members + /// were taken at and drops what is not a member), 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) { + match self.mind.rank(&search.selection, &search.semantic, &search.members, &hits) { Ok(ranked) => { if let Some(note) = dropped_note(&ranked.dropped) { eprintln!("{note}"); @@ -479,7 +493,7 @@ pub(crate) mod tests { IndexStatus::Current } - fn search(&mut self, _text: &str, _top_k: u32) -> Result { + fn search(&mut self, _text: &str, _top_k: u32, _among: Vec) -> Result { Err("this index cannot search".into()) } @@ -521,7 +535,7 @@ pub(crate) mod tests { IndexStatus::Current } - fn search(&mut self, _text: &str, _top_k: u32) -> Result { + fn search(&mut self, _text: &str, _top_k: u32, _among: Vec) -> Result { Err("this index cannot search".into()) } @@ -547,7 +561,7 @@ pub(crate) mod tests { IndexStatus::Current } - fn search(&mut self, _text: &str, _top_k: u32) -> Result { + fn search(&mut self, _text: &str, _top_k: u32, _among: Vec) -> Result { Err("this index cannot search".into()) } diff --git a/crates/huginn-daemon/src/index/fakes.rs b/crates/huginn-daemon/src/index/fakes.rs index 20bcc8c..dd6fc09 100644 --- a/crates/huginn-daemon/src/index/fakes.rs +++ b/crates/huginn-daemon/src/index/fakes.rs @@ -8,7 +8,7 @@ use std::time::Duration; use anyhow::{Result, bail, ensure}; -use super::{CollectionMeta, Described, Embedder, Hit, ModelIdentity, Point, VectorIndex}; +use super::{CollectionMeta, Described, Embedder, Hit, ModelIdentity, Point, VectorIndex, point_id}; pub(crate) fn identity(digest: &str) -> ModelIdentity { ModelIdentity { name: "model".into(), digest: digest.into(), dimensions: 4 } @@ -147,6 +147,8 @@ pub(crate) struct IndexState { pub(crate) hits: Vec, /// The collection, vector length and limit of every search, in order. pub(crate) searches: Vec<(String, usize, u32)>, + /// The point ids each search was restricted to, in order. + pub(crate) among: Vec>, /// 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. @@ -218,18 +220,20 @@ impl VectorIndex for FakeIndex { Ok(()) } - fn search(&mut self, collection: &str, vector: &[f32], limit: u32, within: Duration) -> Result> { + fn search(&mut self, collection: &str, vector: &[f32], among: &[String], 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)); + state.among.push(among.to_vec()); 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 filtered exact index: only the points handed in are scored. + Ok(state.hits.iter().filter(|hit| among.contains(&point_id(&hit.doc_id))).take(limit as usize).cloned().collect()) } } diff --git a/crates/huginn-daemon/src/index/mod.rs b/crates/huginn-daemon/src/index/mod.rs index d52f5b6..7f12f57 100644 --- a/crates/huginn-daemon/src/index/mod.rs +++ b/crates/huginn-daemon/src/index/mod.rs @@ -129,9 +129,6 @@ pub const BULK_EMBED_TIMEOUT: Duration = Duration::from_secs(300); /// 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. @@ -208,10 +205,11 @@ pub trait VectorIndex { /// 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>; + /// Scores exactly the points in `among` (exact, not approximate) against + /// `vector` and returns the `limit` nearest, best first. Which documents + /// are members is the mind's: the index only scores the points it is + /// handed. An error once `within` has passed. + fn search(&mut self, collection: &str, vector: &[f32], among: &[String], limit: u32, within: Duration) -> Result>; } /// The mind's collection: one per instance. @@ -556,11 +554,12 @@ impl Projector { 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. + /// The candidates a query text finds: the text is embedded with the model + /// query instruction and the collection scores exactly the `among` member + /// documents (ids the mind chose) and returns the `top_k` nearest. There + /// is no oversample: the members are scored, so the nearest `top_k` of them + /// are the answer. 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. @@ -573,7 +572,7 @@ impl Projector { /// 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 { + pub fn search(&mut self, text: &str, top_k: u32, among: &[String], within: Duration) -> Result { if let Some(why) = unsearchable(&self.progress_status()) { bail!("{why}"); } @@ -592,8 +591,8 @@ impl Projector { 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 points: Vec = among.iter().map(|id| point_id(id)).collect(); + let found = self.index.search(&self.collection, &vector, &points, top_k, budget.left()?)?; let mut hits = Vec::with_capacity(found.len()); for hit in found { match PipelineKind::ALL.iter().find(|kind| kind.name() == hit.kind) { @@ -665,6 +664,8 @@ struct Queued { ticket: SearchTicket, text: String, top_k: u32, + /// The member document ids the index is to score. + among: Vec, due: Instant, } @@ -780,9 +781,9 @@ fn serve_search(projector: &mut Projector, sh } #[cfg(test)] tests::after_absorb(&mut guard); - let Some(Queued { ticket, text, top_k, due }) = take_search(projector, &mut guard) else { return }; + let Some(Queued { ticket, text, top_k, among, 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:#}")); + let found = projector.search(&text, top_k, &among, due.saturating_duration_since(Instant::now())).map_err(|error| format!("{error:#}")); lock(shared).found.push((ticket, found)); } @@ -1010,7 +1011,7 @@ impl IndexSink for WorkerSink { /// 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 { + fn search(&mut self, text: &str, top_k: u32, among: Vec) -> 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) { @@ -1024,7 +1025,7 @@ impl IndexSink for WorkerSink { } 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 }); + guard.searches.push_back(Queued { ticket, text: text.to_owned(), top_k, among, due: Instant::now() + self.deadline }); drop(guard); self.shared.1.notify_all(); Ok(ticket) diff --git a/crates/huginn-daemon/src/index/qdrant.rs b/crates/huginn-daemon/src/index/qdrant.rs index f4a1c85..b0f637e 100644 --- a/crates/huginn-daemon/src/index/qdrant.rs +++ b/crates/huginn-daemon/src/index/qdrant.rs @@ -138,9 +138,16 @@ impl VectorIndex for QdrantIndex { Ok(()) } - fn search(&mut self, collection: &str, vector: &[f32], limit: u32, within: Duration) -> Result> { + fn search(&mut self, collection: &str, vector: &[f32], among: &[String], 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 body = json!({ + "vector": vector, + "filter": { "must": [{ "has_id": among }] }, + "params": { "exact": true }, + "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")?; diff --git a/crates/huginn-daemon/src/index/tests.rs b/crates/huginn-daemon/src/index/tests.rs index 2e53a16..081bb25 100644 --- a/crates/huginn-daemon/src/index/tests.rs +++ b/crates/huginn-daemon/src/index/tests.rs @@ -361,7 +361,7 @@ fn every_call_on_the_search_path_is_bounded_by_the_searchs_time_and_the_flush_by index.state.lock().unwrap().bounds.clear(); let within = Duration::from_secs(20); - projector.search("anything", 3, within).unwrap(); + 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"); @@ -398,7 +398,7 @@ fn a_hung_embedder_holds_the_worker_no_longer_than_the_search_that_asked() { 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 }); + lock(&sink.shared).searches.push_back(Queued { ticket: SearchTicket(70), text: "hung".into(), top_k: 3, among: vec![], due }); sink.shared.1.notify_all(); let answered = answers(&mut sink, 1); assert_eq!(answered[0].0, SearchTicket(70)); @@ -411,6 +411,7 @@ fn a_hung_embedder_holds_the_worker_no_longer_than_the_search_that_asked() { ticket: SearchTicket(71), text: "late".into(), top_k: 3, + among: vec![], due: Instant::now().checked_sub(Duration::from_secs(1)).unwrap_or_else(Instant::now), }); sink.shared.1.notify_all(); @@ -1024,10 +1025,11 @@ fn an_idle_worker_finds_a_lost_collection_and_a_changed_model_without_a_write() } /// 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. +/// for exactly `top_k` among the members it was handed (no oversample), 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() { +fn a_search_embeds_the_query_with_its_instruction_and_asks_for_top_k_among_the_members() { let (_root, mind) = mind_with_documents(); let entries = mind.index_entries(None).unwrap(); let (embedder, index) = pair(); @@ -1037,19 +1039,23 @@ fn a_search_embeds_the_query_with_its_instruction_and_oversamples() { 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(); + let mut among: Vec = entries.iter().map(|entry| entry.id.id.0.clone()).collect(); + among.push("eureka-state:mystery:X".into()); + let hits = projector.search("who owns the state", 3, &among, 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), - ]); + projector.search("again", 100, &among, SEARCH_DEADLINE).unwrap(); + let state = index.state.lock().unwrap(); + assert_eq!(state.searches.iter().map(|(name, length, limit)| (name.as_str(), *length, *limit)).collect::>(), [ + (collection().as_str(), 4, 3), + (collection().as_str(), 4, 100), + ], "top_k as asked, with no oversample"); + let points: Vec = among.iter().map(|id| point_id(id)).collect(); + assert_eq!(state.among, [points.clone(), points], "the index was handed the members' point ids"); } /// A search is never run against a collection that is not known to fit the @@ -1061,31 +1067,31 @@ fn a_search_refuses_when_the_index_is_not_reconciled_with_the_model() { 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!(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!(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 +/// The search scores exactly the points it is handed (a has_id filter, exact +/// search; which documents are members 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() { +fn qdrant_search_scores_only_the_members_exactly_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(); + let hits = QdrantIndex::new(&answering.url).search(&name, &[0.5, 1.0, 2.0, 3.0], &["p1".to_owned(), "p2".to_owned()], 12, TIME).unwrap(); assert_eq!( hits, [ @@ -1095,20 +1101,26 @@ fn qdrant_search_sends_no_filter_and_reads_ids_kinds_and_scores() { ); 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"] { + for expected in [ + "\"vector\":[0.5,1.0,2.0,3.0]", + "\"has_id\":[\"p1\",\"p2\"]", + "\"exact\":true", + "\"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); + assert!(!seen[0].2.contains("\"kind\":{"), "membership is ids alone, no predicate on a payload field: {}", 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()); + 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()); + 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 } + Queued { ticket: SearchTicket(ticket), text: text.into(), top_k: 3, among: vec![], due: Instant::now() + SEARCH_DEADLINE } } fn shared_with( @@ -1199,7 +1211,7 @@ 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) + >::search(sink, text, top_k, vec![]) } fn collected(sink: &mut WorkerSink) -> Answers { @@ -1278,7 +1290,7 @@ fn a_search_is_refused_only_for_a_rebuild_or_a_failure() { 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()); + 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()); @@ -1287,7 +1299,7 @@ fn a_search_is_refused_only_for_a_rebuild_or_a_failure() { 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()); + assert!(lagging.search("anything", 3, &[], SEARCH_DEADLINE).is_ok()); } /// A search that fails for its own reasons (the vector store errs, the @@ -1309,11 +1321,11 @@ fn a_failed_search_fails_that_search_alone() { 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!(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!(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"); } @@ -1344,7 +1356,7 @@ fn a_model_changed_refusal_names_the_state_and_the_pending_count() { 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()); + 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}"); } @@ -1374,7 +1386,7 @@ fn a_search_re_reads_the_collections_label() { 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()); + 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}"); @@ -1383,7 +1395,7 @@ fn a_search_re_reads_the_collections_label() { 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.search("anything", 3, &[], SEARCH_DEADLINE).is_err(), "no collection"); assert!(projector.stale()); } @@ -1400,7 +1412,7 @@ fn a_model_that_changes_while_the_query_is_embedded_is_refused() { let mut projector = projected(&embedder, &index, &entries); gate.shut_again(); let searching = std::thread::spawn(move || { - let found = projector.search("anything", 3, SEARCH_DEADLINE); + let found = projector.search("anything", 3, &[], SEARCH_DEADLINE); (projector, found) }); gate.wait_arrived(1); @@ -1767,7 +1779,7 @@ fn a_search_reads_an_admission_acknowledged_before_it_was_asked() { #[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()); + 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); @@ -1787,7 +1799,7 @@ fn a_collection_made_again_refuses_searches_until_it_is_refilled() { 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!(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"); @@ -1805,7 +1817,7 @@ fn a_collection_made_again_refuses_searches_until_it_is_refilled() { 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()); + assert!(projector.search("anything", 3, &[], SEARCH_DEADLINE).is_ok()); } /// Through the worker: a collection lost while the worker runs is made again, @@ -1922,7 +1934,7 @@ fn a_later_calls_bound_shrinks_as_the_search_spends_its_time() { state.identity_bounds.clear(); } index.state.lock().unwrap().bounds.clear(); - projector.search("anything", 3, within).unwrap(); + projector.search("anything", 3, &[], within).unwrap(); let embedder = embedder.state.lock().unwrap(); let index = index.state.lock().unwrap(); diff --git a/crates/huginn-daemon/src/semantic_tests.rs b/crates/huginn-daemon/src/semantic_tests.rs index 11da3f5..b72f897 100644 --- a/crates/huginn-daemon/src/semantic_tests.rs +++ b/crates/huginn-daemon/src/semantic_tests.rs @@ -268,7 +268,8 @@ fn healthy() -> Harness { /// 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. +/// index was asked for exactly top_k among the members, and `matched` counts +/// the members. #[test] fn a_semantic_query_returns_only_documents_the_mind_holds_and_the_selection_admits() { let harness = healthy(); @@ -286,7 +287,9 @@ fn a_semantic_query_returns_only_documents_the_mind_holds_and_the_selection_admi 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 members = ids(&ask(&mut client, "m-0", &plain_of(standing()))).len(); + assert!(members > 2, "the mind has more in force than the two hits: {members}"); + assert_eq!((page.matched as usize, page.next.as_ref()), (members, None), "matched counts the members, not the hits"); 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"); @@ -298,7 +301,72 @@ fn a_semantic_query_returns_only_documents_the_mind_holds_and_the_selection_admi 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:?}"); + assert!(searches.iter().all(|(name, _, limit)| *name == collection_name(&slug(INSTANCE)) && *limit == 5), "top_k, with no oversample: {searches:?}"); +} + +fn plain_of(selection: Selection) -> HuginnMindRequest { + HuginnMindRequest::Query { instance: slug(INSTANCE), selection, semantic: None } +} + +/// Thirty rulings stand out of force (superseded by K0) and five stand, and the +/// index ranks every one that does not stand above every one that does. The +/// members reach the index, so the nearest top_k of them are the answer: +/// top_k 4 answers four rows, where an index window cut before the mind judged +/// would have answered none. +#[test] +fn a_narrow_selection_gets_top_k_members_when_non_members_score_higher() { + let gone = |n: u32| id("ruling", &format!("N{n}")); + let kept = |n: u32| id("ruling", &format!("K{n}")); + let mut seed = Vec::new(); + seed.push(campaign_seed()); + seed.push((0..5).map(|n| ruling(&format!("K{n}"), None)).collect()); + seed.push((0..30).map(|n| ruling(&format!("N{n}"), None)).collect()); + seed.push( + (0..30) + .map(|n| { + resolution( + PipelineKind::Ruling, + &gone(n), + ResolutionOutcome::Superseded { by: vec![PipelineRef { kind: PipelineKind::Ruling, id: Short(kept(0)) }] }, + ) + }) + .collect(), + ); + let (embedder, index) = (FakeEmbedder::new("d1"), FakeIndex::default()); + let harness = Harness::start(seed, embedder, index, Duration::from_secs(30)); + let mut client = harness.client(); + until_index(&mut client, "Current", |status| *status == IndexStatus::Current); + let mut hits: Vec = (0..30).map(|n| hit("ruling", gone(n), 0.9 - n as f32 * 0.001)).collect(); + hits.extend((0..5).map(|n| hit("ruling", kept(n), 0.5 - n as f32 * 0.1))); + harness.index.state.lock().unwrap().hits = hits; + + let rulings = Selection { schemas: Some(vec![PipelineKind::Ruling.type_id().into()]), ..standing() }; + let answer = ask(&mut client, "m-1", &semantic(rulings, 4)); + assert_eq!(ids(&answer), (0..4).map(kept).collect::>(), "the four nearest members, none of the nearer non-members"); + let HuginnMindResponse::Query(page) = &answer else { unreachable!() }; + assert_eq!(page.matched, 5); + let scores = page.scores.clone().expect("a semantic page carries scores"); + assert_eq!(scores.len(), 4); + assert!(scores.windows(2).all(|pair| pair[0] >= pair[1]), "best first: {scores:?}"); + assert!((scores[0] - 0.5).abs() < 1e-6, "the score is the index's: {scores:?}"); +} + +/// A selection that matches nothing is an empty page at once: the query is not +/// embedded and the index is not asked. +#[test] +fn an_empty_membership_is_an_empty_page_and_the_index_is_never_asked() { + let harness = healthy(); + let mut client = harness.client(); + until_index(&mut client, "Current", |status| *status == IndexStatus::Current); + let embedded = harness.embedder.texts().len(); + harness.index.state.lock().unwrap().hits = vec![hit("question", id("question", "Q1"), 0.9)]; + + let findings = Selection { schemas: Some(vec![PipelineKind::Finding.type_id().into()]), ..standing() }; + let answer = ask(&mut client, "m-1", &semantic(findings, 5)); + let HuginnMindResponse::Query(page) = &answer else { panic!("{answer:?}") }; + assert_eq!((page.matched, ids(&answer).len(), page.scores.clone()), (0, 0, Some(vec![]))); + assert_eq!(harness.embedder.texts().len(), embedded, "nothing was embedded"); + assert!(harness.index.state.lock().unwrap().searches.is_empty(), "the index was not asked"); } /// A cursor cannot resume a ranked answer and a ranked answer has no order to diff --git a/crates/huginn-daemon/tests/live_index_smoke.rs b/crates/huginn-daemon/tests/live_index_smoke.rs index 85fabc2..1fc60c4 100644 --- a/crates/huginn-daemon/tests/live_index_smoke.rs +++ b/crates/huginn-daemon/tests/live_index_smoke.rs @@ -97,7 +97,8 @@ fn the_adapters_embed_write_query_and_clean_up_a_scratch_collection() { 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(); + let among: Vec = points.iter().map(|point| point.id.clone()).collect(); + let hits = store.search(name, &query, &among, 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:?}"); diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index aef0136..63a64bc 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -48,7 +48,7 @@ pub use mind::{HuginnMindEpoch, Mind}; pub use persona::{PersonaPutOutcome, PersonaStateView}; pub use query::{ AdmissionFacts, PipelineDocumentSummary, PipelineDocumentView, PipelineEdge, PipelineFacts, PipelinePageItems, - PipelineSelectionPage, PipelineStatus, PipelineStatusSummary, Ranked, SUMMARY_MAX_BYTES, SemanticQuery, + Members, PipelineSelectionPage, PipelineStatus, PipelineStatusSummary, Ranked, SUMMARY_MAX_BYTES, SemanticQuery, }; pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION}; pub use refusal::{MindRefusal, RunId}; diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 8debedb..bace21c 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -20,7 +20,7 @@ //! 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 std::collections::{BTreeMap, BTreeSet}; use cultnet_rs::{ Cursor, EdgeAnchor, EdgeMatch, Evaluation, LIMIT_MAX, LIMIT_MIN, PROJECTION_DOCUMENT, Row, Selection, select, validate, @@ -77,7 +77,9 @@ pub struct PipelineDocumentView { /// 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. +/// filter lives here to become a second answer to that question. `top_k` is +/// the most rows answered, all of them members of the selection: the +/// selection decides membership before the index is asked. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct SemanticQuery { pub text: Line, @@ -272,15 +274,27 @@ pub struct PipelineEdge { } /// One page of a selection. `matched` is the count before paging, `as_of` the -/// snapshot the page and every cursor of its walk are exact as of, and `edges` -/// is present exactly when the selection follows a hop. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +/// snapshot the page and every cursor of its walk are exact as of, `edges` +/// is present exactly when the selection follows a hop, and `scores` exactly +/// when the answer is semantic: one cosine similarity per item, in order +/// (higher is nearer), for display and thresholding. It is never stored. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct PipelineSelectionPage { pub matched: u32, pub as_of: u64, pub next: Option, pub items: PipelinePageItems, pub edges: Option>, + pub scores: Option>, +} + +/// The selection's whole match set at one snapshot, in the selection's order: +/// what a cursor walk of `query` over the same selection returns. Local to +/// the process, like `Ranked`; it is never on the wire. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Members { + pub as_of: u64, + pub ids: Vec, } /// A ranked semantic answer: the page, and the hits the index named that the @@ -468,7 +482,7 @@ impl Mind { }; let rows = Reader::at(self, as_of)?.rows()?; let evaluation = select(&Vocabulary, &rows, &selection, as_of, self.cursor_key())?; - Self::page(&selection, evaluation, as_of) + Self::page(&selection, evaluation, as_of, None) } /// What a semantic query must satisfy before anything is embedded: the @@ -503,54 +517,71 @@ impl Mind { refuse_values(selection) } - /// Whether a semantic query may be started, decided before the index is - /// asked anything. `rank` runs the same check again over the same inputs. - pub fn check_semantic(&self, selection: &Selection, semantic: &SemanticQuery) -> Result<(), MindRefusal> { - Self::semantic_selection(selection, semantic).map(drop) + /// The members of a semantic query: every document the selection matches + /// at the head, decided before the index is asked anything. + /// + /// The selection passes the doors `semantic_selection` names, then is + /// walked by cursor at one snapshot, header projection, until it has no + /// next page, so `hops` and `in_force` decide membership exactly as + /// `query` does. The index never decides who is a member; it scores the + /// ids this returns. + pub fn members(&self, selection: &Selection, semantic: &SemanticQuery) -> Result { + let selection = Self::semantic_selection(selection, semantic)?; + let head = receipt::head(self)?; + let rows = Reader::at(self, head)?.rows()?; + let mut walk = Selection { limit: Some(LIMIT_MAX), projection: Selection::default().projection, ..selection }; + let mut ids = Vec::new(); + loop { + let evaluation = select(&Vocabulary, &rows, &walk, head, self.cursor_key())?; + ids.extend(evaluation.rows.iter().map(ref_of)); + match evaluation.next_cursor { + Some(next) => walk.cursor = Some(next), + None => return Ok(Members { as_of: head, ids }), + } + } } - /// The page a semantic query answers, from the candidates the index gave, - /// with the hits the mind holds no document for. + /// The page a semantic query answers, from the hits the index scored + /// among the members, with the hits the mind refused. /// - /// The index supplies ids and scores and nothing else. A hit is a - /// candidate: it is dropped unless the mind holds a document of that kind - /// and id at the head, and what remains is decided by the selection, run - /// by the substrate over every row the mind has, its `keys` narrowed to - /// the candidates. Narrowing `keys` rather than the rows keeps the - /// evaluator's universe whole, so `cited` and `in_force` mean what they - /// mean in `query`. Nothing about a document's standing is read from the - /// index; `in_force` is the caller's to ask for and the mind's to derive. - /// - /// This is the one place the mind judges whether it holds a hit, in the - /// one read of its rows that ranking needs: `Ranked::dropped` returns the - /// hits it found no document for, for the caller to report. + /// The mind decides membership first (`members`); the index only scores + /// the ids it was handed, so the answer is exact as of `members.as_of`, + /// the snapshot the members were taken at, and not the head. The index + /// supplies ids and scores and nothing else. A hit is dropped, and + /// reported in `Ranked::dropped`, unless the mind holds a document of + /// that kind and id at that snapshot and the id is one of `members.ids`. + /// What remains is evaluated by the substrate over every row the mind + /// had, its `keys` narrowed to the hits. Narrowing `keys` rather than the + /// rows keeps the evaluator's universe whole, so `cited` and `in_force` + /// mean what they mean in `query`. Nothing about a document's standing is + /// read from the index. /// /// The order is the one thing the substrate cannot know: score, highest /// first, ties by ordinal. The page holds at most the selection's limit - /// and at most `top_k` documents, `matched` counts every candidate the - /// selection passed, and `next` is never set. The substrate answers at - /// most `LIMIT_MAX` rows and cuts by ordinal, so the candidates are cut - /// to the best `LIMIT_MAX` by score first, among the documents the mind - /// holds: an id the mind does not hold never takes the place of one it - /// does. + /// and at most `top_k` documents, `matched` counts the members, `scores` + /// holds one score per kept row, in row order, and `next` is never set. pub fn rank( &self, selection: &Selection, semantic: &SemanticQuery, + members: &Members, hits: &[(PipelineRef, f32)], ) -> Result { let selection = Self::semantic_selection(selection, semantic)?; - let head = receipt::head(self)?; - let rows = Reader::at(self, head)?.rows()?; + let as_of = members.as_of; + let rows = Reader::at(self, as_of)?.rows()?; let held: BTreeMap<(PipelineKind, &str), i64> = rows.iter().map(|row| ((row.kind, row.key.as_str()), row.ordinal())).collect(); + let member: BTreeSet<(PipelineKind, &str)> = + members.ids.iter().map(|id| (id.kind, id.id.0.as_str())).collect(); let mut candidates: Vec<(&str, f32, i64)> = Vec::new(); let mut dropped = Vec::new(); for (hit, score) in hits { - match held.get(&(hit.kind, hit.id.0.as_str())) { - Some(&ordinal) => candidates.push((hit.id.0.as_str(), *score, ordinal)), - None => dropped.push(hit.clone()), + let id = (hit.kind, hit.id.0.as_str()); + match held.get(&id) { + Some(&ordinal) if member.contains(&id) => candidates.push((id.1, *score, ordinal)), + _ => dropped.push(hit.clone()), } } candidates.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.2.cmp(&b.2))); @@ -569,12 +600,13 @@ impl Mind { Evaluation { rows: Vec::new(), matched: 0, edges: Vec::new(), next_cursor: None } } else { let narrowed = Selection { keys: Some(keys), limit: Some(LIMIT_MAX), ..selection.clone() }; - select(&Vocabulary, &rows, &narrowed, head, self.cursor_key())? + select(&Vocabulary, &rows, &narrowed, as_of, self.cursor_key())? }; let score = |row: &SelectionRow| scores.get(row.key.as_str()).copied().unwrap_or(f32::NEG_INFINITY); evaluation.rows.sort_by(|a, b| score(b).total_cmp(&score(a)).then(a.ordinal().cmp(&b.ordinal()))); evaluation.rows.truncate(limit); evaluation.next_cursor = None; + let row_scores: Vec = evaluation.rows.iter().map(score).collect(); let position = |kind: PipelineKind, key: &str| evaluation.rows.iter().position(|row| row.kind == kind && row.key == key); let mut kept: Vec<(usize, EdgeMatch)> = std::mem::take(&mut evaluation.edges) @@ -589,7 +621,8 @@ impl Mind { .collect(); kept.sort_by_key(|(at, _)| *at); evaluation.edges = kept.into_iter().map(|(_, edge)| edge).collect(); - Ok(Ranked { page: Self::page(&selection, evaluation, head)?, dropped }) + evaluation.matched = u32::try_from(members.ids.len()).unwrap_or(u32::MAX); + Ok(Ranked { page: Self::page(&selection, evaluation, as_of, Some(row_scores))?, dropped }) } /// The projection of an evaluation: a header or a whole view per row, and @@ -598,6 +631,7 @@ impl Mind { selection: &Selection, evaluation: Evaluation, as_of: u64, + scores: Option>, ) -> Result { let items = if selection.projection == PROJECTION_DOCUMENT { PipelinePageItems::Documents(evaluation.rows.iter().map(|row| row.view.clone()).collect()) @@ -616,7 +650,7 @@ impl Mind { } else { None }; - Ok(PipelineSelectionPage { matched: evaluation.matched, as_of, next: evaluation.next_cursor, items, edges }) + Ok(PipelineSelectionPage { matched: evaluation.matched, as_of, next: evaluation.next_cursor, items, edges, scores }) } } @@ -1594,6 +1628,13 @@ mod tests { with(selection, any_of("in_force", &["true"])) } + /// The members of the selection from the mind, then the page ranked from + /// the hits: the two steps the daemon takes around the index. + fn ranked(mind: &Mind, selection: &Selection, semantic: &SemanticQuery, hits: &[(PipelineRef, f32)]) -> Ranked { + let members = mind.members(selection, semantic).unwrap(); + ranked(&mind, selection, semantic, &members, hits) + } + /// A mind with every way a document stops being in force: Q1 answered /// (resolved), Q2 answered and the answer withdrawn (its resolution is /// resolved, the question in force again), R3 superseded by R4. @@ -1637,7 +1678,7 @@ mod tests { hit(K::Ruling, &shelf.r4, 0.5), hit(K::Question, &shelf.q2, 0.4), ]; - let ranked = shelf.mind.rank(&Selection::default(), &asking(10), &hits).unwrap(); + let ranked = ranked(&shelf.mind, &Selection::default(), &asking(10), &hits); assert_eq!( ranked.dropped, vec![r(K::Question, &id("question", "Q99")), r(K::Question, &shelf.r4)], @@ -1646,16 +1687,17 @@ mod tests { let everything = ranked.page; assert_eq!(ids(&everything), vec![shelf.q1.clone(), shelf.withdrawn_resolution.clone(), shelf.r3.clone(), shelf.r4.clone(), shelf.q2.clone()], "without in_force the selection has not asked about standing, and the unheld and mis-kinded hits are gone"); - assert_eq!(everything.matched, 5); + assert_eq!(everything.matched, plain_matched(&shelf.mind, &Selection::default()), "matched counts the whole mind, not the five hits"); - let page = shelf.mind.rank(&standing(Selection::default()), &asking(10), &hits).unwrap().page; + let page = ranked(&shelf.mind, &standing(Selection::default()), &asking(10), &hits).page; assert_eq!(ids(&page), vec![shelf.r4.clone(), shelf.q2.clone()]); - assert_eq!((page.matched, page.next), (2, None)); + assert_eq!((page.matched, page.next), (plain_matched(&shelf.mind, &standing(Selection::default())), None)); assert_eq!(page.as_of, receipt::head(&shelf.mind).unwrap()); - let none = shelf.mind.rank(&standing(Selection::default()), &asking(10), &[hit(K::Question, &id("question", "Q99"), 1.0)]).unwrap().page; - assert_eq!((none.matched, ids(&none)), (0, Vec::::new()), "an empty candidate set is an empty page, not a refusal"); - assert_eq!(shelf.mind.rank(&standing(Selection::default()), &asking(10), &[]).unwrap().page.matched, 0); + let none = ranked(&shelf.mind, &standing(Selection::default()), &asking(10), &[hit(K::Question, &id("question", "Q99"), 1.0)]).page; + assert_eq!(ids(&none), Vec::::new(), "an empty candidate set is an empty page, not a refusal"); + assert_eq!(none.matched, plain_matched(&shelf.mind, &standing(Selection::default())), "and the members are still counted"); + assert_eq!(ranked(&shelf.mind, &standing(Selection::default()), &asking(10), &[]).page.matched, none.matched); } /// The substrate answers 200 rows and cuts by ordinal, so 250 candidates @@ -1673,10 +1715,11 @@ mod tests { let mut hits: Vec<(PipelineRef, f32)> = (0..250).map(|n| hit(K::Question, &key(n), n as f32)).collect(); // Twenty ids the mind holds nothing for, above every real score. hits.extend((0..20).map(|n| hit(K::Question, &id("question", &format!("Gone{n}")), 1000.0 + n as f32))); - let ranked = mind.rank(&Selection::default(), &asking(200), &hits).unwrap(); + let ranked = ranked(&mind, &Selection::default(), &asking(200), &hits); let expected: Vec = (50..250).rev().map(key).collect(); assert_eq!(ids(&ranked.page), expected, "the 200 best, best first"); - assert_eq!(ranked.page.matched, 200); + assert_eq!(ranked.page.matched, plain_matched(&mind, &Selection::default())); + assert!(ranked.page.matched > 250, "every member, not the 200 answered"); assert_eq!(ranked.dropped.len(), 20); } @@ -1687,17 +1730,17 @@ mod tests { fn ranked_results_are_ordered_by_score_then_ordinal_and_cut_to_the_limit() { let shelf = shelf(); let by_ordinal = [hit(K::Question, &shelf.q2, 0.4), hit(K::Ruling, &shelf.r4, 0.5)]; - assert_eq!(ids(&shelf.mind.rank(&standing(Selection::default()), &asking(10), &by_ordinal).unwrap().page), vec![shelf.r4.clone(), shelf.q2.clone()], + assert_eq!(ids(&ranked(&shelf.mind, &standing(Selection::default()), &asking(10), &by_ordinal).page), vec![shelf.r4.clone(), shelf.q2.clone()], "Q2 was admitted first, and the better score still comes first"); let tied = [hit(K::Ruling, &shelf.r4, 0.5), hit(K::Question, &shelf.q2, 0.5)]; - assert_eq!(ids(&shelf.mind.rank(&standing(Selection::default()), &asking(10), &tied).unwrap().page), vec![shelf.q2.clone(), shelf.r4.clone()], + assert_eq!(ids(&ranked(&shelf.mind, &standing(Selection::default()), &asking(10), &tied).page), vec![shelf.q2.clone(), shelf.r4.clone()], "a tie falls to the earlier ordinal"); let limited = Selection { limit: Some(1), ..standing(Selection::default()) }; - let page = shelf.mind.rank(&limited, &asking(10), &by_ordinal).unwrap().page; - assert_eq!((ids(&page), page.matched, page.next), (vec![shelf.r4.clone()], 2, None), "the limit keeps the best, not the earliest"); - let page = shelf.mind.rank(&standing(Selection::default()), &asking(1), &by_ordinal).unwrap().page; - assert_eq!((ids(&page), page.matched), (vec![shelf.r4.clone()], 2), "top_k bounds the page too"); + let page = ranked(&shelf.mind, &limited, &asking(10), &by_ordinal).page; + assert_eq!((ids(&page), page.matched, page.next), (vec![shelf.r4.clone()], plain_matched(&shelf.mind, &standing(Selection::default())), None), "the limit keeps the best, not the earliest"); + let page = ranked(&shelf.mind, &standing(Selection::default()), &asking(1), &by_ordinal).page; + assert_eq!((ids(&page), page.matched), (vec![shelf.r4.clone()], plain_matched(&shelf.mind, &standing(Selection::default()))), "top_k bounds the page too"); } /// Membership is the selection's alone: a kind, a key allowlist and a @@ -1708,16 +1751,18 @@ mod tests { fn the_selections_own_predicates_decide_which_candidates_stay() { let shelf = shelf(); let hits = [hit(K::Question, &shelf.q2, 0.4), hit(K::Ruling, &shelf.r4, 0.5)]; - assert_eq!(ids(&shelf.mind.rank(&of_kinds(&[K::Ruling]), &asking(10), &hits).unwrap().page), vec![shelf.r4.clone()]); + assert_eq!(ids(&ranked(&shelf.mind, &of_kinds(&[K::Ruling]), &asking(10), &hits).page), vec![shelf.r4.clone()]); let keyed = Selection { keys: Some(vec![shelf.q2.clone(), shelf.q1.clone()]), ..Selection::default() }; - assert_eq!(ids(&shelf.mind.rank(&keyed, &asking(10), &hits).unwrap().page), vec![shelf.q2.clone()], "keys intersect the candidates"); + assert_eq!(ids(&ranked(&shelf.mind, &keyed, &asking(10), &hits).page), vec![shelf.q2.clone()], "keys intersect the candidates"); let disjoint = Selection { keys: Some(vec![shelf.q1.clone()]), ..Selection::default() }; - assert_eq!(shelf.mind.rank(&disjoint, &asking(10), &hits).unwrap().page.matched, 0); + let none = ranked(&shelf.mind, &disjoint, &asking(10), &hits); + assert_eq!((none.page.matched, ids(&none.page).len()), (1, 0), "Q1 is the one member, and neither hit is it"); + assert_eq!(none.dropped.len(), 2, "hits outside the members are dropped"); let unnamed = Selection { cited: Some(Incoming { role: "subject".into(), exists: false }), ..Selection::default() }; let plain = ids(&shelf.mind.query(&Selection { keys: Some(vec![shelf.q2.clone(), shelf.r4.clone()]), ..unnamed.clone() }).unwrap()); assert_eq!(plain, vec![shelf.r4.clone()], "no resolution names R4 as its subject, and one names Q2"); - assert_eq!(ids(&shelf.mind.rank(&unnamed, &asking(10), &hits).unwrap().page), plain, "the same answer as the plain query over the same rows"); - assert_eq!(ids(&shelf.mind.rank(&Selection { projection: "document".into(), ..of_kinds(&[K::Ruling]) }, &asking(10), &hits).unwrap().page), vec![shelf.r4.clone()]); + assert_eq!(ids(&ranked(&shelf.mind, &unnamed, &asking(10), &hits).page), plain, "the same answer as the plain query over the same rows"); + assert_eq!(ids(&ranked(&shelf.mind, &Selection { projection: "document".into(), ..of_kinds(&[K::Ruling]) }, &asking(10), &hits).page), vec![shelf.r4.clone()]); } /// A citation hop is answered over the whole mind, and its edges follow the @@ -1729,14 +1774,14 @@ mod tests { let shelf = shelf(); let named = Selection { cited: Some(Incoming { role: "subject".into(), exists: true }), ..of_kinds(&[K::Question]) }; let hits = [hit(K::Question, &shelf.q2, 0.9), hit(K::Question, &shelf.q1, 0.4)]; - let both = shelf.mind.rank(&named, &asking(10), &hits).unwrap().page; + let both = ranked(&shelf.mind, &named, &asking(10), &hits).page; assert_eq!(ids(&both), vec![shelf.q2.clone(), shelf.q1.clone()], "Q1 was admitted first and scored lower"); let edges = both.edges.expect("a hop carries its edges"); let targets: Vec<&str> = edges.iter().map(|edge| edge.to.id.0.as_str()).collect(); assert_eq!(targets, [shelf.q2.as_str(), shelf.q1.as_str()], "the edges follow the page order"); assert!(edges.iter().all(|edge| edge.from.kind == K::Resolution && edge.role == CitationRole::Subject)); - let limited = shelf.mind.rank(&Selection { limit: Some(1), ..named.clone() }, &asking(10), &hits).unwrap().page; + let limited = ranked(&shelf.mind, &Selection { limit: Some(1), ..named.clone() }, &asking(10), &hits).page; assert_eq!(ids(&limited), vec![shelf.q2.clone()]); let kept = limited.edges.unwrap(); assert_eq!(kept.len(), 1, "the dropped row's edge is dropped with it"); @@ -1744,41 +1789,161 @@ mod tests { assert_eq!(kept[0].from.id.0, id("resolution", "question.Q2.n1")); let empty_cursor = Selection { cursor: Some(String::new()), ..standing(Selection::default()) }; - let with = shelf.mind.rank(&empty_cursor, &asking(10), &[hit(K::Ruling, &shelf.r4, 0.5)]).unwrap().page; + let with = ranked(&shelf.mind, &empty_cursor, &asking(10), &[hit(K::Ruling, &shelf.r4, 0.5)]).page; assert_eq!(ids(&with), vec![shelf.r4.clone()]); } /// A ranked answer cannot be resumed, so a cursor is refused by name, and - /// so is a query that cannot be asked, before any hit is looked at. + /// so is a query that cannot be asked: `members` refuses before the index + /// is asked anything and before any hit is looked at. #[test] fn a_semantic_query_that_cannot_be_ranked_is_refused_by_name() { let shelf = shelf(); - let hits = [hit(K::Ruling, &shelf.r4, 0.5)]; let with_cursor = Selection { cursor: Some("anything".into()), ..Selection::default() }; fn on(field: &str, refusal: &MindRefusal) -> bool { matches!(refusal, MindRefusal::SelectionInvalid { field: f, .. } if f == field) } - for refusal in [ - shelf.mind.rank(&with_cursor, &asking(10), &hits).unwrap_err(), - shelf.mind.check_semantic(&with_cursor, &asking(10)).unwrap_err(), - ] { - assert!(on("cursor", &refusal), "{refusal:?}"); - } + let refusal = shelf.mind.members(&with_cursor, &asking(10)).unwrap_err(); + assert!(on("cursor", &refusal), "{refusal:?}"); let reversed = Selection { descending: true, ..Selection::default() }; - for refusal in [ - shelf.mind.rank(&reversed, &asking(10), &hits).unwrap_err(), - shelf.mind.check_semantic(&reversed, &asking(10)).unwrap_err(), - ] { - assert!(on("descending", &refusal), "a ranked answer has no order to reverse: {refusal:?}"); - } - assert!(on("semantic.top_k", &shelf.mind.rank(&Selection::default(), &asking(0), &hits).unwrap_err())); - assert!(on("semantic.top_k", &shelf.mind.rank(&Selection::default(), &asking(201), &hits).unwrap_err())); + let refusal = shelf.mind.members(&reversed, &asking(10)).unwrap_err(); + assert!(on("descending", &refusal), "a ranked answer has no order to reverse: {refusal:?}"); + assert!(on("semantic.top_k", &shelf.mind.members(&Selection::default(), &asking(0)).unwrap_err())); + assert!(on("semantic.top_k", &shelf.mind.members(&Selection::default(), &asking(201)).unwrap_err())); let blank = SemanticQuery { text: " ".into(), top_k: 3 }; - assert!(on("semantic.text", &shelf.mind.rank(&Selection::default(), &blank, &hits).unwrap_err())); + assert!(on("semantic.text", &shelf.mind.members(&Selection::default(), &blank).unwrap_err())); let malformed = with(Selection::default(), any_of("root", &["a/../b"])); - assert_eq!(shelf.mind.check_semantic(&malformed, &asking(3)), shelf.mind.query(&malformed).map(drop), "the same door as a plain query"); - assert!(shelf.mind.check_semantic(&standing(Selection::default()), &asking(3)).is_ok()); + assert_eq!( + shelf.mind.members(&malformed, &asking(3)).map(drop), + shelf.mind.query(&malformed).map(drop), + "the same door as a plain query" + ); + assert!(shelf.mind.members(&standing(Selection::default()), &asking(3)).is_ok()); + // rank runs the same doors over the same inputs. + let members = shelf.mind.members(&Selection::default(), &asking(10)).unwrap(); + let refusal = shelf.mind.rank(&with_cursor, &asking(10), &members, &[]).unwrap_err(); + assert!(on("cursor", &refusal), "{refusal:?}"); + } + + /// The matched count a plain query gives the same selection: the figure a + /// semantic answer's `matched` must equal, computed without the semantic + /// path. + fn plain_matched(mind: &Mind, selection: &Selection) -> u32 { + mind.query(selection).unwrap().matched + } + + /// Every id a cursor walk of `query` returns for the selection, in order. + fn walked(mind: &Mind, selection: &Selection) -> Vec { + let mut walk = selection.clone(); + let mut out = Vec::new(); + loop { + let page = mind.query(&walk).unwrap(); + match &page.items { + PipelinePageItems::Headers(headers) => out.extend(headers.iter().map(|header| header.id.clone())), + PipelinePageItems::Documents(views) => out.extend(views.iter().map(|view| view.id.clone())), + } + match page.next { + Some(next) => walk.cursor = Some(next), + None => return out, + } + } + } + + /// The members of a selection are everything it matches, not the first + /// page: past 200 rows, with a citation hop and `in_force`, they are the + /// cursor walk of `query` over the same selection, as of the head. + #[test] + fn members_are_the_whole_match_of_the_selection_past_one_page() { + let mut mind = seeded(); + for start in (0..250).step_by(crate::admission::BATCH_MAX) { + committed(admit(&mut mind, (start..(start + crate::admission::BATCH_MAX as u32).min(250)).map(question_n).collect())); + } + // Forty questions are answered: resolved, and named as a subject. + let answered: Vec = + (0..40).map(|n| answering(&format!("R{n}"), &id("question", &format!("Q{n}")))).collect(); + for batch in answered.chunks(crate::admission::BATCH_MAX) { + committed(admit(&mut mind, batch.to_vec())); + } + let questions = of_kinds(&[K::Question]); + let unnamed = Selection { cited: Some(Incoming { role: "subject".into(), exists: false }), ..questions.clone() }; + let selections = [ + Selection::default(), + questions.clone(), + standing(questions.clone()), + unnamed.clone(), + standing(unnamed), + ]; + for selection in selections { + let members = mind.members(&selection, &asking(5)).unwrap(); + assert_eq!(members.as_of, receipt::head(&mind).unwrap()); + assert_eq!(members.ids, walked(&mind, &selection), "{selection:?}"); + assert_eq!(members.ids.len() as u32, plain_matched(&mind, &selection)); + } + let standing_questions = mind.members(&standing(questions), &asking(5)).unwrap(); + assert_eq!(standing_questions.ids.len(), 210, "250 questions, 40 of them resolved, all past one page"); + // The selection's own limit shortens a page, never the membership. + let limited = Selection { limit: Some(3), ..of_kinds(&[K::Question]) }; + assert_eq!(mind.members(&limited, &asking(5)).unwrap().ids.len(), 250); + } + + /// `matched` is what the selection admits, not what the index returned: + /// two hits among five members still say five. + #[test] + fn matched_counts_the_members_not_the_hits() { + let mut mind = seeded(); + committed(admit(&mut mind, (0..5).map(question_n).collect())); + let key = |n: u32| id("question", &format!("Q{n}")); + let selection = of_kinds(&[K::Question]); + let ranked = ranked(&mind, &selection, &asking(10), &[hit(K::Question, &key(3), 0.9), hit(K::Question, &key(1), 0.2)]); + assert_eq!(ranked.page.matched, 5); + assert_eq!(ids(&ranked.page), vec![key(3), key(1)]); + } + + /// A semantic page says how near each row is, row for row and best first; + /// a plain page says nothing of the kind. + #[test] + fn a_semantic_page_carries_one_score_per_row_best_first_and_a_plain_page_none() { + let shelf = shelf(); + let hits = [ + hit(K::Question, &shelf.q2, 0.4), + hit(K::Ruling, &shelf.r4, 0.5), + hit(K::Question, &shelf.q1, 0.9), + hit(K::Question, &id("question", "Q99"), 0.8), + ]; + let page = ranked(&shelf.mind, &standing(Selection::default()), &asking(10), &hits).page; + assert_eq!(ids(&page), vec![shelf.r4.clone(), shelf.q2.clone()]); + assert_eq!(page.scores, Some(vec![0.5, 0.4]), "the scores of the rows kept, in row order"); + let all = ranked(&shelf.mind, &Selection::default(), &asking(2), &hits).page; + assert_eq!(ids(&all), vec![shelf.q1.clone(), shelf.r4.clone()]); + assert_eq!(all.scores, Some(vec![0.9, 0.5]), "cut to top_k, the scores are cut with the rows"); + let empty = ranked(&shelf.mind, &standing(Selection::default()), &asking(10), &[]).page; + assert_eq!(empty.scores, Some(vec![]), "a semantic answer with no rows still says it is semantic"); + assert_eq!(shelf.mind.query(&standing(Selection::default())).unwrap().scores, None); + } + + /// The answer is the snapshot the members were taken at, not the head: a + /// ruling superseded after `members` is still in force there, and a hit + /// the members do not hold is dropped and reported. + #[test] + fn rank_reads_the_snapshot_the_members_were_taken_at() { + let mut shelf = shelf(); + let selection = standing(of_kinds(&[K::Ruling])); + let members = shelf.mind.members(&selection, &asking(10)).unwrap(); + assert!(members.ids.contains(&r(K::Ruling, &shelf.r4))); + let r5 = id("ruling", "R5"); + committed(admit(&mut shelf.mind, vec![D::Ruling(ruling("R5"))])); + committed(admit(&mut shelf.mind, vec![resolution(r(K::Ruling, &shelf.r4), superseded(&[r(K::Ruling, &r5)]))])); + assert!( + !ids(&shelf.mind.query(&selection).unwrap()).contains(&shelf.r4), + "at the head R4 no longer stands" + ); + let hits = [hit(K::Ruling, &shelf.r4, 0.5), hit(K::Ruling, &r5, 0.9)]; + let ranked = shelf.mind.rank(&selection, &asking(10), &members, &hits).expect("the members rank"); + assert_eq!(ids(&ranked.page), vec![shelf.r4.clone()], "R4 still stands as of the members' snapshot"); + assert_eq!(ranked.page.as_of, members.as_of); + assert!(members.as_of < receipt::head(&shelf.mind).unwrap()); + assert_eq!(ranked.dropped, vec![r(K::Ruling, &r5)], "a hit outside the members is dropped, not answered"); } /// A summary is bounded by a named constant whatever the document's lists diff --git a/crates/huginn-mind/src/wire.rs b/crates/huginn-mind/src/wire.rs index e0c198d..ab079c7 100644 --- a/crates/huginn-mind/src/wire.rs +++ b/crates/huginn-mind/src/wire.rs @@ -119,7 +119,7 @@ impl HuginnMindRequest { /// Every answer a mind gives. A refusal is data in the response, never a /// transport failure: the read side carries `Refused`, the write side carries /// the outcome's own `Refused`. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] pub enum HuginnMindResponse { Whoami(MindStatus), Admit(PipelineAdmissionOutcome), @@ -263,6 +263,7 @@ mod tests { } fn responses() -> Vec { + let one_row = seeded().query(&Selection { limit: Some(1), ..Selection::default() }).unwrap().items; let refusal = MindRefusal::ForeignInstance { declared: OTHER_INSTANCE.into(), mind: INSTANCE.into() }; vec![ HuginnMindResponse::Whoami(seeded().status(IndexStatus::Current)), @@ -276,6 +277,15 @@ mod tests { next: None, items: PipelinePageItems::Headers(vec![]), edges: None, + scores: None, + }), + HuginnMindResponse::Query(PipelineSelectionPage { + matched: 1, + as_of: 1, + next: None, + items: one_row, + edges: None, + scores: Some(vec![0.5]), }), HuginnMindResponse::Refused(refusal), HuginnMindResponse::Persona(None), @@ -326,6 +336,24 @@ mod tests { } } + /// A new client reads an old daemon's page: the five fields it had, no + /// `scores`. This is what keeps rollback by flip safe. + #[test] + fn a_page_encoded_without_scores_decodes_with_none() { + #[derive(Serialize)] + struct OldPage { + matched: u32, + as_of: u64, + next: Option, + items: PipelinePageItems, + edges: Option>, + } + let old = OldPage { matched: 0, as_of: 7, next: None, items: PipelinePageItems::Headers(vec![]), edges: None }; + let bytes = rmp_serde::to_vec_named(&old).unwrap(); + let page = rmp_serde::from_slice::(&bytes).unwrap(); + assert_eq!((page.matched, page.as_of, page.scores), (0, 7, None)); + } + /// The two files the catalog publishes are the derivation of the two /// types, byte for byte. #[test] diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index 28d1fa2..0303dfc 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -3663,7 +3663,7 @@ ] }, "PipelineSelectionPage": { - "description": "One page of a selection. `matched` is the count before paging, `as_of` the\nsnapshot the page and every cursor of its walk are exact as of, and `edges`\nis present exactly when the selection follows a hop.", + "description": "One page of a selection. `matched` is the count before paging, `as_of` the\nsnapshot the page and every cursor of its walk are exact as of, `edges`\nis present exactly when the selection follows a hop, and `scores` exactly\nwhen the answer is semantic: one cosine similarity per item, in order\n(higher is nearer), for display and thresholding. It is never stored.", "type": "object", "properties": { "as_of": { @@ -3693,6 +3693,16 @@ "string", "null" ] + }, + "scores": { + "type": [ + "array", + "null" + ], + "items": { + "type": "number", + "format": "float" + } } }, "required": [ From 874dacde38d5eba9a5ff7f7732ce2014f18097d8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 19:52:28 +0100 Subject: [PATCH 008/333] Test helper rank_all: the local `ranked` no longer shadows it The first commit's huginn-mind test module did not compile (helper shadowed by a local binding, and a mechanical rewrite had touched the helper body). Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 46 ++++++++++++++++----------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index bace21c..dea58be 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -1630,9 +1630,9 @@ mod tests { /// The members of the selection from the mind, then the page ranked from /// the hits: the two steps the daemon takes around the index. - fn ranked(mind: &Mind, selection: &Selection, semantic: &SemanticQuery, hits: &[(PipelineRef, f32)]) -> Ranked { + fn rank_all(mind: &Mind, selection: &Selection, semantic: &SemanticQuery, hits: &[(PipelineRef, f32)]) -> Ranked { let members = mind.members(selection, semantic).unwrap(); - ranked(&mind, selection, semantic, &members, hits) + mind.rank(selection, semantic, &members, hits).unwrap() } /// A mind with every way a document stops being in force: Q1 answered @@ -1678,7 +1678,7 @@ mod tests { hit(K::Ruling, &shelf.r4, 0.5), hit(K::Question, &shelf.q2, 0.4), ]; - let ranked = ranked(&shelf.mind, &Selection::default(), &asking(10), &hits); + let ranked = rank_all(&shelf.mind, &Selection::default(), &asking(10), &hits); assert_eq!( ranked.dropped, vec![r(K::Question, &id("question", "Q99")), r(K::Question, &shelf.r4)], @@ -1689,15 +1689,15 @@ mod tests { "without in_force the selection has not asked about standing, and the unheld and mis-kinded hits are gone"); assert_eq!(everything.matched, plain_matched(&shelf.mind, &Selection::default()), "matched counts the whole mind, not the five hits"); - let page = ranked(&shelf.mind, &standing(Selection::default()), &asking(10), &hits).page; + let page = rank_all(&shelf.mind, &standing(Selection::default()), &asking(10), &hits).page; assert_eq!(ids(&page), vec![shelf.r4.clone(), shelf.q2.clone()]); assert_eq!((page.matched, page.next), (plain_matched(&shelf.mind, &standing(Selection::default())), None)); assert_eq!(page.as_of, receipt::head(&shelf.mind).unwrap()); - let none = ranked(&shelf.mind, &standing(Selection::default()), &asking(10), &[hit(K::Question, &id("question", "Q99"), 1.0)]).page; + let none = rank_all(&shelf.mind, &standing(Selection::default()), &asking(10), &[hit(K::Question, &id("question", "Q99"), 1.0)]).page; assert_eq!(ids(&none), Vec::::new(), "an empty candidate set is an empty page, not a refusal"); assert_eq!(none.matched, plain_matched(&shelf.mind, &standing(Selection::default())), "and the members are still counted"); - assert_eq!(ranked(&shelf.mind, &standing(Selection::default()), &asking(10), &[]).page.matched, none.matched); + assert_eq!(rank_all(&shelf.mind, &standing(Selection::default()), &asking(10), &[]).page.matched, none.matched); } /// The substrate answers 200 rows and cuts by ordinal, so 250 candidates @@ -1715,7 +1715,7 @@ mod tests { let mut hits: Vec<(PipelineRef, f32)> = (0..250).map(|n| hit(K::Question, &key(n), n as f32)).collect(); // Twenty ids the mind holds nothing for, above every real score. hits.extend((0..20).map(|n| hit(K::Question, &id("question", &format!("Gone{n}")), 1000.0 + n as f32))); - let ranked = ranked(&mind, &Selection::default(), &asking(200), &hits); + let ranked = rank_all(&mind, &Selection::default(), &asking(200), &hits); let expected: Vec = (50..250).rev().map(key).collect(); assert_eq!(ids(&ranked.page), expected, "the 200 best, best first"); assert_eq!(ranked.page.matched, plain_matched(&mind, &Selection::default())); @@ -1730,16 +1730,16 @@ mod tests { fn ranked_results_are_ordered_by_score_then_ordinal_and_cut_to_the_limit() { let shelf = shelf(); let by_ordinal = [hit(K::Question, &shelf.q2, 0.4), hit(K::Ruling, &shelf.r4, 0.5)]; - assert_eq!(ids(&ranked(&shelf.mind, &standing(Selection::default()), &asking(10), &by_ordinal).page), vec![shelf.r4.clone(), shelf.q2.clone()], + assert_eq!(ids(&rank_all(&shelf.mind, &standing(Selection::default()), &asking(10), &by_ordinal).page), vec![shelf.r4.clone(), shelf.q2.clone()], "Q2 was admitted first, and the better score still comes first"); let tied = [hit(K::Ruling, &shelf.r4, 0.5), hit(K::Question, &shelf.q2, 0.5)]; - assert_eq!(ids(&ranked(&shelf.mind, &standing(Selection::default()), &asking(10), &tied).page), vec![shelf.q2.clone(), shelf.r4.clone()], + assert_eq!(ids(&rank_all(&shelf.mind, &standing(Selection::default()), &asking(10), &tied).page), vec![shelf.q2.clone(), shelf.r4.clone()], "a tie falls to the earlier ordinal"); let limited = Selection { limit: Some(1), ..standing(Selection::default()) }; - let page = ranked(&shelf.mind, &limited, &asking(10), &by_ordinal).page; + let page = rank_all(&shelf.mind, &limited, &asking(10), &by_ordinal).page; assert_eq!((ids(&page), page.matched, page.next), (vec![shelf.r4.clone()], plain_matched(&shelf.mind, &standing(Selection::default())), None), "the limit keeps the best, not the earliest"); - let page = ranked(&shelf.mind, &standing(Selection::default()), &asking(1), &by_ordinal).page; + let page = rank_all(&shelf.mind, &standing(Selection::default()), &asking(1), &by_ordinal).page; assert_eq!((ids(&page), page.matched), (vec![shelf.r4.clone()], plain_matched(&shelf.mind, &standing(Selection::default()))), "top_k bounds the page too"); } @@ -1751,18 +1751,18 @@ mod tests { fn the_selections_own_predicates_decide_which_candidates_stay() { let shelf = shelf(); let hits = [hit(K::Question, &shelf.q2, 0.4), hit(K::Ruling, &shelf.r4, 0.5)]; - assert_eq!(ids(&ranked(&shelf.mind, &of_kinds(&[K::Ruling]), &asking(10), &hits).page), vec![shelf.r4.clone()]); + assert_eq!(ids(&rank_all(&shelf.mind, &of_kinds(&[K::Ruling]), &asking(10), &hits).page), vec![shelf.r4.clone()]); let keyed = Selection { keys: Some(vec![shelf.q2.clone(), shelf.q1.clone()]), ..Selection::default() }; - assert_eq!(ids(&ranked(&shelf.mind, &keyed, &asking(10), &hits).page), vec![shelf.q2.clone()], "keys intersect the candidates"); + assert_eq!(ids(&rank_all(&shelf.mind, &keyed, &asking(10), &hits).page), vec![shelf.q2.clone()], "keys intersect the candidates"); let disjoint = Selection { keys: Some(vec![shelf.q1.clone()]), ..Selection::default() }; - let none = ranked(&shelf.mind, &disjoint, &asking(10), &hits); + let none = rank_all(&shelf.mind, &disjoint, &asking(10), &hits); assert_eq!((none.page.matched, ids(&none.page).len()), (1, 0), "Q1 is the one member, and neither hit is it"); assert_eq!(none.dropped.len(), 2, "hits outside the members are dropped"); let unnamed = Selection { cited: Some(Incoming { role: "subject".into(), exists: false }), ..Selection::default() }; let plain = ids(&shelf.mind.query(&Selection { keys: Some(vec![shelf.q2.clone(), shelf.r4.clone()]), ..unnamed.clone() }).unwrap()); assert_eq!(plain, vec![shelf.r4.clone()], "no resolution names R4 as its subject, and one names Q2"); - assert_eq!(ids(&ranked(&shelf.mind, &unnamed, &asking(10), &hits).page), plain, "the same answer as the plain query over the same rows"); - assert_eq!(ids(&ranked(&shelf.mind, &Selection { projection: "document".into(), ..of_kinds(&[K::Ruling]) }, &asking(10), &hits).page), vec![shelf.r4.clone()]); + assert_eq!(ids(&rank_all(&shelf.mind, &unnamed, &asking(10), &hits).page), plain, "the same answer as the plain query over the same rows"); + assert_eq!(ids(&rank_all(&shelf.mind, &Selection { projection: "document".into(), ..of_kinds(&[K::Ruling]) }, &asking(10), &hits).page), vec![shelf.r4.clone()]); } /// A citation hop is answered over the whole mind, and its edges follow the @@ -1774,14 +1774,14 @@ mod tests { let shelf = shelf(); let named = Selection { cited: Some(Incoming { role: "subject".into(), exists: true }), ..of_kinds(&[K::Question]) }; let hits = [hit(K::Question, &shelf.q2, 0.9), hit(K::Question, &shelf.q1, 0.4)]; - let both = ranked(&shelf.mind, &named, &asking(10), &hits).page; + let both = rank_all(&shelf.mind, &named, &asking(10), &hits).page; assert_eq!(ids(&both), vec![shelf.q2.clone(), shelf.q1.clone()], "Q1 was admitted first and scored lower"); let edges = both.edges.expect("a hop carries its edges"); let targets: Vec<&str> = edges.iter().map(|edge| edge.to.id.0.as_str()).collect(); assert_eq!(targets, [shelf.q2.as_str(), shelf.q1.as_str()], "the edges follow the page order"); assert!(edges.iter().all(|edge| edge.from.kind == K::Resolution && edge.role == CitationRole::Subject)); - let limited = ranked(&shelf.mind, &Selection { limit: Some(1), ..named.clone() }, &asking(10), &hits).page; + let limited = rank_all(&shelf.mind, &Selection { limit: Some(1), ..named.clone() }, &asking(10), &hits).page; assert_eq!(ids(&limited), vec![shelf.q2.clone()]); let kept = limited.edges.unwrap(); assert_eq!(kept.len(), 1, "the dropped row's edge is dropped with it"); @@ -1789,7 +1789,7 @@ mod tests { assert_eq!(kept[0].from.id.0, id("resolution", "question.Q2.n1")); let empty_cursor = Selection { cursor: Some(String::new()), ..standing(Selection::default()) }; - let with = ranked(&shelf.mind, &empty_cursor, &asking(10), &[hit(K::Ruling, &shelf.r4, 0.5)]).page; + let with = rank_all(&shelf.mind, &empty_cursor, &asking(10), &[hit(K::Ruling, &shelf.r4, 0.5)]).page; assert_eq!(ids(&with), vec![shelf.r4.clone()]); } @@ -1895,7 +1895,7 @@ mod tests { committed(admit(&mut mind, (0..5).map(question_n).collect())); let key = |n: u32| id("question", &format!("Q{n}")); let selection = of_kinds(&[K::Question]); - let ranked = ranked(&mind, &selection, &asking(10), &[hit(K::Question, &key(3), 0.9), hit(K::Question, &key(1), 0.2)]); + let ranked = rank_all(&mind, &selection, &asking(10), &[hit(K::Question, &key(3), 0.9), hit(K::Question, &key(1), 0.2)]); assert_eq!(ranked.page.matched, 5); assert_eq!(ids(&ranked.page), vec![key(3), key(1)]); } @@ -1911,13 +1911,13 @@ mod tests { hit(K::Question, &shelf.q1, 0.9), hit(K::Question, &id("question", "Q99"), 0.8), ]; - let page = ranked(&shelf.mind, &standing(Selection::default()), &asking(10), &hits).page; + let page = rank_all(&shelf.mind, &standing(Selection::default()), &asking(10), &hits).page; assert_eq!(ids(&page), vec![shelf.r4.clone(), shelf.q2.clone()]); assert_eq!(page.scores, Some(vec![0.5, 0.4]), "the scores of the rows kept, in row order"); - let all = ranked(&shelf.mind, &Selection::default(), &asking(2), &hits).page; + let all = rank_all(&shelf.mind, &Selection::default(), &asking(2), &hits).page; assert_eq!(ids(&all), vec![shelf.q1.clone(), shelf.r4.clone()]); assert_eq!(all.scores, Some(vec![0.9, 0.5]), "cut to top_k, the scores are cut with the rows"); - let empty = ranked(&shelf.mind, &standing(Selection::default()), &asking(10), &[]).page; + let empty = rank_all(&shelf.mind, &standing(Selection::default()), &asking(10), &[]).page; assert_eq!(empty.scores, Some(vec![]), "a semantic answer with no rows still says it is semantic"); assert_eq!(shelf.mind.query(&standing(Selection::default())).unwrap().scores, None); } From 0cfb4fa27801fcb432462beca7204f13e25f747b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 19:54:52 +0100 Subject: [PATCH 009/333] Index test: the ticket test hands the sink the member it expects back Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-daemon/src/index/tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/huginn-daemon/src/index/tests.rs b/crates/huginn-daemon/src/index/tests.rs index 081bb25..831e401 100644 --- a/crates/huginn-daemon/src/index/tests.rs +++ b/crates/huginn-daemon/src/index/tests.rs @@ -1443,7 +1443,8 @@ fn every_search_has_its_own_ticket_and_its_answer_comes_back_exactly_once() { )]; let mut tickets = Vec::new(); for text in ["a", "b", "c"] { - let ticket = ask_sink(&mut sink, text, 3).unwrap(); + let among = vec!["eureka-state:question:Q1".to_owned()]; + let ticket = >::search(&mut sink, text, 3, among).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); From 19a4d835714b6dca1682d56962407319c1eb5dda Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 19:58:26 +0100 Subject: [PATCH 010/333] Request schema: regenerate SemanticQuery's description (top_k is the most rows answered, all members) Co-Authored-By: Claude Sonnet 5.5 --- schemas/cultnet/huginn.mind_request.v1.schema.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index 77ee834..684bea9 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -1857,7 +1857,9 @@ ] }, "SemanticQuery": { - "description": "A semantic query: the text to find documents near, and how many. The index\nowns which fields of each kind are text (`index_text`), so no substring\nfilter lives here to become a second answer to that question.", + "description": "A semantic query: the text to find documents near, and how many. The index\nowns which fields of each kind are text (`index_text`), so no substring\nfilter lives here to become a second answer to that question. `top_k` is +the most rows answered, all of them members of the selection: the +selection decides membership before the index is asked.", "type": "object", "properties": { "text": { From 1868f6a023a989ba0abbbec339123b0cddfad493 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 20:03:08 +0100 Subject: [PATCH 011/333] Request schema: the SemanticQuery description keeps its newlines escaped The previous commit wrote raw newlines into the JSON string, which the published file must not hold. Co-Authored-By: Claude Sonnet 5.5 --- schemas/cultnet/huginn.mind_request.v1.schema.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index 684bea9..0059e46 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -1857,9 +1857,7 @@ ] }, "SemanticQuery": { - "description": "A semantic query: the text to find documents near, and how many. The index\nowns which fields of each kind are text (`index_text`), so no substring\nfilter lives here to become a second answer to that question. `top_k` is -the most rows answered, all of them members of the selection: the -selection decides membership before the index is asked.", + "description": "A semantic query: the text to find documents near, and how many. The index\nowns which fields of each kind are text (`index_text`), so no substring\nfilter lives here to become a second answer to that question. `top_k` is\nthe most rows answered, all of them members of the selection: the\nselection decides membership before the index is asked.", "type": "object", "properties": { "text": { From f13138c3fc498b015a954f3c21e8ce6b8a609586 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 20:17:47 +0100 Subject: [PATCH 012/333] Mind::members: drop the projection override the walk never read cargo-mutants showed deleting it survives: ids come from rows whatever the projection, so the override decided nothing. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index dea58be..6c8d816 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -521,7 +521,7 @@ impl Mind { /// at the head, decided before the index is asked anything. /// /// The selection passes the doors `semantic_selection` names, then is - /// walked by cursor at one snapshot, header projection, until it has no + /// walked by cursor at one snapshot, a page of LIMIT_MAX at a time, until it has no /// next page, so `hops` and `in_force` decide membership exactly as /// `query` does. The index never decides who is a member; it scores the /// ids this returns. @@ -529,7 +529,7 @@ impl Mind { let selection = Self::semantic_selection(selection, semantic)?; let head = receipt::head(self)?; let rows = Reader::at(self, head)?.rows()?; - let mut walk = Selection { limit: Some(LIMIT_MAX), projection: Selection::default().projection, ..selection }; + let mut walk = Selection { limit: Some(LIMIT_MAX), ..selection }; let mut ids = Vec::new(); loop { let evaluation = select(&Vocabulary, &rows, &walk, head, self.cursor_key())?; From abd28908347683e8c86bef568ede68a2aed92008 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 21:05:02 +0100 Subject: [PATCH 013/333] Mind::members: the walk's page size is the mind's by construction The walk selection is built field by field from the caller's filters, so a caller's limit, projection, order or cursor cannot reach it (one select per member at limit 1 measured 300 ms against 14 ms). A test pins it. Unverified at this commit: built and tested once at the end of the batch. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 47 +++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 6c8d816..3ba5946 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -23,7 +23,7 @@ use std::collections::{BTreeMap, BTreeSet}; use cultnet_rs::{ - Cursor, EdgeAnchor, EdgeMatch, Evaluation, LIMIT_MAX, LIMIT_MIN, PROJECTION_DOCUMENT, Row, Selection, select, validate, + Cursor, EdgeAnchor, EdgeMatch, Evaluation, LIMIT_MAX, LIMIT_MIN, PROJECTION_DOCUMENT, PROJECTION_HEADER, Row, Selection, select, validate, }; use eureka_pipeline::{ ClaimOutcome, CommitRange, Date, FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, OrgRepo, @@ -517,6 +517,26 @@ impl Mind { refuse_values(selection) } + /// The selection `members` walks: the caller's filters and nothing else. + /// Built field by field, never spread from the caller's selection, so the + /// page size is ours (the caller's `limit` shapes an answer, not the + /// walk's cost: one select per member at limit 1) and the walk is headers + /// only, from the first page, in the one order. + fn walk_selection(selection: Selection) -> Selection { + let Selection { schemas, keys, fields, cites, cited, .. } = selection; + Selection { + schemas, + keys, + fields, + cites, + cited, + projection: PROJECTION_HEADER.into(), + descending: false, + limit: Some(LIMIT_MAX), + cursor: None, + } + } + /// The members of a semantic query: every document the selection matches /// at the head, decided before the index is asked anything. /// @@ -529,7 +549,7 @@ impl Mind { let selection = Self::semantic_selection(selection, semantic)?; let head = receipt::head(self)?; let rows = Reader::at(self, head)?.rows()?; - let mut walk = Selection { limit: Some(LIMIT_MAX), ..selection }; + let mut walk = Self::walk_selection(selection); let mut ids = Vec::new(); loop { let evaluation = select(&Vocabulary, &rows, &walk, head, self.cursor_key())?; @@ -1887,6 +1907,29 @@ mod tests { assert_eq!(mind.members(&limited, &asking(5)).unwrap().ids.len(), 250); } + /// The walk costs one select per 200 members whatever the caller asked + /// for: a caller's `limit`, projection, order or cursor never reach it, + /// and its filters all do. + #[test] + fn the_members_walk_takes_its_page_size_from_the_mind_not_the_caller() { + let questions = of_kinds(&[K::Question]); + let caller = Selection { + limit: Some(1), + projection: PROJECTION_DOCUMENT.into(), + descending: true, + cursor: Some("caller-cursor".into()), + keys: Some(vec!["Q1".into()]), + cited: Some(Incoming { role: "subject".into(), exists: false }), + ..questions + }; + let walk = Mind::::walk_selection(caller.clone()); + assert_eq!(walk.limit, Some(LIMIT_MAX)); + assert_eq!(walk.projection, PROJECTION_HEADER); + assert!(!walk.descending); + assert_eq!(walk.cursor, None); + assert_eq!((walk.schemas, walk.keys, walk.cited), (caller.schemas, caller.keys, caller.cited)); + } + /// `matched` is what the selection admits, not what the index returned: /// two hits among five members still say five. #[test] From 2041fa84675acd9ce0748fea8dc2c44c6d72cc32 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 21:05:02 +0100 Subject: [PATCH 014/333] Qdrant search: the test parses the request body as JSON; the live smoke excludes a non-member filter and params are asserted by value, so must_not and a top-level exact fail. live_index_smoke searches a strict subset that leaves out the nearest point. Unverified at this commit: built and tested once at the end of the batch. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-daemon/src/index/tests.rs | 17 +++++++---------- crates/huginn-daemon/tests/live_index_smoke.rs | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/crates/huginn-daemon/src/index/tests.rs b/crates/huginn-daemon/src/index/tests.rs index 831e401..855bef6 100644 --- a/crates/huginn-daemon/src/index/tests.rs +++ b/crates/huginn-daemon/src/index/tests.rs @@ -1101,16 +1101,13 @@ fn qdrant_search_scores_only_the_members_exactly_and_reads_ids_kinds_and_scores( ); 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]", - "\"has_id\":[\"p1\",\"p2\"]", - "\"exact\":true", - "\"limit\":12", - "\"with_vector\":false", - ] { - assert!(seen[0].2.contains(expected), "{expected} in {}", seen[0].2); - } - assert!(!seen[0].2.contains("\"kind\":{"), "membership is ids alone, no predicate on a payload field: {}", seen[0].2); + let body: serde_json::Value = serde_json::from_str(&seen[0].2).expect("the request body is JSON"); + assert_eq!(body["vector"], serde_json::json!([0.5, 1.0, 2.0, 3.0])); + assert_eq!(body["filter"], serde_json::json!({ "must": [{ "has_id": ["p1", "p2"] }] }), "members are included, by id alone"); + assert_eq!(body["params"], serde_json::json!({ "exact": true }), "exact search, under params"); + assert_eq!(body["limit"], 12); + assert_eq!(body["with_vector"], false); + assert!(body.get("exact").is_none(), "a top-level exact is ignored by Qdrant: {body}"); drop(seen); let nameless = stub(&[("POST /collections/", 200, r#"{"result":[{"id":"p","score":0.5,"payload":{"kind":"question"}}]}"#)]); diff --git a/crates/huginn-daemon/tests/live_index_smoke.rs b/crates/huginn-daemon/tests/live_index_smoke.rs index 1fc60c4..c6a2c9e 100644 --- a/crates/huginn-daemon/tests/live_index_smoke.rs +++ b/crates/huginn-daemon/tests/live_index_smoke.rs @@ -46,9 +46,13 @@ fn the_adapters_embed_write_query_and_clean_up_a_scratch_collection() { 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); + let texts = [ + "The organ owns the mind.".to_string(), + "A recipe for lentil soup.".to_string(), + "How to keep a sourdough starter alive.".to_string(), + ]; + let vectors = embedder.embed(&texts, Duration::from_secs(60)).expect("three texts embed"); + assert_eq!(vectors.len(), 3); assert!(vectors.iter().all(|vector| vector.len() == identity.dimensions as usize)); let meta = CollectionMeta { @@ -102,6 +106,12 @@ Query: Who is responsible for keeping the memory?"); 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:?}"); + // The members are a strict subset that leaves out the nearest point: + // the filter must exclude it, not merely rank it. + let subset: Vec = points[1..].iter().map(|point| point.id.clone()).collect(); + let hits = store.search(name, &query, &subset, 3, Duration::from_secs(60)).unwrap(); + let docs: BTreeSet<&str> = hits.iter().map(|hit| hit.doc_id.as_str()).collect(); + assert_eq!(docs, BTreeSet::from(["smoke:doc:1", "smoke:doc:2"]), "only the members are scored: {hits:?}"); println!("{name}: described, created, upserted {} points, queried, searched: {hits:?}", points.len()); } })); From 68f93b69dc68ac230472d861880bb8cbff90380c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 21:05:03 +0100 Subject: [PATCH 015/333] Daemon test: a large top_k reaches the index whole Twenty members, top_k 15 and 200: the index limit and the row count are both asserted. Unverified at this commit: built and tested once at the end of the batch. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-daemon/src/semantic_tests.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/huginn-daemon/src/semantic_tests.rs b/crates/huginn-daemon/src/semantic_tests.rs index b72f897..4d46b90 100644 --- a/crates/huginn-daemon/src/semantic_tests.rs +++ b/crates/huginn-daemon/src/semantic_tests.rs @@ -351,6 +351,27 @@ fn a_narrow_selection_gets_top_k_members_when_non_members_score_higher() { assert!((scores[0] - 0.5).abs() < 1e-6, "the score is the index's: {scores:?}"); } +/// The daemon forwards the caller's top_k to the index as asked, however large: +/// twenty members stand, top_k 15 reaches the index as 15 and answers fifteen +/// rows, and top_k 200 reaches it as 200 and answers all twenty. +#[test] +fn a_large_top_k_reaches_the_index_whole_and_answers_that_many_members() { + let kept = |n: u32| id("ruling", &format!("K{n:02}")); + let seed = vec![campaign_seed(), (0..20).map(|n| ruling(&format!("K{n:02}"), None)).collect()]; + let harness = Harness::start(seed, FakeEmbedder::new("d1"), FakeIndex::default(), Duration::from_secs(30)); + let mut client = harness.client(); + until_index(&mut client, "Current", |status| *status == IndexStatus::Current); + harness.index.state.lock().unwrap().hits = (0..20).map(|n| hit("ruling", kept(n), 0.9 - n as f32 * 0.01)).collect(); + let rulings = Selection { schemas: Some(vec![PipelineKind::Ruling.type_id().into()]), ..standing() }; + + let fifteen = ask(&mut client, "m-1", &semantic(rulings.clone(), 15)); + assert_eq!(ids(&fifteen), (0..15).map(kept).collect::>()); + let all = ask(&mut client, "m-2", &semantic(rulings, 200)); + assert_eq!(ids(&all), (0..20).map(kept).collect::>()); + let limits: Vec = harness.index.state.lock().unwrap().searches.iter().map(|(_, _, limit)| *limit).collect(); + assert_eq!(limits, vec![15, 200], "the index is asked for the caller's top_k, not a clamp of it"); +} + /// A selection that matches nothing is an empty page at once: the query is not /// embedded and the index is not asked. #[test] From dccf9200303809a591e512dfdf8c4fba7e7bd91c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 21:32:56 +0100 Subject: [PATCH 016/333] Mind::members builds its walk inline and counts its pages; the tests observe the production walk walk_selection is deleted: members is the only place the walk is spelled. Members carries the number of select pages taken. The page-size test calls members with caller limit 1 and document projection over 590 questions and asserts the exact ids and exactly 3 pages. A new test pins that the cites hop limits members and matched to the cited rows. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 101 +++++++++++++++++++------------- 1 file changed, 60 insertions(+), 41 deletions(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 3ba5946..a6b1b22 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -295,6 +295,8 @@ pub struct PipelineSelectionPage { pub struct Members { pub as_of: u64, pub ids: Vec, + /// How many select pages the walk took: the cost of the membership. + pub pages: u32, } /// A ranked semantic answer: the page, and the hits the index named that the @@ -517,26 +519,6 @@ impl Mind { refuse_values(selection) } - /// The selection `members` walks: the caller's filters and nothing else. - /// Built field by field, never spread from the caller's selection, so the - /// page size is ours (the caller's `limit` shapes an answer, not the - /// walk's cost: one select per member at limit 1) and the walk is headers - /// only, from the first page, in the one order. - fn walk_selection(selection: Selection) -> Selection { - let Selection { schemas, keys, fields, cites, cited, .. } = selection; - Selection { - schemas, - keys, - fields, - cites, - cited, - projection: PROJECTION_HEADER.into(), - descending: false, - limit: Some(LIMIT_MAX), - cursor: None, - } - } - /// The members of a semantic query: every document the selection matches /// at the head, decided before the index is asked anything. /// @@ -549,14 +531,32 @@ impl Mind { let selection = Self::semantic_selection(selection, semantic)?; let head = receipt::head(self)?; let rows = Reader::at(self, head)?.rows()?; - let mut walk = Self::walk_selection(selection); + // The walk is built here, field by field, from the caller's filters + // alone and never spread from the caller's selection: the page size + // is ours (the caller's `limit` shapes an answer, not the walk's cost: + // one select per member at limit 1), and the walk is headers only, + // from the first page, in the one order. + let Selection { schemas, keys, fields, cites, cited, .. } = selection; + let mut walk = Selection { + schemas, + keys, + fields, + cites, + cited, + projection: PROJECTION_HEADER.into(), + descending: false, + limit: Some(LIMIT_MAX), + cursor: None, + }; let mut ids = Vec::new(); + let mut pages = 0; loop { let evaluation = select(&Vocabulary, &rows, &walk, head, self.cursor_key())?; + pages += 1; ids.extend(evaluation.rows.iter().map(ref_of)); match evaluation.next_cursor { Some(next) => walk.cursor = Some(next), - None => return Ok(Members { as_of: head, ids }), + None => return Ok(Members { as_of: head, ids, pages }), } } } @@ -1907,27 +1907,46 @@ mod tests { assert_eq!(mind.members(&limited, &asking(5)).unwrap().ids.len(), 250); } - /// The walk costs one select per 200 members whatever the caller asked - /// for: a caller's `limit`, projection, order or cursor never reach it, - /// and its filters all do. + /// The walk costs one select per LIMIT_MAX members whatever the caller + /// asked for, through the production `members`: a caller's `limit`, + /// projection or spread never reach it. At limit 1 and document + /// projection over 590 questions the walk takes exactly 3 pages and + /// returns exactly the questions, in key order. #[test] fn the_members_walk_takes_its_page_size_from_the_mind_not_the_caller() { - let questions = of_kinds(&[K::Question]); - let caller = Selection { - limit: Some(1), - projection: PROJECTION_DOCUMENT.into(), - descending: true, - cursor: Some("caller-cursor".into()), - keys: Some(vec!["Q1".into()]), - cited: Some(Incoming { role: "subject".into(), exists: false }), - ..questions - }; - let walk = Mind::::walk_selection(caller.clone()); - assert_eq!(walk.limit, Some(LIMIT_MAX)); - assert_eq!(walk.projection, PROJECTION_HEADER); - assert!(!walk.descending); - assert_eq!(walk.cursor, None); - assert_eq!((walk.schemas, walk.keys, walk.cited), (caller.schemas, caller.keys, caller.cited)); + let mut mind = seeded(); + for start in (0..590u32).step_by(crate::admission::BATCH_MAX) { + committed(admit(&mut mind, (start..(start + crate::admission::BATCH_MAX as u32).min(590)).map(question_n).collect())); + } + let caller = Selection { limit: Some(1), projection: PROJECTION_DOCUMENT.into(), ..of_kinds(&[K::Question]) }; + let members = mind.members(&caller, &asking(5)).unwrap(); + let mut expected: Vec = (0..590).map(|n| r(K::Question, &id("question", &format!("Q{n}")))).collect(); + expected.sort_by(|a, b| a.id.0.cmp(&b.id.0)); + assert_eq!(members.ids, expected, "exactly the questions, once each, in key order"); + assert_eq!(LIMIT_MAX, 200); + assert_eq!(members.pages, 3, "590 members at the mind's page of 200"); + } + + /// The walk keeps the caller's `cites` hop: among follow-ups, only the one + /// citing Q1 is a member, and a ranking over all three counts only it. + #[test] + fn a_cites_hop_limits_the_members_to_the_cited_rows() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A"), question("Q2", &["A", "B"], "A")])); + let (q1, q2) = (id("question", "Q1"), id("question", "Q2")); + committed(admit(&mut mind, vec![ + follow_up("FU-1", r(K::Question, &q1)), + follow_up("FU-2", r(K::Question, &q2)), + follow_up("FU-3", r(K::Question, &q2)), + ])); + let selection = Selection { cites: Some(cites(K::Question, &q1, None)), ..of_kinds(&[K::FollowUp]) }; + let fu = |label: &str| id("follow_up", label); + let members = mind.members(&selection, &asking(5)).unwrap(); + assert_eq!(members.ids, vec![r(K::FollowUp, &fu("FU-1"))]); + let hits = [hit(K::FollowUp, &fu("FU-2"), 0.9), hit(K::FollowUp, &fu("FU-1"), 0.8), hit(K::FollowUp, &fu("FU-3"), 0.7)]; + let ranked = rank_all(&mind, &selection, &asking(5), &hits); + assert_eq!(ranked.page.matched, 1); + assert_eq!(ids(&ranked.page), vec![fu("FU-1")]); } /// `matched` is what the selection admits, not what the index returned: From eadc2bfadc0719bb39165f31229cf72fc2cdd8d2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 21:32:56 +0100 Subject: [PATCH 017/333] Qdrant search test: the limit is pinned by value at 1, 12 and 200 Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-daemon/src/index/tests.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/huginn-daemon/src/index/tests.rs b/crates/huginn-daemon/src/index/tests.rs index 855bef6..2e70044 100644 --- a/crates/huginn-daemon/src/index/tests.rs +++ b/crates/huginn-daemon/src/index/tests.rs @@ -1110,6 +1110,15 @@ fn qdrant_search_scores_only_the_members_exactly_and_reads_ids_kinds_and_scores( assert!(body.get("exact").is_none(), "a top-level exact is ignored by Qdrant: {body}"); drop(seen); + // The limit is the caller's by value, across the range top_k takes: no + // constant, clamp or cap sits between the daemon and Qdrant. + for limit in [1, 12, 200] { + let echoing = stub(&[("POST /collections/", 200, r#"{"result":[]}"#)]); + QdrantIndex::new(&echoing.url).search(&name, &[1.0], &["p1".to_owned()], limit, TIME).unwrap(); + let sent: serde_json::Value = serde_json::from_str(&echoing.seen.lock().unwrap()[0].2).expect("the request body is JSON"); + assert_eq!(sent["limit"], limit, "the limit asked for is the limit sent"); + } + 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"}}"#)]); From 05347b5417cf83db18b803ff95eb3e67a5be8753 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 21:33:27 +0100 Subject: [PATCH 018/333] Members walk test: 599 members, so a page of 199 takes 4 pages and fails Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index a6b1b22..1f4dfd4 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -1910,21 +1910,21 @@ mod tests { /// The walk costs one select per LIMIT_MAX members whatever the caller /// asked for, through the production `members`: a caller's `limit`, /// projection or spread never reach it. At limit 1 and document - /// projection over 590 questions the walk takes exactly 3 pages and + /// projection over 599 questions the walk takes exactly 3 pages and /// returns exactly the questions, in key order. #[test] fn the_members_walk_takes_its_page_size_from_the_mind_not_the_caller() { let mut mind = seeded(); - for start in (0..590u32).step_by(crate::admission::BATCH_MAX) { - committed(admit(&mut mind, (start..(start + crate::admission::BATCH_MAX as u32).min(590)).map(question_n).collect())); + for start in (0..599u32).step_by(crate::admission::BATCH_MAX) { + committed(admit(&mut mind, (start..(start + crate::admission::BATCH_MAX as u32).min(599)).map(question_n).collect())); } let caller = Selection { limit: Some(1), projection: PROJECTION_DOCUMENT.into(), ..of_kinds(&[K::Question]) }; let members = mind.members(&caller, &asking(5)).unwrap(); - let mut expected: Vec = (0..590).map(|n| r(K::Question, &id("question", &format!("Q{n}")))).collect(); + let mut expected: Vec = (0..599).map(|n| r(K::Question, &id("question", &format!("Q{n}")))).collect(); expected.sort_by(|a, b| a.id.0.cmp(&b.id.0)); assert_eq!(members.ids, expected, "exactly the questions, once each, in key order"); assert_eq!(LIMIT_MAX, 200); - assert_eq!(members.pages, 3, "590 members at the mind's page of 200"); + assert_eq!(members.pages, 3, "599 members at the mind's page of 200"); } /// The walk keeps the caller's `cites` hop: among follow-ups, only the one From 15d8efe8ab72cdaea20a5ab4d0fb1ec2714d16a4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 21:34:42 +0100 Subject: [PATCH 019/333] Members walk test: expected ids in the mind's order (admission batch, then key) The previous commit's test expected plain key order and fails; this one builds and corrects it. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 1f4dfd4..f76b5aa 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -1911,7 +1911,7 @@ mod tests { /// asked for, through the production `members`: a caller's `limit`, /// projection or spread never reach it. At limit 1 and document /// projection over 599 questions the walk takes exactly 3 pages and - /// returns exactly the questions, in key order. + /// returns exactly the questions, in the mind's order. #[test] fn the_members_walk_takes_its_page_size_from_the_mind_not_the_caller() { let mut mind = seeded(); @@ -1920,9 +1920,11 @@ mod tests { } let caller = Selection { limit: Some(1), projection: PROJECTION_DOCUMENT.into(), ..of_kinds(&[K::Question]) }; let members = mind.members(&caller, &asking(5)).unwrap(); - let mut expected: Vec = (0..599).map(|n| r(K::Question, &id("question", &format!("Q{n}")))).collect(); - expected.sort_by(|a, b| a.id.0.cmp(&b.id.0)); - assert_eq!(members.ids, expected, "exactly the questions, once each, in key order"); + // The one order: admission order (a batch is one ordinal), then key. + let mut numbered: Vec = (0..599).collect(); + numbered.sort_by_key(|n| (n / crate::admission::BATCH_MAX as u32, format!("Q{n}"))); + let expected: Vec = numbered.iter().map(|n| r(K::Question, &id("question", &format!("Q{n}")))).collect(); + assert_eq!(members.ids, expected, "exactly the questions, once each, in the mind's order"); assert_eq!(LIMIT_MAX, 200); assert_eq!(members.pages, 3, "599 members at the mind's page of 200"); } From 8c98b7b02877ed20933fd83d2ead7ed54dcbb4d1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 22:16:07 +0100 Subject: [PATCH 020/333] Members walk: drop the dead projection assignment; pin the cites role through members The walk never reads projection (select reads it only in validate and the cursor digest), so the assignment and its "headers only" comment decided nothing. A new test cites Q1 through two roles by rows of one schema and asserts exact members and matched for subject, superseded_by and no role. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 38 +++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index f76b5aa..f39df86 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -23,7 +23,7 @@ use std::collections::{BTreeMap, BTreeSet}; use cultnet_rs::{ - Cursor, EdgeAnchor, EdgeMatch, Evaluation, LIMIT_MAX, LIMIT_MIN, PROJECTION_DOCUMENT, PROJECTION_HEADER, Row, Selection, select, validate, + Cursor, EdgeAnchor, EdgeMatch, Evaluation, LIMIT_MAX, LIMIT_MIN, PROJECTION_DOCUMENT, Row, Selection, select, validate, }; use eureka_pipeline::{ ClaimOutcome, CommitRange, Date, FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, OrgRepo, @@ -534,8 +534,8 @@ impl Mind { // The walk is built here, field by field, from the caller's filters // alone and never spread from the caller's selection: the page size // is ours (the caller's `limit` shapes an answer, not the walk's cost: - // one select per member at limit 1), and the walk is headers only, - // from the first page, in the one order. + // one select per member at limit 1), from the first page, in the + // one order. let Selection { schemas, keys, fields, cites, cited, .. } = selection; let mut walk = Selection { schemas, @@ -543,10 +543,10 @@ impl Mind { fields, cites, cited, - projection: PROJECTION_HEADER.into(), descending: false, limit: Some(LIMIT_MAX), cursor: None, + ..Selection::default() }; let mut ids = Vec::new(); let mut pages = 0; @@ -1951,6 +1951,36 @@ mod tests { assert_eq!(ids(&ranked.page), vec![fu("FU-1")]); } + /// The walk keeps the role of the caller's `cites` hop: Q1 is cited by two + /// resolutions, one as its subject and one as what supersedes another + /// question, and a hop through `subject` admits only the first. + #[test] + fn a_cites_hop_keeps_its_role_in_the_members() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A"), question("Q2", &["A", "B"], "A")])); + let (q1, q2) = (id("question", "Q1"), id("question", "Q2")); + committed(admit(&mut mind, vec![answering("R1", &q1)])); + committed(admit(&mut mind, vec![resolution(r(K::Question, &q2), superseded(&[r(K::Question, &q1)]))])); + let (as_subject, as_superseder) = (id("resolution", "question.Q1.n1"), id("resolution", "question.Q2.n1")); + let through = |role: Option<&str>| Selection { + cites: Some(cites(K::Question, &q1, role)), + ..of_kinds(&[K::Resolution]) + }; + let hits = [hit(K::Resolution, &as_superseder, 0.9), hit(K::Resolution, &as_subject, 0.8)]; + + let subject = through(Some("subject")); + assert_eq!(mind.members(&subject, &asking(5)).unwrap().ids, vec![r(K::Resolution, &as_subject)]); + let ranked = rank_all(&mind, &subject, &asking(5), &hits); + assert_eq!(ranked.page.matched, 1); + assert_eq!(ids(&ranked.page), vec![as_subject.clone()]); + + let superseded_by = through(Some("superseded_by")); + assert_eq!(mind.members(&superseded_by, &asking(5)).unwrap().ids, vec![r(K::Resolution, &as_superseder)]); + + let any = mind.members(&through(None), &asking(5)).unwrap(); + assert_eq!(any.ids, vec![r(K::Resolution, &as_subject), r(K::Resolution, &as_superseder)]); + } + /// `matched` is what the selection admits, not what the index returned: /// two hits among five members still say five. #[test] From 2a98dcdff2ca6e27acbd7412c265102089570f76 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 22:17:30 +0100 Subject: [PATCH 021/333] Members walk test: the role hop uses resolutions of a cut spec (a question cannot be superseded) The previous commit's test admitted a Superseded resolution of a question, which admission refuses. Cite r2 through subject and superseded_by instead. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index f39df86..ce7bbe1 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -1951,19 +1951,22 @@ mod tests { assert_eq!(ids(&ranked.page), vec![fu("FU-1")]); } - /// The walk keeps the role of the caller's `cites` hop: Q1 is cited by two - /// resolutions, one as its subject and one as what supersedes another - /// question, and a hop through `subject` admits only the first. + /// The walk keeps the role of the caller's `cites` hop: spec r2 is cited + /// by two resolutions, one as its subject and one as what supersedes r1, + /// and a hop through `subject` admits only the first. #[test] fn a_cites_hop_keeps_its_role_in_the_members() { let mut mind = seeded(); - committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A"), question("Q2", &["A", "B"], "A")])); - let (q1, q2) = (id("question", "Q1"), id("question", "Q2")); - committed(admit(&mut mind, vec![answering("R1", &q1)])); - committed(admit(&mut mind, vec![resolution(r(K::Question, &q2), superseded(&[r(K::Question, &q1)]))])); - let (as_subject, as_superseder) = (id("resolution", "question.Q1.n1"), id("resolution", "question.Q2.n1")); + let (r1, r2) = (id("cut_spec", "cut-1.r1"), id("cut_spec", "cut-1.r2")); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1))])); + committed(admit(&mut mind, vec![ + D::CutSpec(cut_spec("1", 2)), + resolution(r(K::CutSpec, &r1), superseded(&[r(K::CutSpec, &r2)])), + ])); + committed(admit(&mut mind, vec![resolution(r(K::CutSpec, &r2), withdrawn())])); + let (as_superseder, as_subject) = (id("resolution", "cut_spec.cut-1.r1.n1"), id("resolution", "cut_spec.cut-1.r2.n1")); let through = |role: Option<&str>| Selection { - cites: Some(cites(K::Question, &q1, role)), + cites: Some(cites(K::CutSpec, &r2, role)), ..of_kinds(&[K::Resolution]) }; let hits = [hit(K::Resolution, &as_superseder, 0.9), hit(K::Resolution, &as_subject, 0.8)]; @@ -1978,7 +1981,7 @@ mod tests { assert_eq!(mind.members(&superseded_by, &asking(5)).unwrap().ids, vec![r(K::Resolution, &as_superseder)]); let any = mind.members(&through(None), &asking(5)).unwrap(); - assert_eq!(any.ids, vec![r(K::Resolution, &as_subject), r(K::Resolution, &as_superseder)]); + assert_eq!(any.ids, vec![r(K::Resolution, &as_superseder), r(K::Resolution, &as_subject)]); } /// `matched` is what the selection admits, not what the index returned: From af3efcad40b067dd9a26b85b11270b2cdd859e3b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 25 Oct 2026 22:21:58 +0100 Subject: [PATCH 022/333] Members walk: leave descending and cursor to the Selection default With projection gone the walk spreads Selection::default(); an explicit descending: false was a mutant no test could tell from the default. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/query.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index ce7bbe1..1f1945b 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -543,9 +543,7 @@ impl Mind { fields, cites, cited, - descending: false, limit: Some(LIMIT_MAX), - cursor: None, ..Selection::default() }; let mut ids = Vec::new(); From fd321fdacd8f1c4d0230c0f8940bac8ce5af89db Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 26 Oct 2026 13:53:34 +0100 Subject: [PATCH 023/333] Research: prior art for a materialized read image in Huginn (serves eureka-substrate:follow_up:huginn-materialized-image) Co-Authored-By: Claude Opus 5.5 --- docs/research/huginn-materialized-image.md | 109 +++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/research/huginn-materialized-image.md diff --git a/docs/research/huginn-materialized-image.md b/docs/research/huginn-materialized-image.md new file mode 100644 index 0000000..5ad4bef --- /dev/null +++ b/docs/research/huginn-materialized-image.md @@ -0,0 +1,109 @@ +# Prior art: a materialized read image advanced by a single writer, with snapshots for cursors + +Serves follow-up `eureka-substrate:follow_up:huginn-materialized-image` under operator ruling `eureka-substrate:ruling:readback-gate-and-materialized-image`. Campaign `eureka-substrate`, session `self-2026-10-25-eureka`. Written by Eyes (`eyes-materialized-image`) on 2026-10-26. Facts carry evidence pointers; inference is marked "(inferred)". No recommendation is made in the body; the last section lists what each option would delete and cost, for the design fork. + +Method note: web sources were read through a summarizing fetch tool, not verbatim. Claims about third-party systems are as summarized from the cited page; where a page did not say something, that is stated. Nothing was measured in Huginn; the 200 ms / 11k figure is the brief's. + +## 1. What Huginn does today (code facts) + +Paths are under `F:\Projects\Huginn\crates\huginn-mind\src\` unless noted. + +**Storage and the in-memory image** +- `Mind::open` takes the redb store's exclusive per-path lock for the Mind's lifetime (`mind.rs:130-147`; refusal `MindAlreadyOwned`). One process, one writer. +- `open_checked` pulls all envelopes, runs the gates, then `attach` (`mind.rs:183-191`). `Mind.image` is a `Vec` (`mind.rs:102`), the raw encoded envelopes (msgpack payload bytes), in identity order. `Mind::envelopes()` hands it out (`mind.rs:214`). +- After every commit, `refresh()` runs `cache.pull_all_backing_stores()` then `image = snapshot(&cache)` (`mind.rs:274-278`), called from `receipt::commit` (`receipt.rs:246-286`, the call at `:271`). The redb backing store's `pull_all` opens a redb `begin_read()` and reads the whole table (`F:\Projects\CultLib\packages\cultcache-rs\src\lib.rs:2349-2350`). So the write path is also linear in the mind: every admitted batch re-reads every envelope from redb. +- Commit is a compare-and-swap batch: strong reads as expectation, writes plus the receipt as replacements (`receipt.rs:253-270`). Receipts are ordinary envelopes of type `huginn.mind_commit_receipt.v2` (`receipt.rs:28, 97`) with a dense ordinal `1..=N`; `head()` decodes every receipt, sorts ordinals, and refuses unless they are exactly `1..=N` (`receipt.rs:204-213`). + +**The read path (`query.rs`)** +- `Mind::view` (`query.rs:456-460`): `receipt::head` (decodes all receipts), then `Reader::at(self, head)`, then a find and `view_of`. +- `Mind::query` (`query.rs:469-487`): validates, takes `as_of` from the cursor (`Cursor::parse`, refused if beyond head, `:473-484`) or head, then `Reader::at(self, as_of)?.rows()?`, then `select`. Members/ranked paths build a Reader the same way (`:532-533`, `:589-590`). +- `Reader::at` (`query.rs:380-391`): `Docs::from_image(mind.envelopes())` decodes every envelope of a known kind into `Held { kind, key, document }` (`docs.rs:27-54`); `AdmissionIndex::build` calls `head` again and decodes every receipt again, building a `BTreeMap<(type, key), AdmissionFacts>` over all writes, refusing if two receipts write one identity (`query.rs:315-340`); then it keeps `Held` whose receipt `ordinal <= as_of` and drops the rest. "Built once per call and thrown away with it" (`query.rs:365-367`). +- `held()` is a linear `iter().find` (`query.rs:393-395`); `Docs::find`/`in_image` likewise (`docs.rs:65-75`); `Mind::raw_envelope` is a linear scan over the envelope Vec (`mind.rs:261-263`). +- Status is derived per document: `view_of` calls `Docs::closing_resolution` (`query.rs:397-405`), which scans all resolutions and, per candidate, calls `in_force(Resolution, key)`, which itself is `closing_resolution_unless(...).is_none()` (`docs.rs:165-183, 204-206`). `rows()` does this for every held document plus a `base()` walk (`query.rs:420-439`). Cost shape (inferred from the code, not profiled): decode O(N), receipt decode O(R) twice, rows O(N x Rz) where Rz is the number of resolutions, recursion depth bounded by the Q19 cap at two (`query.rs:414-419`). The 200 ms may be dominated by the status scan rather than decode; that is a hypothesis to profile (inferred). +- Admission builds its own `Docs::from_image(self.envelopes())` per batch (`admission.rs:127`; also `index.rs:128`), so admission pays the full decode as well. + +**Invariants the image must keep (from doc comments)** +- A snapshot at `as_of` is the documents whose writing receipt has `ordinal <= as_of`, with every derivation (in force, closing resolution, base, citations) computed over that image alone (`query.rs:374-379`). +- Every held document must have its receipt at any `as_of`; an orphan or a double write refuses at any snapshot (`query.rs:377-379, 351`). +- `writes` only: a document later cited as a strong read keeps the receipt that wrote it (`query.rs:311-314`). A10: one write per identity, which means documents are written once and never overwritten in place; the only "change" is a later resolution document (inferred from `query.rs:311-314`; a persona put is the stated exception, `receipt.rs:259-260`). +- Cursors carry `as_of` and are minted with a per-process random key (`mind.rs:190`, `CursorKey::random()`), so a cursor does not outlive the process (inferred: a restart makes old cursors unverifiable). + +Consequence worth stating plainly (inferred): because identities are written once and receipts are dense and monotone, the set of documents visible at `as_of` is a prefix of the receipt chain. A document's own introduction ordinal is immutable. Only derived status (closed by a resolution) varies with `as_of`, and it varies monotonically: a resolution with ordinal r closes its subject for every `as_of >= r`. This is the structure the prior art below is being asked to exploit. + +## 2. MVCC read views + +**PostgreSQL.** Under Repeatable Read the snapshot is taken at the first non-transaction-control statement and every later statement in the transaction sees the same committed data (PostgreSQL docs, "Transaction Isolation", https://www.postgresql.org/docs/current/transaction-iso.html). A snapshot can be exported with `pg_export_snapshot()` and imported by another session via `SET TRANSACTION SNAPSHOT`; the exporting transaction must stay open for the id to remain importable (https://www.postgresql.org/docs/current/functions-admin.html). Failure mode: an old row version must be kept while any transaction may still see it, so open or idle-in-transaction sessions, prepared transactions and replication slots hold back cleanup and cause bloat (https://www.postgresql.org/docs/current/routine-vacuuming.html; the xmin-horizon mechanism is named in the page's diagnostic queries and "outside the page" note, i.e. partly the fetch tool's general knowledge). +- Relevance: PostgreSQL's cursor-like long snapshot is a held transaction. The cost of serving an old snapshot is paid in retained versions, by the writer's storage, for as long as the snapshot is held. A Huginn cursor has no connection and may be resumed minutes later; holding an engine transaction per outstanding cursor is the Postgres exported-snapshot model and carries its bloat risk (inferred). + +**LMDB.** Single writer, readers take no locks; read transactions pin old page versions, so "the database can grow quickly" under long-lived readers, and stale readers from aborted processes cause the same; docs advise avoiding long-lived transactions, checking stale readers with `mdb_reader_check`, and note the reader table has a fixed slot count (default 126) (lmdb.h comments, https://raw.githubusercontent.com/LMDB/lmdb/mdb.master/libraries/liblmdb/lmdb.h). `MDB_NOTLS` lets a read transaction cross threads. +- Relevance: same cost class as redb below. + +**redb (already in Huginn).** MVCC on a copy-on-write B+tree. A read transaction takes a private copy of the root and registers itself, so pages that root references are not freed while it runs; freed pages are queued and reused only after readers that could reference them finish (redb `docs/design.md`, https://github.com/cberner/redb/blob/master/docs/design.md; also the 1.0 post, https://www.redb.org/post/2023/06/16/1-0-stable-release/, which says readers and writers do not block each other). `ReadTransaction` is `Send + Sync`, closes when it and all objects referencing it drop (https://docs.rs/redb/latest/redb/struct.ReadTransaction.html). Savepoints register as read transactions to preserve a snapshot and cost allocator-state copy of about 64 kB per GB of values at 4 kB pages; persistent savepoints survive restart, must be deallocated explicitly, and keep their pages from reclamation (design.md; the space-growth statement is the fetch tool's inference). +- What a redb read transaction gives for free: a consistent, lock-free view of the whole table as of the last commit before it began, for the lifetime of the handle, with no copy. Huginn's `pull_all` already uses it, but only to copy every envelope into a Vec and drop the transaction (`cultcache-rs lib.rs:2349-2350`). Nothing in the pages read gives a way to open a read transaction at an arbitrary older commit; older views exist only as held read transactions or savepoints (the pages describe no "open at txid" API; absence in the fetched text, not proof). +- What it does not give: decoded documents, a status index, or an ordinal filter. It would remove the in-process image copy and its refresh, not the decode and derivation work (inferred). +- Failure mode for cursors: one pinned transaction per live cursor-snapshot prevents page reuse, so the redb file grows while cursors are held, and there is no natural expiry for a cursor that an agent abandons (inferred from the reuse rule). +- Note a structural mismatch (inferred): redb's snapshots are per commit of the engine; Huginn's `as_of` is per receipt. Because each admitted batch is one compare-and-swap commit with its receipt in the same write transaction (`receipt.rs:268-270`), engine commits and receipt ordinals correspond 1:1, but redb does not expose the mapping and cannot be asked for "the view as of ordinal 12,345" after the fact. + +## 3. Immutable database values and persistent maps + +**Datomic.** Information accumulates and is never removed; databases are immutable values, so compute instances need no coordination for query and caches hold only immutable data (https://docs.datomic.com/whatis/architecture.html). Queries merge persistent index trees with an in-memory representation of recent change; new index trees are built occasionally by background indexing jobs (same page; the pages fetched did not use the terms novelty/memory index there). The operations page defines the memory index as transaction data logged but not yet in a disk index, triggers an indexing job at `memory-index-threshold` (default 32m), lets the transactor continue until `memory-index-max` (default 512m), and applies radical backpressure if novelty exceeds max before indexing finishes (https://docs.datomic.com/operation/capacity.html). +- `as-of` is a filter over the same indexes, not a stored copy; filtering "is not free"; entity-plus-time queries jump via EAVT then drop newer datoms; time-only questions would need a scan of the whole database (https://docs.datomic.com/reference/filters.html). The current db from a connection does no filtering, so "now" pays no history penalty. +- Relevance (inferred): this is the closest match to Huginn's data model: append-only, writes never overwritten, and `as_of` is a filter on a monotone position (Datomic `t`, Huginn receipt ordinal). The Datomic answer to "older snapshot" is not to keep versions; it is to keep every datom with its `t` and filter. Applied here: keep each document once, tagged with its introduction ordinal, and keep each resolution tagged with its ordinal; an `as_of` read filters by tag. A Datomic filter pays per touched datom, not per mind, because it goes through an index. +- Failure modes: the novelty/indexing split exists because the in-memory representation must be bounded; backpressure is the cost of the writer outrunning the indexer. Huginn's single-writer, low-rate admission is far from that regime (inferred). + +**Persistent (structural-sharing) maps in Rust.** +- `imbl`: clone is a refcount increment on the root; nodes are copied on write only when shared; `HashMap` is "almost neck and neck" with std, `OrdMap` about 2-3x slower than std on basic operations, `Vector` loses to `Vec` on memory and indexing; sole-owner in-place mutation is about an order of magnitude faster than immutable operations; default pointer is `Arc` (thread safe), `Rc` and `triomphe` options exist (https://docs.rs/imbl/latest/imbl/). +- `rpds`: `Vector`, `HashTrieMap`, `RedBlackTreeMap`; thread safety is opt-in via `_sync` constructors using `Arc`, which is "significantly slower to clone and drop than Rc"; mutable methods are faster when old versions are not needed (https://github.com/orium/rpds). +- Relevance (inferred): a persistent map gives one owner advancing a head version per admission and cheap retention of any older version by holding its root. Memory cost of N retained versions is the changed paths, not N copies. Hazards: per-version retention has no expiry unless something drops the root (a cursor table), and `OrdMap`/trie ops cost a constant factor over a plain map for reads. Because Huginn's history is a prefix chain, each version differs from its predecessor by a few documents, so path-copy cost per admission is small (inferred); but the same prefix property means a version could be reconstructed by filtering rather than retained (see Datomic). +- Handing a version to readers: `arc-swap` provides an atomically swappable `Arc` for read-mostly state, one writer publishing, many lock-free readers each keeping the version it loaded (https://docs.rs/arc-swap/latest/arc_swap/). The page notes the crate is niche, and that 1.9 strengthened memory orderings with probable cost; it did not discuss guard hold times. + +## 4. Event-sourced projections with a checkpoint + +**Marten (PostgreSQL).** An async daemon processes events in order; a high-water mark (HWM) advances contiguously and stops at gaps; each projection shard records its own progression; rebuilds via `projections --rebuild` or `RebuildProjectionAsync` (https://martendb.io/events/projections/async-daemon.html). Failure modes listed there: non-transactional PostgreSQL sequences burn numbers on rollback, creating gaps that stall the HWM; long-running or idle-in-transaction sessions can stall it; forcing a skip (`SkipStaleGapsDespiteLiveTransactionsAfter`, `AdvanceHighWaterMarkToLatestAsync`) can permanently skip events that commit later; slow pages beyond `StopAndDrainTimeout` get cancelled and reprocessed each shutdown; poison events go to a dead-letter table; migrating an inline projection to async needs downtime. +- Relevance (inferred): the gap hazard comes from multiple concurrent appenders with non-transactional sequence numbers. Huginn has one writer and a dense ordinal checked as `1..=N` in `head` (`receipt.rs:204-213`), so the HWM and gap machinery have no counterpart; the projection's checkpoint is simply "head ordinal applied". + +**EventStoreDB / Kurrent, Akka Projection.** The Kurrent projections page URL fetched returned 404 on the second path; the Akka overview page says only that an offset per event lets a projection resume after restart, with offsets kept in Cassandra, JDBC, R2DBC or Slick, and did not state delivery guarantees or rebuild procedure. Neither was verified further here. Not evidence for or against. + +**Fowler, "Event Sourcing".** State can be derived by replaying the log from a blank state, to any point; replay is "a slow process, particularly if there are many events"; snapshots of application state speed restarts and can be built in parallel; "Complete Rebuild" discards derived state and replays (https://martinfowler.com/eaaDev/EventSourcing.html). +- Relevance (inferred): Huginn is already its own event log (the receipts) and its own store (the envelopes). The projection here is derived from the same file that holds its checkpoint. On a crash or version change the image rebuilds from the envelopes (what `Reader::at` does today per read), so "rebuild on version change" is the existing code run once at open instead of per read. A persisted projection (a second store) would add a checkpoint that can disagree with the file (inferred: a second owner of the head ordinal). + +## 5. Incremental view maintenance for derived status + +**DBSP (Budiu, McSherry, Ryzhyk, Tannen).** A language for computation over streams, with an algorithm that incrementalizes arbitrary programs, covering relational queries, aggregation and recursion (https://arxiv.org/abs/2203.16684, abstract only; the PDF fetch returned unreadable binary, so claims about cost bounds were not verified; the fetch tool's general-knowledge recollection is that distinct and aggregation need integrated state that grows with input). +**Differential dataflow / Materialize arrangements.** Arrangements are shared indexed state serving many queries; logical compaction (`set_logical_compaction(frontier)`) declares that times before the frontier will no longer be queried, letting the trace merge them, after which reads of older times are not guaranteed; holding trace handles prevents compaction (https://timelydataflow.github.io/differential-dataflow/chapter_5/chapter_5_3.html). +- Relevance (inferred): Huginn's "status" is a two-level lookup (resolution subject -> closing resolution, `docs.rs:171-183`), not a recursive query. A general IVM engine is machinery well beyond that derivation. The transferable lesson is the compaction frontier: an indexed state that serves several times needs an explicit declaration of the oldest time still readable, otherwise it never shrinks. For Huginn the analogue is a cursor TTL. The simple incremental alternative the evidence supports is recompute-per-key-on-write: when a resolution with subject S is admitted, record `closed_by[S] = (resolution key, ordinal)`; `view_of` is then one map lookup. Status at `as_of` is `closed_by[S]` if its ordinal <= as_of (inferred, from the monotone-closure property in section 1; A8 and the Q19 cap admit at most one closing resolution per document, `docs.rs:168-170`, so no multi-valued history is needed for that case; resolutions that are themselves resolved make `in_force(Resolution)` depend on the same lookup, depth capped at two). + +## 6. What each option would mean here + +Common frame: single owner of the image (the `Mind`); an admission advances it after the redb commit; `Mind::view/query` read it. Memory figures are order-of-magnitude (inferred) with decoded document size unknown; assume 1-3 KB decoded per document: 11k docs about 10-35 MB; 1M docs about 1-3 GB. The current per-read build allocates and frees that on every call; the options differ in how much stays resident. + +| Option | Owner / advance | Old `as_of` served by | Resident memory | Deletes from Huginn | Main risk | +|---|---|---|---|---|---| +| A. Hold redb read transactions as the snapshot (per cursor) | Image stays derived per read from a held transaction; no resident image | Keep the transaction open per live cursor | Engine pages for pinned versions, grows with write churn while cursors live | `refresh()` re-pull and the `image` Vec could go (inferred); not decode or status scan | Unbounded cursor lifetime pins file growth (LMDB/redb rule); does not fix the 200 ms, which is decode plus derivation (inferred) | +| B. One resident decoded image at head, advanced per admission; old `as_of` rebuilt on demand | `Mind` owns `Image { docs by (kind,key) -> Held+ordinal, closed_by map, receipts }`; admission appends the batch's Held and closure entries after the redb commit; head reads clone nothing | Rebuild from envelopes for `as_of < head` only (today's `Reader::at` path, kept for the rare cursor) | Full decoded mind: 10-35 MB at 11k, 1-3 GB at 1M | per-read `from_image`, `AdmissionIndex::build`, double `head` receipt decode for head reads; admission's own `from_image` (`admission.rs:127`); the linear `held`/`raw_envelope` scans become map lookups | Two code paths (head fast, old slow) must agree; rebuild cost for old cursors stays O(N) | +| C. Resident image tagged with ordinals; `as_of` is a filter (Datomic-style) | Same as B, but every Held and every closure entry keeps its ordinal; an `as_of` read filters by ordinal tag | The same image: visible iff `ordinal <= as_of`; status iff `closed_by.ordinal <= as_of` | Same as B plus an ordinal per document and closure | Everything B deletes, plus the second (rebuild) path: one image, one read path, `Reader::at(as_of)` becomes a view parameter | A filtered scan for `query` is still O(N) per page unless ordered by ordinal; correctness relies on the prefix property (documents written once) | +| D. Persistent-map versions (imbl/rpds), one root retained per live cursor | Writer publishes a new root per admission (e.g. via `ArcSwap`); cursors hold a root | The retained root | Head image plus changed paths per retained version | Same as B; plus the rebuild path | Constant-factor read cost (OrdMap about 2-3x std); roots need an expiry; new dependency; solves a problem C does not have if history is a prefix (inferred) | +| E. Persisted projection with checkpoint (event-sourcing style) | A second store holding the image and a checkpoint ordinal, advanced by an applier | Replay from checkpoint or rebuild | Same resident image plus disk | Nothing; adds a second owner of head | Checkpoint can disagree with the file; rebuild-on-version-change machinery (Marten/Fowler) for no gain over rebuilding at open (inferred) | +| F. General IVM (DBSP/differential) | An IVM runtime maintains `status` | Compaction frontier | Arrangements | The status scan | Machinery far exceeding a two-level subject lookup; unverified cost bounds here | + +What B/C replace in the status derivation (inferred): `closing_resolution` / `in_force` scans (`docs.rs:171-206`) with a `closed_by` map written at admission time. The admission rule that already computes "which resolution closes this subject" for A8/Q19 (`docs.rs:165-183` is shared by admission and views, per its doc comment at `docs.rs:202-203`) would be the place that writes the entry, keeping one derivation. + +## 7. Open facts the fork will want measured (not measured here) + +- Where the 200 ms goes: envelope decode (`docs.rs:44-54`), receipt decode twice (`receipt.rs:204`, `query.rs:325`), or the status scan in `rows()` (`query.rs:431-439` over `docs.rs:171-183`). The brief's linear-in-mind figure does not distinguish these. +- Decoded size per document at 11k; the memory table above is an estimate. +- Whether `Selection` evaluation (`select`, `query.rs:486`) is itself linear per page, which an image would not fix. +- Cursor lifetime in practice (key is per process, `mind.rs:190`) and how many outstanding `as_of` values older than head exist at once; this decides whether "rebuild on demand for old as_of only" is rare enough. +- Whether the per-commit `refresh()` (`mind.rs:274-278`) is worth keeping once an image is advanced from the committed batch; it also serves the lost-CAS diff (`receipt.rs:272-283`). + +## Sources + +- Huginn: `F:\Projects\Huginn\crates\huginn-mind\src\{query,docs,mind,receipt,admission,index}.rs`; `F:\Projects\CultLib\packages\cultcache-rs\src\lib.rs` (lines 1975, 2349-2350). +- redb: https://github.com/cberner/redb/blob/master/docs/design.md ; https://www.redb.org/post/2023/06/16/1-0-stable-release/ ; https://docs.rs/redb/latest/redb/struct.ReadTransaction.html +- LMDB: https://raw.githubusercontent.com/LMDB/lmdb/mdb.master/libraries/liblmdb/lmdb.h +- PostgreSQL: https://www.postgresql.org/docs/current/transaction-iso.html ; https://www.postgresql.org/docs/current/functions-admin.html ; https://www.postgresql.org/docs/current/routine-vacuuming.html +- Datomic: https://docs.datomic.com/whatis/architecture.html ; https://docs.datomic.com/operation/capacity.html ; https://docs.datomic.com/reference/filters.html +- imbl: https://docs.rs/imbl/latest/imbl/ ; rpds: https://github.com/orium/rpds ; arc-swap: https://docs.rs/arc-swap/latest/arc_swap/ +- Marten: https://martendb.io/events/projections/async-daemon.html ; Akka Projection: https://doc.akka.io/libraries/akka-projection/current/overview.html ; Fowler: https://martinfowler.com/eaaDev/EventSourcing.html +- DBSP: https://arxiv.org/abs/2203.16684 ; differential dataflow: https://timelydataflow.github.io/differential-dataflow/chapter_5/chapter_5_3.html +- Not reached: Datomic "Time in Datomic" and indexing pages (404), Kurrent projections page (404/redirect), LMDB docs HTML (TLS error), DBSP PDF (binary). From 1e319f75f44654fc9bed7eab32572a3c14cc1fea Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 26 Oct 2026 14:00:15 +0100 Subject: [PATCH 024/333] eureka-substrate map: upgrade read-back gate (probe timings, one gate in huginn-mind, cut sequence) Co-Authored-By: Claude Opus 5.5 --- docs/eureka-substrate-map.md | 71 ++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/docs/eureka-substrate-map.md b/docs/eureka-substrate-map.md index e9c61c4..d76270d 100644 --- a/docs/eureka-substrate-map.md +++ b/docs/eureka-substrate-map.md @@ -718,3 +718,74 @@ payload and metadata. Only the search request changes. score. It waits on 1 being deployed. 5. `gap-persona-state-not-indexed` stays with the persona-state upgrade (`huginn-upgrade-daemon-first`). + +## Upgrade read-back gate (Imagination, 2026-10-26) + +Ruling `eureka-substrate:ruling:readback-gate-and-materialized-image` part (1) +and follow-up `eureka-substrate:follow_up:upgrade-readback-unbounded`. Pinned: +Huginn `origin/main` `dd07485b4e69e6743825fec2f19825761ea446a6`, gamecult-ops +`origin/main` `b9e0109773eccea3215ed53c1af5ecccae37b543`. + +### Body facts (probe and source reads, 2026-10-26) + +- Probe, 2026-10-26 ~12:52 UTC, Yggdrasil, `ygg-verify.sh` `RUN_ON_HOST=1` + (image `eureka-verify-rust:b99d30ec441e`, 4 CPUs, 6 GiB, nice 10). A scratch + clone at `dd07485` plus one probe test in `query.rs`'s tests (scratch commit + `d93b400`, pushed only to the verify mirror) ran against a copy of the cold + copy `/var/backups/huginn-upgrade/dd07485b…/mind.redb` (sha256 + `814947b9…c5e0`, 245,370,880 bytes) inside the job's work dir; the copy was + deleted afterwards. 15,291 envelopes, 11,328 pipeline documents. + + | Step | debug | release | + | --- | --- | --- | + | `Mind::open` | 3.16 s | 0.58 s | + | decode, validate, re-key, re-prepare to stored bytes (all 11,328) | 1.14 s | 0.23 s | + | one `Mind::view` (one `Reader::at`) | 19.6 s | 1.05 s | + | one `Reader::at` | 20.4 s | 0.97 s | + | `held` + `view_of` for all 11,328 from that one `Reader` | 4.88 s | 0.39 s | + | whole test binary | 49.3 s | 3.35 s | + + So the deleted organ test's loop (one `Mind::view` per document) costs about + 11,328 x 20 s = 63 h in debug and 3.3 h in release: it never finishes, which + matches the 2 h hang. The one-snapshot gate costs open + byte check + + one `Reader::at` + the view loop: about 30 s debug, 2.5 s release at this + size. The runbook's deadline is 600 s. +- The view loop from one `Reader` is not strictly linear: `Reader::held` is a + linear find and `Docs::closing_resolution` scans every resolution, so it is + O(N x R) comparisons. It is the same cost `Reader::rows` pays on every query + today, measured at 4.9 s debug. The gate does not fix it; part (2) of the + ruling (the materialized image) owns it. +- `mind::tests::snapshot_admits_and_reads_back` (`mind.rs:789-878`) also + resolves six hard-coded idunn-watchdog findings and withdraws resolutions + whose withdrawal keys exceed 64 bytes: the 2026-10-01 key-bound migration, + done once. `eureka-pipeline` `tests::stored_documents_read_back` + (`lib.rs:2750-2822`) checks decode, validate, re-key and stored bytes, plus + resolvability loops and the same six findings ("still stuck"). + +### Rationale + +One gate, in `huginn-mind`. The leaf cannot hold it: `eureka-pipeline` has no +dependency on `huginn-mind` (the reverse holds), so it cannot see `view`. Two +gates overlapped on decode, validate and re-key; the leaf's byte check costs +1.1 s debug and moves into the organ's gate beside the view check. The leaf's +resolvability loops are pinned on synthetic keys by its bound tests (ruling +`key-bound`), and its six-finding list is history. + +The gate goes through the read path, not a copy of it: `Mind::view` is split +into `Reader::at` plus a new `Reader::view` (lookup, then `view_of`), and the +gate builds one `Reader` and calls `Reader::view` per document. A test that +re-spelled `held` and `view_of` would agree with its own copy. That split is +the only production edit; `Reader::at`, `held`, `view_of`, `rows`, `query` +and `rank` are unchanged, and the read path's cost stays with part (2). + +### Cut sequence + +1. readback-gate (Huginn): `cut-readback-gate.r1`. +2. ops-readback-deadline (gamecult-ops): `cut-ops-readback-deadline.r1`, + beside 1. Step 5 runs the one gate detached in container + `huginn-readback-$sha`, deadline 600 s, kill by name, old daemon restarted on + expiry or failure. It names 1's test, so an upgrade with it targets Huginn + main at or after 1's merge. +3. materialized-image (Huginn): a decoded, indexed image the daemon keeps and + advances per admission (ruling part 2). Mapped after prior art; not mapped + here. From fe9069e528162b121ecb5e15997855153d2a5fbf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 26 Oct 2026 14:02:51 +0100 Subject: [PATCH 025/333] Delete the migration-era read-back tests and the leaf's overlapping real-store gate mind::tests::snapshot_admits_and_reads_back (an O(N^2) per-document Mind::view loop plus the six idunn-watchdog resolutions and over-64-byte withdrawals of the 2026-10-01 migration) and eureka-pipeline tests::stored_documents_read_back go, with the leaf README's real-store section. The one gate follows in huginn-mind. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-pipeline/README.md | 13 ++--- crates/eureka-pipeline/src/lib.rs | 73 ------------------------- crates/huginn-mind/src/mind.rs | 90 ------------------------------- docs/eureka-body-map.md | 2 +- 4 files changed, 4 insertions(+), 174 deletions(-) diff --git a/crates/eureka-pipeline/README.md b/crates/eureka-pipeline/README.md index 5d9c633..25fa726 100644 --- a/crates/eureka-pipeline/README.md +++ b/crates/eureka-pipeline/README.md @@ -18,13 +18,6 @@ and every encoding-relevant variant against `golden/envelopes.txt`. A diff there is a wire change; do not regenerate it to make the test pass. An additive field adds golden lines and never changes an existing one. -Opt-in, against a real store: `stored_documents_read_back` decodes, validates -and keys every stored pipeline document and asserts that it re-encodes to the -stored key, type and payload bytes. Take a copy of the store (the test copies -the file again before opening it, so the copy may be read-only), never open the -live one: - -```sh -sudo cp /var/lib/gamecult/huginn/minds/eureka/mind.redb "$SNAP/minds/eureka/mind.redb" -HUGINN_MIND_SNAPSHOT="$SNAP" cargo test -p eureka-pipeline -- --ignored --exact tests::stored_documents_read_back --nocapture -``` +The real-store gate is huginn-mind's `#[ignore]` `query::tests::stored_documents_read_back`, +which checks these stored bytes and the read path together (the leaf cannot see the read +path); the Huginn Upgrade runbook in gamecult-ops runs it. diff --git a/crates/eureka-pipeline/src/lib.rs b/crates/eureka-pipeline/src/lib.rs index d19d192..9288d4c 100644 --- a/crates/eureka-pipeline/src/lib.rs +++ b/crates/eureka-pipeline/src/lib.rs @@ -2747,79 +2747,6 @@ mod tests { assert!(!Sha("5f98228a".into()).names_same_commit(&Sha("5f98228b".into())), "equal length, differing"); } - /// stored-documents-valid and stored-bytes-reproduce, on the real mind. - /// `HUGINN_MIND_SNAPSHOT` is a copy of a state root holding - /// `minds/eureka/mind.redb`; the test copies the store file into a scratch - /// directory first (redb opens read-write), so the snapshot may be - /// read-only. Read through cultcache-rs's own store. Each pipeline - /// document must re-prepare to its stored envelope: key, type and payload - /// bytes. - #[test] - #[ignore = "needs HUGINN_MIND_SNAPSHOT, a copy of a Huginn state root; see the crate README"] - fn stored_documents_read_back() { - use cultcache_rs::{CacheBackingStore, OwnedRedbMessagePackBackingStore}; - let root = std::env::var("HUGINN_MIND_SNAPSHOT").expect("HUGINN_MIND_SNAPSHOT names the snapshot state root"); - let scratch = std::env::temp_dir().join(format!("eureka-pipeline-readback-{}", std::process::id())); - std::fs::create_dir_all(&scratch).expect("scratch directory"); - let working = scratch.join("mind.redb"); - std::fs::copy(Path::new(&root).join("minds").join("eureka").join("mind.redb"), &working).expect("the snapshot copies"); - let store = OwnedRedbMessagePackBackingStore::new(&working).expect("the snapshot opens"); - let cache = schema_cache().expect("the registrar registers"); - let envelopes = store.pull_all().expect("the snapshot reads"); - let mut stored = Vec::new(); - for envelope in &envelopes { - match PipelineDocument::decode(envelope) { - Ok(document) => { - assert_eq!(document.validate(), Ok(()), "{}: valid", envelope.key); - assert_eq!(pipeline_key(&document).as_deref(), Ok(envelope.key.as_str()), "{}: key derives byte for byte", envelope.key); - let again = document.prepare(&cache).unwrap_or_else(|error| panic!("{}: does not re-prepare: {error}", envelope.key)); - assert_eq!( - (&again.key, &again.r#type, &again.payload), - (&envelope.key, &envelope.r#type, &envelope.payload), - "{}: re-encodes to the stored bytes", - envelope.key - ); - stored.push((document.kind(), envelope.key.clone())); - } - Err(PipelineRefusal::ForeignStore { .. }) => {} - Err(error) => panic!("{}: does not decode: {error}", envelope.key), - } - } - assert!(stored.len() > 200, "{} pipeline documents read", stored.len()); - let mut subjects = 0; - let resolvable = |kind: &PipelineKind| !matches!(kind, PipelineKind::Resolution | PipelineKind::Campaign | PipelineKind::Instance); - for (kind, key) in stored.iter().filter(|(kind, _)| resolvable(kind)) { - let one = resolved_at(*kind, key, 1).unwrap_or_else(|error| panic!("{key}: no resolution keys: {error}")); - let two = resolved_at(PipelineKind::Resolution, &one, 1).unwrap_or_else(|error| panic!("{key}: no withdrawal keys: {error}")); - for derived in [&one, &two] { - pipeline_id("read_back", derived, PipelineKind::Resolution).unwrap_or_else(|error| panic!("{derived}: does not parse: {error}")); - } - subjects += 1; - } - for (_, key) in stored.iter().filter(|(kind, _)| *kind == PipelineKind::Resolution) { - resolved_at(PipelineKind::Resolution, key, 1).unwrap_or_else(|error| panic!("{key}: cannot be withdrawn: {error}")); - } - // The six findings that could not be resolved before this cut. - let stuck = [ - "cut-bifrost-retire-alarm.s2.verb-default-accepts-malformed", - "cut-ops-notice-deploy.s2.pinned-bifrost-predates-retry", - "cut-bifrost-notice-retry.s1.unknown-test-ignores-backoff", - "cut-bifrost-notice-retry.s2.closed-unknown-retry-unpinned", - "cut-bifrost-notice-retry.s2.flapping-clock-retries-every-tick", - "cut-idunn-topology-lock.s2.boot-reconcile-skipped-on-contention", - ]; - for label in stuck { - let found = stored - .iter() - .find(|(kind, key)| *kind == PipelineKind::Finding && key.ends_with(label)) - .unwrap_or_else(|| panic!("{label}: not in the snapshot")); - let one = resolved_at(PipelineKind::Finding, &found.1, 1).unwrap_or_else(|error| panic!("{label}: still stuck: {error}")); - assert!(one.split(':').nth(2).is_some_and(|local| local.len() > SUBJECT_LOCAL_MAX), "{label}: the old bound refused it"); - } - eprintln!("read back {} pipeline documents, {subjects} subjects", stored.len()); - let _ = std::fs::remove_dir_all(&scratch); - } - /// The total bound, in the one place it lives: parts that are each a legal /// label compose a local wider than 64 bytes and are refused as a whole. #[test] diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index c33ee6a..697ea51 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -786,94 +786,4 @@ mod tests { })) ); } - - /// stored-documents-valid and every-subject-resolvable, against the real - /// mind: every stored document views back unchanged; the six findings the - /// 64-byte bound left unresolvable take a resolution; and a stored - /// resolution whose withdrawal key is over 64 bytes is withdrawn. Run it - /// on a scratch copy of the state root only: it writes. - #[test] - #[ignore = "reads and writes the scratch copy of a live state root named by HUGINN_MIND_SNAPSHOT"] - fn snapshot_admits_and_reads_back() { - use crate::fixtures::{admit, r, resolution, withdrawn}; - use eureka_pipeline::{PipelineRef, ResolutionOutcome, Short, pipeline_key}; - - let root = std::env::var("HUGINN_MIND_SNAPSHOT").expect("HUGINN_MIND_SNAPSHOT names a scratch copy of the state root"); - let mut mind = Mind::open(Path::new(&root), &slug("eureka")).unwrap(); - - // The census is taken apart from the loop that checks each document. - let pipeline_envelopes = mind - .envelopes() - .iter() - .filter(|envelope| PipelineKind::ALL.iter().any(|kind| kind.type_id() == envelope.r#type)) - .count(); - assert!(pipeline_envelopes > 0, "the snapshot held no pipeline document"); - - let mut viewed = 0; - let mut resolutions = Vec::new(); - for envelope in mind.envelopes() { - let Some(kind) = PipelineKind::ALL.iter().copied().find(|kind| kind.type_id() == envelope.r#type) else { continue }; - let stored = PipelineDocument::decode(envelope).unwrap(); - stored.validate().unwrap_or_else(|refusal| panic!("{} no longer validates: {refusal:?}", envelope.key)); - let rekeyed = pipeline_key(&stored).unwrap_or_else(|refusal| panic!("{} no longer keys: {refusal:?}", envelope.key)); - assert_eq!(rekeyed, envelope.key, "stored key differs from the key the leaf computes"); - let view = mind - .view(&PipelineRef { kind, id: Short(envelope.key.clone()) }) - .unwrap() - .unwrap_or_else(|| panic!("{} does not view", envelope.key)); - assert_eq!(view.document, stored, "{}", envelope.key); - if let PipelineDocument::Resolution(held) = &stored - && !matches!(held.outcome, eureka_pipeline::ResolutionOutcome::Withdrawn { .. }) - && !envelope.key.contains(":resolution:resolution.") - { - resolutions.push(envelope.key.clone()); - } - viewed += 1; - } - assert_eq!(viewed, pipeline_envelopes, "every pipeline envelope validates, rekeys and views back"); - println!("validated, rekeyed and viewed {viewed} stored documents unchanged"); - - let stuck = [ - "cut-bifrost-retire-alarm.s2.verb-default-accepts-malformed", - "cut-ops-notice-deploy.s2.pinned-bifrost-predates-retry", - "cut-bifrost-notice-retry.s1.unknown-test-ignores-backoff", - "cut-bifrost-notice-retry.s2.closed-unknown-retry-unpinned", - "cut-bifrost-notice-retry.s2.flapping-clock-retries-every-tick", - "cut-idunn-topology-lock.s2.boot-reconcile-skipped-on-contention", - ]; - let mut resolved = 0; - for local in stuck { - let subject = r(PipelineKind::Finding, &format!("idunn-watchdog:finding:{local}")); - let outcome = admit(&mut mind, vec![resolution(subject, ResolutionOutcome::Recorded { reason: "snapshot read-back".into() })]); - match outcome { - PipelineAdmissionOutcome::Committed { writes, .. } => { - assert_eq!(writes.len(), 1, "{local}"); - resolved += 1; - } - other => panic!("stuck finding {local} was not resolved: {other:?}"), - } - } - - assert_eq!(resolved, stuck.len(), "every stuck finding resolves"); - - let mut candidates = Vec::new(); - for key in resolutions { - let withdrawal = resolution(r(PipelineKind::Resolution, &key), withdrawn()); - let withdrawal_key = pipeline_key(&withdrawal).unwrap_or_else(|refusal| panic!("withdrawal of {key} does not key: {refusal:?}")); - if withdrawal_key.rsplit(':').next().unwrap().len() > 64 { - candidates.push((key, withdrawal)); - } - } - assert!(!candidates.is_empty(), "no stored resolution had a withdrawal key over 64 bytes"); - let wanted = candidates.len(); - let mut withdrew = 0; - for (key, withdrawal) in candidates { - match admit(&mut mind, vec![withdrawal]) { - PipelineAdmissionOutcome::Committed { .. } => withdrew += 1, - other => panic!("withdrawal of {key} was not committed: {other:?}"), - } - } - assert_eq!(withdrew, wanted, "every candidate withdraws"); - println!("withdrew {withdrew} resolutions whose withdrawal keys exceed 64 bytes"); - } } diff --git a/docs/eureka-body-map.md b/docs/eureka-body-map.md index 4c6538e..f47c118 100644 --- a/docs/eureka-body-map.md +++ b/docs/eureka-body-map.md @@ -326,7 +326,7 @@ pipeline kinds are in Huginn `docs/eureka-substrate-map.md`. | Kind | Identity (what names it) | Lifecycle (what happens over time) | Authority (who decides) | | --- | --- | --- | --- | | Pipeline wire ids | `epiphany.pipeline..v2`, epoch `epiphany.pipeline.epoch.v2`, defined by the leaf, the Huginn crate `crates/eureka-pipeline` (lib `eureka_pipeline`), ids unchanged. | Renamed once to `eureka.pipeline..v3` by one `mind.redb` migration, at the next breaking leaf change or before the first hosted mind (ruling `wire-names-rename-before-hosting`). Adding the `run` kind or the `Mind` authority variant is additive (old stored documents decode; no migration), so it does not trigger the rename; a field added with the leaf's `= absent` marker is additive too (leaf-read-anchors r2: old bytes decode, empty fields encode byte-identically, `golden/envelopes.txt` gains lines and loses none); a field added any other way would. | Huginn owns the leaf. The migration is a live-mind upgrade, which is the operator's (`mind-rules-which-forks`). | -| Leaf crate and schema catalogue | Huginn `crates/eureka-pipeline`, `schemas/cultnet/` (13 pipeline schemas, `gamecult.persona_state.v0`, index, README). `epiphany.work_organ_state.v0` dies: no reader outside Epiphany (grep, F1/F3). | Landed at Huginn `afc35f9` (leaf-into-huginn). Wire pinned by `crates/eureka-pipeline/golden/envelopes.txt` and the opt-in `stored_documents_read_back`. Huginn is AGPL-3.0-only (ruling `eureka-body:ruling:leaf-relicensed-agpl`). | Huginn. Forbidden: any Cargo source resolving into GameCult/Epiphany (`nothing-runs-from-epiphany`). | +| Leaf crate and schema catalogue | Huginn `crates/eureka-pipeline`, `schemas/cultnet/` (13 pipeline schemas, `gamecult.persona_state.v0`, index, README). `epiphany.work_organ_state.v0` dies: no reader outside Epiphany (grep, F1/F3). | Landed at Huginn `afc35f9` (leaf-into-huginn). Wire pinned by `crates/eureka-pipeline/golden/envelopes.txt` and huginn-mind's opt-in `query::tests::stored_documents_read_back`. Huginn is AGPL-3.0-only (ruling `eureka-body:ruling:leaf-relicensed-agpl`). | Huginn. Forbidden: any Cargo source resolving into GameCult/Epiphany (`nothing-runs-from-epiphany`). | | `run` | `:run: