diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index b80a539ae..064dc9688 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -22,6 +22,7 @@ on: - cron - database - email + - eval - harness - hermes - http diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 268f53ac1..274ab64d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,7 @@ on: - 'cron/v*' - 'database/v*' - 'email/v*' + - 'eval/v*' - 'harness/v*' - 'hermes/v*' - 'http/v*' diff --git a/README.md b/README.md index 469e6a0c7..f76e1c965 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ npx skills add iii-hq/iii --all | [`acp`](acp/) | Rust | Agent Client Protocol surface — stdio JSON-RPC, exposes iii agents as ACP sessions. | | [`approval-gate`](approval-gate/) | Rust | Human-in-the-loop approval gate — evaluates each function call (continue / deny / hold), holds pending calls for a human, and emits `approval::pending-*` events. Binds the harness `pre_trigger` hook. See [`approval-gate/architecture/`](approval-gate/architecture/). | | [`harness`](harness/) | Node | TS port of the iii harness stack — bundles `harness` (provider registry + credentials/settings/permissions via the `configuration` worker), `turn-orchestrator`, `hook-fanout`, `models-catalog`, the `provider-*` workers, `llm-budget`, and `context-compaction` as one pnpm monorepo. Approval is delegated to the standalone `approval-gate` worker via the `pre_trigger` hook. Conversations persist in `session-manager`. See [`harness/README.md`](harness/README.md). | +| [`eval`](eval/) | Rust | Durable same-model A/B evaluation for prompts and system prompts — runs paired harness sessions, delegates correctness to iii evaluator functions, and reports pass rates with token, cost, latency, function-call, trace, and span metrics. | | [`codex`](codex/) | Rust | OpenAI Codex as an iii worker — `codex::*` spawn the codex CLI for headless turns, mirror raw thread events onto `codex::events`, and stream AgentEvent frames onto `agent::events`. | | [`grok`](grok/) | Rust | xAI Grok CLI as an iii worker — `grok::*` spawn the grok CLI for headless turns (`grok --print --output-format streaming-json`), mirror raw events onto `grok::events`, and stream AgentEvent frames onto `agent::events`. | | [`devin`](devin/) | Rust | Devin as an iii worker: `devin::run` drives the local devin CLI and streams AgentEvent frames onto `agent::events`, `devin::session::*` wrap the Devin cloud session lifecycle, and `devin::api` reaches any v3 endpoint. | diff --git a/approval-gate/Cargo.lock b/approval-gate/Cargo.lock index 46a733694..6101c32c9 100644 --- a/approval-gate/Cargo.lock +++ b/approval-gate/Cargo.lock @@ -545,7 +545,7 @@ dependencies = [ [[package]] name = "harness" -version = "1.5.2" +version = "1.6.1" dependencies = [ "anyhow", "async-trait", diff --git a/approval-gate/iii.worker.yaml b/approval-gate/iii.worker.yaml index be1da10a2..0ccb4618b 100644 --- a/approval-gate/iii.worker.yaml +++ b/approval-gate/iii.worker.yaml @@ -8,7 +8,7 @@ tags: [approval, human-in-the-loop, permissions, policy, security] description: Policy and decision surface for human-held function calls — pre_trigger gate, pending inbox, per-session permission settings, and two notification trigger types. dependencies: - state: "^0.21.2" + state: "^0.21.3" configuration: "^0.21.6" iii-directory: "^1.0.0" session-manager: "^1.0.0" diff --git a/console/web/e2e/ui-send.spec.ts b/console/web/e2e/ui-send.spec.ts index c628f8634..70c35176b 100644 --- a/console/web/e2e/ui-send.spec.ts +++ b/console/web/e2e/ui-send.spec.ts @@ -9,7 +9,8 @@ test('sends and renders a streamed turn through the Console', async ({ const completed = stack.waitForTurnCompleted() await openSession(page, stack) const composer = page.getByLabel('message composer') - await composer.fill(stack.ready.message) + await composer.pressSequentially(stack.ready.message) + await expect(composer).toHaveText(stack.ready.message) await page.getByRole('button', { name: 'send message' }).click() await expect( diff --git a/eval/Cargo.lock b/eval/Cargo.lock new file mode 100644 index 000000000..1e0c7ff72 --- /dev/null +++ b/eval/Cargo.lock @@ -0,0 +1,2603 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "eval" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "harness", + "iii-console-ui", + "iii-helpers", + "iii-sdk", + "schemars", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "harness" +version = "1.6.1" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "globset", + "iii-helpers", + "iii-sdk", + "jsonschema", + "schemars", + "serde", + "serde_json", + "serde_yaml", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "iii-console-ui" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "iii-helpers" +version = "0.21.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0d84d5c149ae4404365a79feca28aa66f6a7dbed56423b4b8c4e2421e0b5add" +dependencies = [ + "futures-util", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "reqwest", + "schemars", + "serde", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "iii-sdk" +version = "0.21.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07dd060fddcc9153b0dd07c038a14cf172ce15ce1d4edb98155563ed55b2caba" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "iii-helpers", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "iso8601" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" +dependencies = [ + "nom", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa0f4bea31643be4c6a678e9aa4ae44f0db9e5609d5ca9dc9083d06eb3e9a27a" +dependencies = [ + "ahash", + "anyhow", + "base64", + "bytecount", + "fancy-regex", + "fraction", + "getrandom 0.2.17", + "iso8601", + "itoa", + "memchr", + "num-cmp", + "once_cell", + "parking_lot", + "percent-encoding", + "regex", + "serde", + "serde_json", + "time", + "url", + "uuid", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.5", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/eval/Cargo.toml b/eval/Cargo.toml new file mode 100644 index 000000000..055b20f1c --- /dev/null +++ b/eval/Cargo.toml @@ -0,0 +1,36 @@ +[workspace] + +[package] +name = "eval" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "eval" +path = "src/main.rs" + +[lib] +name = "eval" +path = "src/lib.rs" + +[dependencies] +harness = { path = "../harness" } +iii-sdk = "=0.21.6" +iii-helpers = "=0.21.6" +iii-console-ui = { path = "../crates/console-ui" } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +clap = { version = "4", features = ["derive", "env"] } +async-trait = "0.1" +schemars = "0.8" +sha2 = "0.10" +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +serde_yaml = "0.9" diff --git a/eval/README.md b/eval/README.md new file mode 100644 index 000000000..fcec889a3 --- /dev/null +++ b/eval/README.md @@ -0,0 +1,79 @@ +# eval + +`eval` compares two prompt or system-prompt variants under the same model and +execution settings. It runs control and treatment through the iii harness, +evaluates each result with an iii function, and reports correctness together +with tokens, cost, latency, function-call, trace, and span metrics. + +## Start an evaluation + +```json +{ + "dimension": "prompt", + "model": { + "model": "codex/gpt-5.6-luna", + "provider": "openai-codex", + "system_prompt_strategy": "override" + }, + "control": { + "label": "baseline", + "prompt": "Reply with exactly OK.", + "system_prompt": "Follow the user request exactly." + }, + "treatment": { + "label": "candidate", + "prompt": "Return exactly the text OK.", + "system_prompt": "Follow the user request exactly." + }, + "evaluator": { + "function_id": "eval::assert::exact", + "arguments": { + "expected": "OK" + } + } +} +``` + +Call `eval::start` with the request. It returns an `evaluation_id` +immediately. Use `eval::status` for occasional progress checks and +`eval::result` for the terminal report, or bind to the `eval::completed` +trigger type. + +Three runs per variant are scheduled by default. Their order alternates by +pair to reduce order bias. A candidate is eligible only when every treatment +run passes and its pass count does not regress against control. Efficiency +metrics are descriptive and never select a winner automatically. + +## Public functions + +- `eval::start` — validate, persist, and enqueue an evaluation. +- `eval::list` — list recent evaluations as lightweight summaries. +- `eval::status` — inspect progress without loading the full report. +- `eval::result` — read the normalized request and terminal comparison report. +- `eval::cancel` — cancel the active harness session and remaining runs. +- `eval::delete` — delete a terminal evaluation and its session indexes. +- `eval::assert::exact` — built-in deep JSON/string equality evaluator. + +Evaluator functions receive the output, `harness::metrics`, run identity, and +caller-supplied arguments. They return `{ passed, score?, reason?, details? }`. +Evaluators should be deterministic and idempotent because durable delivery is +at-least-once. + +## Console UI + +When the console worker is running, `eval` injects an **eval** page at +`#/ext/eval-benchmarks`. The page creates prompt or system-prompt comparisons, +tracks durable progress, restores recent reports after reload, and compares +correctness, token, cost, latency, function-call, trace, and span metrics. + +The model picker reads the live `router::models::list` catalog and falls back +to manual model/provider entry when the catalog is unavailable. Exact-value +evaluation is built in; any evaluator function can also be selected by id with +JSON arguments. Harness policies and output/metadata options remain collapsed +until needed. + +## Boundaries + +The worker intentionally does not implement an agent loop, model router, +metrics collector, trace collector, test DSL, or LLM judge. Those concerns +remain in the harness, engine, and user-provided evaluator functions. diff --git a/eval/build.rs b/eval/build.rs new file mode 100644 index 000000000..705915d28 --- /dev/null +++ b/eval/build.rs @@ -0,0 +1,137 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::SystemTime; + +fn main() { + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").unwrap() + ); + for path in [ + "ui/page.tsx", + "ui/styles.css", + "ui/src", + "ui/build.mjs", + "ui/package.json", + "ui/tsconfig.json", + "../pnpm-lock.yaml", + ] { + println!("cargo:rerun-if-changed={path}"); + } + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let ui_dir = manifest_dir.join("ui"); + let assets = [ui_dir.join("dist/page.js"), ui_dir.join("dist/styles.css")]; + if assets + .iter() + .all(|asset| asset.exists() && dist_is_fresh(asset, &ui_dir)) + { + return; + } + + if std::env::var_os("SKIP_UI_BUILD").is_some() { + for asset in &assets { + assert!( + asset.exists(), + "SKIP_UI_BUILD set but {} is missing", + asset.display() + ); + } + return; + } + + let pnpm = locate_pnpm(); + run(&pnpm, &["install"], &ui_dir); + run(&pnpm, &["build"], &ui_dir); + for asset in &assets { + assert!( + asset.exists(), + "UI build finished but {} is missing", + asset.display() + ); + } +} + +fn run(program: &Path, args: &[&str], directory: &Path) { + let status = Command::new(program) + .args(args) + .current_dir(directory) + .status() + .unwrap_or_else(|error| { + panic!( + "failed to run {} in {}: {error}", + program.display(), + directory.display() + ) + }); + assert!( + status.success(), + "{} exited with {status}", + program.display() + ); +} + +fn dist_is_fresh(asset: &Path, ui_dir: &Path) -> bool { + let Ok(asset_time) = asset.metadata().and_then(|metadata| metadata.modified()) else { + return false; + }; + for path in [ + ui_dir.join("page.tsx"), + ui_dir.join("styles.css"), + ui_dir.join("build.mjs"), + ui_dir.join("package.json"), + ui_dir.join("tsconfig.json"), + ui_dir.join("../../pnpm-lock.yaml"), + ] { + if path.exists() { + match path.metadata().and_then(|metadata| metadata.modified()) { + Ok(modified) if modified <= asset_time => {} + _ => return false, + } + } + } + subtree_is_older(&ui_dir.join("src"), asset_time) +} + +fn subtree_is_older(root: &Path, ceiling: SystemTime) -> bool { + let Ok(entries) = std::fs::read_dir(root) else { + return false; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(metadata) = entry.metadata() else { + return false; + }; + if metadata.is_dir() { + if !subtree_is_older(&path, ceiling) { + return false; + } + } else { + match metadata.modified() { + Ok(modified) if modified <= ceiling => {} + _ => return false, + } + } + } + true +} + +fn locate_pnpm() -> PathBuf { + if let Ok(explicit) = std::env::var("PNPM") { + return PathBuf::from(explicit); + } + let names: &[&str] = if cfg!(windows) { + &["pnpm.cmd", "pnpm.exe", "pnpm"] + } else { + &["pnpm"] + }; + for directory in std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) { + for name in names { + let candidate = directory.join(name); + if candidate.is_file() { + return candidate; + } + } + } + panic!("pnpm not found on PATH; build eval/ui first or set PNPM"); +} diff --git a/eval/iii.worker.yaml b/eval/iii.worker.yaml new file mode 100644 index 000000000..149af3d93 --- /dev/null +++ b/eval/iii.worker.yaml @@ -0,0 +1,15 @@ +iii: v1 +name: eval +language: rust +deploy: binary +manifest: Cargo.toml +bin: eval +tags: [evaluation, benchmark, prompt, system-prompt, ab-test] +description: Compare prompt or system-prompt variants on the same model using durable harness runs and correctness-first reports. + +dependencies: + harness: "^1.5.5" + state: "^0.21.3" + queue: "^0.2.0" + cron: "^0.21.0" + iii-observability: "^0.21.6" diff --git a/eval/src/contract.rs b/eval/src/contract.rs new file mode 100644 index 000000000..2d7f5ca30 --- /dev/null +++ b/eval/src/contract.rs @@ -0,0 +1,619 @@ +use std::collections::BTreeMap; + +use harness::functions::metrics::SessionMetricsResponseV1; +use harness::prompt::{Mode, SystemPromptStrategy}; +use harness::types::model::ThinkingLevel; +use harness::types::output::OutputContract; +use harness::types::turn::FunctionPolicy; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::error::EvalError; +use crate::limits::EvalLimitsV1; +use crate::report::EvalReportV1; + +pub const DEFAULT_RUNS: u32 = 1; +pub const MAX_RUNS: u32 = 20; +pub const DEFAULT_LIST_LIMIT: u32 = 50; +pub const MAX_LIST_LIMIT: u32 = 100; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ComparisonDimensionV1 { + Prompt, + SystemPrompt, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum VariantRoleV1 { + Control, + Treatment, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExecutionOrderV1 { + #[default] + BalancedControlFirst, + BalancedTreatmentFirst, +} + +impl ExecutionOrderV1 { + pub fn roles(self, iteration: u32) -> [VariantRoleV1; 2] { + let control_first = match self { + Self::BalancedControlFirst => iteration % 2 == 1, + Self::BalancedTreatmentFirst => iteration.is_multiple_of(2), + }; + if control_first { + [VariantRoleV1::Control, VariantRoleV1::Treatment] + } else { + [VariantRoleV1::Treatment, VariantRoleV1::Control] + } + } + + pub fn reversed(self) -> Self { + match self { + Self::BalancedControlFirst => Self::BalancedTreatmentFirst, + Self::BalancedTreatmentFirst => Self::BalancedControlFirst, + } + } +} + +impl VariantRoleV1 { + pub fn as_str(self) -> &'static str { + match self { + Self::Control => "control", + Self::Treatment => "treatment", + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalVariantV1 { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + pub prompt: String, + /// `null` disables the system prompt for this variant. An empty string + /// keeps the harness behavior of resolving the provider or built-in prompt. + pub system_prompt: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalModelConfigV1 { + pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default)] + pub system_prompt_strategy: SystemPromptStrategy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thinking_level: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvaluatorSpecV1 { + pub function_id: String, + #[serde(default = "empty_object")] + pub arguments: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct EvalStartRequestV1 { + pub dimension: ComparisonDimensionV1, + pub model: EvalModelConfigV1, + pub control: EvalVariantV1, + pub treatment: EvalVariantV1, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evaluator: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_order: Option, + #[serde(default)] + pub limits: EvalLimitsV1, + /// Shared function policy for both variants. Omitted means deny all. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub functions: Option, + /// Shared output contract for both variants. Omitted means text. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, + /// Shared harness metadata, including an optional filesystem scope. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NormalizedEvalRequestV1 { + pub dimension: ComparisonDimensionV1, + pub model: EvalModelConfigV1, + pub control: EvalVariantV1, + pub treatment: EvalVariantV1, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evaluator: Option, + pub runs: u32, + #[serde(default)] + pub execution_order: ExecutionOrderV1, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_evaluation_id: Option, + pub limits: EvalLimitsV1, + pub functions: FunctionPolicy, + pub output: OutputContract, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +impl EvalStartRequestV1 { + pub fn normalize(self) -> Result { + validate_non_empty("model.model", &self.model.model)?; + if self + .model + .provider + .as_deref() + .is_some_and(|provider| provider.trim().is_empty()) + { + return Err(EvalError::InvalidRequest( + "model.provider cannot be empty when supplied".into(), + )); + } + validate_non_empty("control.prompt", &self.control.prompt)?; + validate_non_empty("treatment.prompt", &self.treatment.prompt)?; + if let Some(evaluator) = &self.evaluator { + validate_non_empty("evaluator.function_id", &evaluator.function_id)?; + } + if self + .control + .label + .as_deref() + .is_some_and(|label| label.trim().is_empty()) + || self + .treatment + .label + .as_deref() + .is_some_and(|label| label.trim().is_empty()) + { + return Err(EvalError::InvalidRequest( + "variant labels cannot be empty when supplied".into(), + )); + } + + match self.dimension { + ComparisonDimensionV1::Prompt => { + if self.control.system_prompt != self.treatment.system_prompt { + return Err(EvalError::InvalidRequest( + "prompt comparison requires identical system prompts".into(), + )); + } + if self.control.prompt == self.treatment.prompt { + return Err(EvalError::InvalidRequest( + "prompt comparison requires different prompts".into(), + )); + } + } + ComparisonDimensionV1::SystemPrompt => { + if self.control.prompt != self.treatment.prompt { + return Err(EvalError::InvalidRequest( + "system_prompt comparison requires identical prompts".into(), + )); + } + if self.control.system_prompt == self.treatment.system_prompt { + return Err(EvalError::InvalidRequest( + "system_prompt comparison requires different system prompts".into(), + )); + } + } + } + + let runs = self.runs.unwrap_or(DEFAULT_RUNS); + if !(1..=MAX_RUNS).contains(&runs) { + return Err(EvalError::InvalidRequest(format!( + "runs must be between 1 and {MAX_RUNS}" + ))); + } + self.limits.validate()?; + + Ok(NormalizedEvalRequestV1 { + dimension: self.dimension, + model: self.model, + control: self.control, + treatment: self.treatment, + evaluator: self.evaluator, + runs, + execution_order: self.execution_order.unwrap_or_default(), + source_evaluation_id: None, + limits: self.limits, + functions: self.functions.unwrap_or_default(), + output: self.output.unwrap_or_default(), + metadata: self.metadata, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum EvalStatusV1 { + Queued, + Running, + Completed, + Failed, + Cancelled, +} + +impl EvalStatusV1 { + pub fn is_terminal(self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum EvalRunStatusV1 { + Pending, + Running, + Completed, + Failed, + Cancelled, +} + +impl EvalRunStatusV1 { + pub fn is_terminal(self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalStartResponseV1 { + pub evaluation_id: String, + pub status: EvalStatusV1, +} + +#[derive(Debug, Clone, Default, Deserialize, JsonSchema)] +pub struct EvalListRequestV1 { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +impl EvalListRequestV1 { + pub fn normalized_limit(&self) -> Result { + let limit = self.limit.unwrap_or(DEFAULT_LIST_LIMIT); + if !(1..=MAX_LIST_LIMIT).contains(&limit) { + return Err(EvalError::InvalidRequest(format!( + "limit must be between 1 and {MAX_LIST_LIMIT}" + ))); + } + Ok(limit as usize) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalSummaryV1 { + pub evaluation_id: String, + pub status: EvalStatusV1, + pub dimension: ComparisonDimensionV1, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub control_label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub treatment_label: Option, + pub total_runs: u32, + pub terminal_runs: u32, + pub passed_runs: u32, + pub failed_runs: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub eligible: Option, + pub created_at: i64, + pub updated_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalListResponseV1 { + pub evaluations: Vec, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct EvaluationIdRequestV1 { + pub evaluation_id: String, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct EvalRerunRequestV1 { + pub evaluation_id: String, + #[serde(default)] + pub reverse_order: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ActiveRunV1 { + pub run_id: String, + pub role: VariantRoleV1, + pub iteration: u32, + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + pub started_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalStatusResponseV1 { + pub evaluation_id: String, + pub status: EvalStatusV1, + pub total_runs: u32, + pub terminal_runs: u32, + pub passed_runs: u32, + pub failed_runs: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub active: Option, + pub created_at: i64, + pub updated_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalResultResponseV1 { + pub status: EvalStatusV1, + pub request: NormalizedEvalRequestV1, + pub progress: crate::report::EvalProgressV1, + #[serde(skip_serializing_if = "Option::is_none")] + pub report: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalCancelResponseV1 { + pub cancelled: bool, + pub status: EvalStatusV1, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalDeleteResponseV1 { + pub deleted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct EvaluatorInputV1 { + pub evaluation_id: String, + pub run_id: String, + pub role: VariantRoleV1, + pub session_id: String, + pub output: Value, + pub metrics: SessionMetricsResponseV1, + #[serde(default = "empty_object")] + pub arguments: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvaluatorResponseV1 { + pub passed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub score: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl EvaluatorResponseV1 { + pub fn validate(self) -> Result { + if self + .score + .is_some_and(|score| !score.is_finite() || !(0.0..=1.0).contains(&score)) + { + return Err(EvalError::InvalidRequest( + "evaluator score must be a finite number between 0 and 1".into(), + )); + } + Ok(self) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct StepRequestV1 { + pub evaluation_id: String, + pub step: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct StepResponseV1 { + pub skipped: bool, + pub status: EvalStatusV1, +} + +#[derive(Debug, Clone, Default, Deserialize, JsonSchema)] +pub struct WakeEventV1 { + #[serde(default)] + pub session_id: String, + #[serde(default)] + pub terminal: bool, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WakeResponseV1 { + pub woke: bool, +} + +#[derive(Debug, Clone, Default, Deserialize, JsonSchema)] +pub struct SweepEventV1 { + #[serde(default)] + pub scheduled_at: Option, + #[serde(default)] + pub scheduled_time: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SweepResponseV1 { + pub swept: u64, +} + +fn validate_non_empty(name: &str, value: &str) -> Result<(), EvalError> { + if value.trim().is_empty() { + Err(EvalError::InvalidRequest(format!("{name} cannot be empty"))) + } else { + Ok(()) + } +} + +fn empty_object() -> Value { + json!({}) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(dimension: ComparisonDimensionV1) -> EvalStartRequestV1 { + EvalStartRequestV1 { + dimension, + model: EvalModelConfigV1 { + model: "model".into(), + provider: Some("provider".into()), + system_prompt_strategy: SystemPromptStrategy::Override, + mode: Some(Mode::Agent), + thinking_level: None, + provider_options: None, + }, + control: EvalVariantV1 { + label: None, + prompt: "control".into(), + system_prompt: Some("system".into()), + }, + treatment: EvalVariantV1 { + label: None, + prompt: "treatment".into(), + system_prompt: Some("system".into()), + }, + evaluator: Some(EvaluatorSpecV1 { + function_id: "eval::assert::exact".into(), + arguments: json!({"expected": "OK"}), + }), + runs: None, + execution_order: None, + limits: EvalLimitsV1::default(), + functions: None, + output: None, + metadata: None, + } + } + + #[test] + fn list_limit_defaults_and_stays_bounded() { + assert_eq!( + EvalListRequestV1::default().normalized_limit().unwrap(), + DEFAULT_LIST_LIMIT as usize + ); + assert_eq!( + EvalListRequestV1 { limit: Some(1) } + .normalized_limit() + .unwrap(), + 1 + ); + assert!(EvalListRequestV1 { limit: Some(0) } + .normalized_limit() + .is_err()); + assert!(EvalListRequestV1 { + limit: Some(MAX_LIST_LIMIT + 1) + } + .normalized_limit() + .is_err()); + } + + #[test] + fn list_request_accepts_engine_metadata() { + let request: EvalListRequestV1 = + serde_json::from_value(json!({"limit": 5, "_caller_worker_id": "console"})).unwrap(); + assert_eq!(request.limit, Some(5)); + } + + #[test] + fn prompt_comparison_changes_only_prompt_and_defaults_to_one_run() { + let normalized = request(ComparisonDimensionV1::Prompt).normalize().unwrap(); + assert_eq!(normalized.runs, 1); + assert_eq!(normalized.functions, FunctionPolicy::default()); + assert_eq!(normalized.output, OutputContract::Text); + } + + #[test] + fn system_prompt_comparison_changes_only_system_prompt() { + let mut request = request(ComparisonDimensionV1::SystemPrompt); + request.treatment.prompt = request.control.prompt.clone(); + request.treatment.system_prompt = Some("new system".into()); + assert!(request.normalize().is_ok()); + } + + #[test] + fn system_prompt_comparison_accepts_default_vs_disabled() { + let mut request = request(ComparisonDimensionV1::SystemPrompt); + request.treatment.prompt = request.control.prompt.clone(); + request.control.system_prompt = Some(String::new()); + request.treatment.system_prompt = None; + assert!(request.normalize().is_ok()); + } + + #[test] + fn rejects_identical_or_two_dimension_changes() { + let mut identical = request(ComparisonDimensionV1::Prompt); + identical.treatment.prompt = identical.control.prompt.clone(); + assert!(identical.normalize().is_err()); + + let mut both = request(ComparisonDimensionV1::Prompt); + both.treatment.system_prompt = Some("different".into()); + assert!(both.normalize().is_err()); + } + + #[test] + fn runs_are_bounded() { + let mut zero = request(ComparisonDimensionV1::Prompt); + zero.runs = Some(0); + assert!(zero.normalize().is_err()); + let mut too_many = request(ComparisonDimensionV1::Prompt); + too_many.runs = Some(MAX_RUNS + 1); + assert!(too_many.normalize().is_err()); + } + + #[test] + fn evaluator_score_is_bounded() { + assert!(EvaluatorResponseV1 { + passed: true, + score: Some(1.0), + reason: None, + details: None, + } + .validate() + .is_ok()); + assert!(EvaluatorResponseV1 { + passed: true, + score: Some(1.1), + reason: None, + details: None, + } + .validate() + .is_err()); + } +} diff --git a/eval/src/error.rs b/eval/src/error.rs new file mode 100644 index 000000000..51fd6259c --- /dev/null +++ b/eval/src/error.rs @@ -0,0 +1,78 @@ +use iii_sdk::errors::Error as SdkError; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum EvalPhaseV1 { + Setup, + Send, + Await, + Collect, + Evaluate, + Limit, + Cancel, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalFailureV1 { + pub phase: EvalPhaseV1, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub function_id: Option, + pub message: String, +} + +impl EvalFailureV1 { + pub fn new(phase: EvalPhaseV1, message: impl Into) -> Self { + Self { + phase, + code: None, + function_id: None, + message: message.into(), + } + } + + pub fn function( + phase: EvalPhaseV1, + function_id: impl Into, + message: impl Into, + ) -> Self { + Self { + phase, + code: None, + function_id: Some(function_id.into()), + message: message.into(), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum EvalError { + #[error("invalid request: {0}")] + InvalidRequest(String), + #[error("evaluation not found: {0}")] + NotFound(String), + #[error("evaluation conflict: {0}")] + Conflict(String), + #[error("dependency error: {0}")] + Dependency(String), + #[error("state error: {0}")] + State(String), + #[error("serialization error: {0}")] + Serialization(String), +} + +impl From for EvalError { + fn from(error: serde_json::Error) -> Self { + Self::Serialization(error.to_string()) + } +} + +impl From for SdkError { + fn from(error: EvalError) -> Self { + SdkError::Handler(error.to_string()) + } +} diff --git a/eval/src/events.rs b/eval/src/events.rs new file mode 100644 index 000000000..3c9fa29ed --- /dev/null +++ b/eval/src/events.rs @@ -0,0 +1,154 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use iii_sdk::errors::Error; +use iii_sdk::protocol::TriggerRequest; +use iii_sdk::trigger::{TriggerConfig, TriggerHandler}; +use iii_sdk::{IIIClient, RegisterTriggerType, TriggerAction}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::contract::EvalStatusV1; + +pub const COMPLETED: &str = "eval::completed"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CompletedBindingConfigV1 { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evaluation_id: Option, +} + +#[derive(Debug, Clone)] +struct Binding { + function_id: String, + evaluation_id: Option, + metadata: Option, +} + +#[derive(Clone, Default)] +struct SubscriberSet { + inner: Arc>>, +} + +impl SubscriberSet { + fn insert(&self, config: TriggerConfig) -> Result<(), String> { + let raw = if config.config.is_null() { + json!({}) + } else { + config.config + }; + let filter: CompletedBindingConfigV1 = serde_json::from_value(raw) + .map_err(|error| format!("invalid eval::completed config: {error}"))?; + self.inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert( + config.id, + Binding { + function_id: config.function_id, + evaluation_id: filter.evaluation_id, + metadata: config.metadata, + }, + ); + Ok(()) + } + + fn remove(&self, id: &str) { + self.inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(id); + } + + fn snapshot(&self) -> Vec { + self.inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .values() + .cloned() + .collect() + } +} + +struct CompletedTriggerHandler { + set: SubscriberSet, +} + +#[async_trait] +impl TriggerHandler for CompletedTriggerHandler { + async fn register_trigger(&self, config: TriggerConfig) -> Result<(), Error> { + self.set.insert(config).map_err(Error::Handler) + } + + async fn unregister_trigger(&self, config: TriggerConfig) -> Result<(), Error> { + self.set.remove(&config.id); + Ok(()) + } +} + +#[derive(Clone)] +pub struct EvalEvents { + iii: Arc, + completed: SubscriberSet, +} + +impl EvalEvents { + pub fn register(iii: &Arc) -> Self { + let completed = SubscriberSet::default(); + let _ = iii.register_trigger_type( + RegisterTriggerType::new( + COMPLETED, + "An evaluation reached a terminal status.", + CompletedTriggerHandler { + set: completed.clone(), + }, + ) + .trigger_request_format::(), + ); + Self { + iii: iii.clone(), + completed, + } + } + + pub async fn emit_completed( + &self, + evaluation_id: &str, + status: EvalStatusV1, + eligible: Option, + ) { + let mut payload = json!({ + "evaluation_id": evaluation_id, + "status": status, + "timestamp": crate::ids::now_ms(), + }); + if let Some(eligible) = eligible { + payload["eligible"] = Value::Bool(eligible); + } + for binding in self.completed.snapshot() { + if binding + .evaluation_id + .as_deref() + .is_some_and(|filter| filter != evaluation_id) + { + continue; + } + let request = TriggerRequest { + function_id: binding.function_id, + payload: payload.clone(), + action: Some(TriggerAction::Void), + timeout_ms: None, + }; + let result = match binding.metadata { + Some(metadata) => self.iii.trigger(request.metadata(metadata)).await, + None => self.iii.trigger(request).await, + }; + if let Err(error) = result { + tracing::warn!(%evaluation_id, %error, "eval::completed delivery failed"); + } + } + } +} diff --git a/eval/src/functions.rs b/eval/src/functions.rs new file mode 100644 index 000000000..417686377 --- /dev/null +++ b/eval/src/functions.rs @@ -0,0 +1,195 @@ +use std::sync::Arc; + +use iii_sdk::errors::Error; +use iii_sdk::{IIIClient, RegisterFunction}; +use serde_json::json; + +use crate::contract::{ + EvalListRequestV1, EvalRerunRequestV1, EvalStartRequestV1, EvaluationIdRequestV1, + EvaluatorInputV1, StepRequestV1, SweepEventV1, WakeEventV1, +}; +use crate::runtime::Deps; + +pub const START_ID: &str = "eval::start"; +pub const LIST_ID: &str = "eval::list"; +pub const RERUN_ID: &str = "eval::rerun"; +pub const STATUS_ID: &str = "eval::status"; +pub const RESULT_ID: &str = "eval::result"; +pub const CANCEL_ID: &str = "eval::cancel"; +pub const DELETE_ID: &str = "eval::delete"; +pub const EXACT_ID: &str = "eval::assert::exact"; +pub const NORMALIZED_TEXT_ID: &str = "eval::assert::normalized_text"; +pub const STEP_ID: &str = "eval::step"; +pub const WAKE_ID: &str = "eval::on-turn-completed"; +pub const SWEEP_ID: &str = "eval::sweep"; + +pub fn register_all(iii: &Arc, deps: &Deps) { + let current = deps.clone(); + iii.register_function( + START_ID, + RegisterFunction::new_async(move |request: EvalStartRequestV1| { + let deps = current.clone(); + async move { + crate::runtime::start(&deps, request) + .await + .map_err(Error::from) + } + }) + .description( + "Start a durable same-model A/B evaluation. Exactly one dimension may change: \ + `prompt` or `system_prompt`. Returns evaluation_id immediately; use eval::status, \ + eval::result, or the eval::completed trigger instead of polling in a tight loop.", + ), + ); + + let current = deps.clone(); + iii.register_function( + RERUN_ID, + RegisterFunction::new_async(move |request: EvalRerunRequestV1| { + let deps = current.clone(); + async move { + crate::runtime::rerun(&deps, request) + .await + .map_err(Error::from) + } + }) + .description( + "Repeat a terminal evaluation from its persisted request. Set reverse_order to invert \ + the balanced A/B order. The rerun receives a new evaluation ID and fresh sessions.", + ), + ); + + let current = deps.clone(); + iii.register_function( + LIST_ID, + RegisterFunction::new_async(move |request: EvalListRequestV1| { + let deps = current.clone(); + async move { + crate::runtime::list(&deps, request) + .await + .map_err(Error::from) + } + }) + .description("List recent evaluations as lightweight summaries, newest first."), + ); + + let current = deps.clone(); + iii.register_function( + STATUS_ID, + RegisterFunction::new_async(move |request: EvaluationIdRequestV1| { + let deps = current.clone(); + async move { + crate::runtime::status(&deps, request) + .await + .map_err(Error::from) + } + }) + .description("Read evaluation progress without loading the complete report."), + ); + + let current = deps.clone(); + iii.register_function( + RESULT_ID, + RegisterFunction::new_async(move |request: EvaluationIdRequestV1| { + let deps = current.clone(); + async move { + crate::runtime::result(&deps, request) + .await + .map_err(Error::from) + } + }) + .description( + "Read an evaluation result. The report is present only after terminal completion.", + ), + ); + + let current = deps.clone(); + iii.register_function( + CANCEL_ID, + RegisterFunction::new_async(move |request: EvaluationIdRequestV1| { + let deps = current.clone(); + async move { + crate::runtime::cancel(&deps, request) + .await + .map_err(Error::from) + } + }) + .description("Cancel an evaluation and its active harness turn."), + ); + + let current = deps.clone(); + iii.register_function( + DELETE_ID, + RegisterFunction::new_async(move |request: EvaluationIdRequestV1| { + let deps = current.clone(); + async move { + crate::runtime::delete(&deps, request) + .await + .map_err(Error::from) + } + }) + .description("Delete a terminal evaluation report and its session indexes."), + ); + + iii.register_function( + EXACT_ID, + RegisterFunction::new_async(move |input: EvaluatorInputV1| async move { + crate::runtime::exact(input).map_err(Error::from) + }) + .description( + "Built-in deterministic evaluator: deep-compare output with arguments.expected.", + ), + ); + + iii.register_function( + NORMALIZED_TEXT_ID, + RegisterFunction::new_async(move |input: EvaluatorInputV1| async move { + crate::runtime::normalized_text(input).map_err(Error::from) + }) + .description( + "Built-in deterministic evaluator: compare text after normalizing case, \ + whitespace, and surrounding punctuation.", + ), + ); + + let current = deps.clone(); + iii.register_function( + STEP_ID, + RegisterFunction::new_async(move |request: StepRequestV1| { + let deps = current.clone(); + async move { + crate::runtime::step(&deps, request) + .await + .map_err(Error::from) + } + }) + .description("Internal durable evaluation step.") + .metadata(json!({ "internal": true, "trace_hidden": true })), + ); + + let current = deps.clone(); + iii.register_function( + WAKE_ID, + RegisterFunction::new_async(move |event: WakeEventV1| { + let deps = current.clone(); + async move { + crate::runtime::wake(&deps, event) + .await + .map_err(Error::from) + } + }) + .description("Internal harness turn-completed wake-up.") + .metadata(json!({ "internal": true, "trace_hidden": true })), + ); + + let current = deps.clone(); + iii.register_function( + SWEEP_ID, + RegisterFunction::new_async(move |_event: SweepEventV1| { + let deps = current.clone(); + async move { crate::runtime::sweep(&deps).await.map_err(Error::from) } + }) + .description("Internal recovery and timeout sweep.") + .metadata(json!({ "internal": true, "trace_hidden": true })), + ); +} diff --git a/eval/src/ids.rs b/eval/src/ids.rs new file mode 100644 index 000000000..4afbd37e7 --- /dev/null +++ b/eval/src/ids.rs @@ -0,0 +1,51 @@ +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::contract::VariantRoleV1; + +pub fn evaluation_id() -> String { + format!("eval_{}", Uuid::new_v4().simple()) +} + +pub fn run_id(role: VariantRoleV1, iteration: u32) -> String { + format!("{}-{iteration}", role.as_str()) +} + +pub fn session_id(evaluation_id: &str, role: VariantRoleV1, iteration: u32) -> String { + let suffix = match role { + VariantRoleV1::Control => "c", + VariantRoleV1::Treatment => "t", + }; + format!("{evaluation_id}_{suffix}_{iteration}") +} + +pub fn send_idempotency_key(evaluation_id: &str, role: VariantRoleV1, iteration: u32) -> String { + format!("eval:{evaluation_id}:{}:{iteration}:send", role.as_str()) +} + +pub fn finalization_idempotency_key( + evaluation_id: &str, + role: VariantRoleV1, + iteration: u32, +) -> String { + format!( + "eval:{evaluation_id}:{}:{iteration}:finalize", + role.as_str() + ) +} + +pub fn sha256_json(value: &serde_json::Value) -> String { + let bytes = serde_json::to_vec(value).unwrap_or_default(); + format!("{:x}", Sha256::digest(bytes)) +} + +pub fn sha256_text(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes())) +} + +pub fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) +} diff --git a/eval/src/lib.rs b/eval/src/lib.rs new file mode 100644 index 000000000..217e01c69 --- /dev/null +++ b/eval/src/lib.rs @@ -0,0 +1,16 @@ +//! Durable A/B evaluation of prompts and system prompts over harness turns. + +pub mod contract; +pub mod error; +pub mod events; +pub mod functions; +pub mod ids; +pub mod limits; +pub mod locks; +pub mod manifest; +pub mod queue; +pub mod report; +pub mod runtime; +pub mod state; +pub mod surface; +pub mod ui; diff --git a/eval/src/limits.rs b/eval/src/limits.rs new file mode 100644 index 000000000..6d45509a0 --- /dev/null +++ b/eval/src/limits.rs @@ -0,0 +1,261 @@ +use std::time::Duration; + +use harness::functions::metrics::SessionMetricsResponseV1; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::error::{EvalError, EvalFailureV1, EvalPhaseV1}; + +pub const DEFAULT_INVOCATION_TIMEOUT_SECONDS: u64 = 120; +pub const DEFAULT_SCENARIO_TIMEOUT_SECONDS: u64 = 600; +pub const DEFAULT_MAX_TURNS: u32 = 1_000; +pub const DEFAULT_MAX_OUTPUT_TOKENS_PER_CALL: u64 = 8_192; +pub const AGENT_QUALITY_DEFAULT_MAX_TOTAL_TOKENS: u64 = 100_000; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalLimitsV1 { + #[serde(default)] + pub execution: ExecutionLimitsV1, + #[serde(default)] + pub evaluation: EvaluationLimitsV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ExecutionLimitsV1 { + #[serde(default = "default_invocation_timeout_seconds")] + pub invocation_timeout_seconds: u64, + #[serde(default = "default_scenario_timeout_seconds")] + pub scenario_timeout_seconds: u64, + #[serde(default = "default_max_turns")] + pub max_turns: u32, + #[serde(default = "default_max_output_tokens_per_call")] + pub max_output_tokens_per_call: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_total_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_cost_usd: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvaluationLimitsV1 { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_function_call_errors: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_error_spans: Option, +} + +impl Default for ExecutionLimitsV1 { + fn default() -> Self { + Self { + invocation_timeout_seconds: default_invocation_timeout_seconds(), + scenario_timeout_seconds: default_scenario_timeout_seconds(), + max_turns: default_max_turns(), + max_output_tokens_per_call: default_max_output_tokens_per_call(), + max_total_tokens: None, + max_cost_usd: None, + } + } +} + +fn default_invocation_timeout_seconds() -> u64 { + DEFAULT_INVOCATION_TIMEOUT_SECONDS +} + +fn default_scenario_timeout_seconds() -> u64 { + DEFAULT_SCENARIO_TIMEOUT_SECONDS +} + +fn default_max_turns() -> u32 { + DEFAULT_MAX_TURNS +} + +fn default_max_output_tokens_per_call() -> u64 { + DEFAULT_MAX_OUTPUT_TOKENS_PER_CALL +} + +impl EvalLimitsV1 { + pub fn validate(self) -> Result<(), EvalError> { + for (name, value) in [ + ( + "limits.execution.invocation_timeout_seconds", + self.execution.invocation_timeout_seconds, + ), + ( + "limits.execution.scenario_timeout_seconds", + self.execution.scenario_timeout_seconds, + ), + ( + "limits.execution.max_output_tokens_per_call", + self.execution.max_output_tokens_per_call, + ), + ] { + if value == 0 { + return Err(EvalError::InvalidRequest(format!( + "{name} must be greater than zero" + ))); + } + } + if self.execution.max_turns == 0 { + return Err(EvalError::InvalidRequest( + "limits.execution.max_turns must be greater than zero".into(), + )); + } + if self.execution.max_total_tokens == Some(0) { + return Err(EvalError::InvalidRequest( + "limits.execution.max_total_tokens must be greater than zero when supplied".into(), + )); + } + if self + .execution + .max_cost_usd + .is_some_and(|value| !value.is_finite() || value < 0.0) + { + return Err(EvalError::InvalidRequest( + "limits.execution.max_cost_usd must be a finite non-negative number".into(), + )); + } + Ok(()) + } + + pub fn failures(self, metrics: &SessionMetricsResponseV1) -> Vec { + let mut failures = Vec::new(); + if let Some(limit) = self.execution.max_total_tokens { + match (metrics.totals.input_tokens, metrics.totals.output_tokens) { + (Some(input), Some(output)) => { + let total = input.saturating_add(output); + if total > limit { + failures.push(EvalFailureV1::new( + EvalPhaseV1::Limit, + format!("total tokens {total} exceeded limit {limit}"), + )); + } + } + _ => failures.push(EvalFailureV1::function( + EvalPhaseV1::Collect, + "harness::metrics", + "total-token validation requires input_tokens and output_tokens", + )), + } + } + + if let Some(limit) = self.execution.max_cost_usd { + match metrics.totals.cost_usd { + Some(cost) if cost > limit => failures.push(EvalFailureV1::new( + EvalPhaseV1::Limit, + format!("cost ${cost:.6} exceeded limit ${limit:.6}"), + )), + Some(_) => {} + None => failures.push(EvalFailureV1::function( + EvalPhaseV1::Collect, + "harness::metrics", + "cost validation requires cost_usd from every model generation", + )), + } + } + + if let Some(limit) = self.evaluation.max_function_call_errors { + if metrics.totals.function_call_errors > limit { + failures.push(EvalFailureV1::new( + EvalPhaseV1::Limit, + format!( + "function call errors {} exceeded limit {limit}", + metrics.totals.function_call_errors + ), + )); + } + } + + if let Some(limit) = self.evaluation.max_error_spans { + match metrics.traces.as_ref() { + Some(traces) if traces.error_span_count > limit => { + failures.push(EvalFailureV1::new( + EvalPhaseV1::Limit, + format!( + "error spans {} exceeded limit {limit}", + traces.error_span_count + ), + )); + } + Some(_) => {} + None => failures.push(EvalFailureV1::function( + EvalPhaseV1::Collect, + "harness::metrics", + "error-span validation requires in-memory observability metrics", + )), + } + } + failures + } +} + +impl ExecutionLimitsV1 { + pub fn invocation_timeout(self) -> Duration { + Duration::from_secs(self.invocation_timeout_seconds) + } + + pub fn scenario_timeout(self) -> Duration { + Duration::from_secs(self.scenario_timeout_seconds) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use harness::functions::metrics::SessionUsageTotalsV1; + + #[test] + fn defaults_leave_optional_tree_budgets_unset() { + let limits = EvalLimitsV1::default(); + assert_eq!(limits.execution.max_turns, 1_000); + assert_eq!(limits.execution.max_output_tokens_per_call, 8_192); + assert_eq!(limits.execution.max_total_tokens, None); + assert_eq!(limits.evaluation.max_function_call_errors, None); + limits.validate().unwrap(); + } + + #[test] + fn partial_limits_resolve_operational_defaults() { + let limits: EvalLimitsV1 = + serde_json::from_value(serde_json::json!({"execution": {}, "evaluation": {}})).unwrap(); + + assert_eq!(limits, EvalLimitsV1::default()); + } + + #[test] + fn function_call_errors_are_only_limited_when_explicitly_configured() { + let metrics = SessionMetricsResponseV1 { + root_session_id: "session".into(), + complete: true, + totals: SessionUsageTotalsV1 { + function_call_errors: 3, + ..Default::default() + }, + by_session: Vec::new(), + traces: None, + }; + + assert!(EvalLimitsV1::default().failures(&metrics).is_empty()); + + let mut limits = EvalLimitsV1::default(); + limits.evaluation.max_function_call_errors = Some(0); + assert_eq!(limits.failures(&metrics).len(), 1); + } + + #[test] + fn validation_rejects_zero_and_invalid_cost() { + let mut limits = EvalLimitsV1::default(); + limits.execution.scenario_timeout_seconds = 0; + assert!(limits.validate().is_err()); + + let mut limits = EvalLimitsV1::default(); + limits.execution.max_total_tokens = Some(0); + assert!(limits.validate().is_err()); + + let mut limits = EvalLimitsV1::default(); + limits.execution.max_cost_usd = Some(f64::NAN); + assert!(limits.validate().is_err()); + } +} diff --git a/eval/src/locks.rs b/eval/src/locks.rs new file mode 100644 index 000000000..b13cb959a --- /dev/null +++ b/eval/src/locks.rs @@ -0,0 +1,29 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex, Weak}; + +use tokio::sync::OwnedMutexGuard; + +#[derive(Clone, Default)] +pub struct EvalLocks { + map: Arc>>>>, +} + +impl EvalLocks { + pub async fn guard(&self, evaluation_id: &str) -> OwnedMutexGuard<()> { + let lock = { + let mut map = self + .map + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match map.get(evaluation_id).and_then(Weak::upgrade) { + Some(lock) => lock, + None => { + let lock = Arc::new(tokio::sync::Mutex::new(())); + map.insert(evaluation_id.to_string(), Arc::downgrade(&lock)); + lock + } + } + }; + lock.lock_owned().await + } +} diff --git a/eval/src/main.rs b/eval/src/main.rs new file mode 100644 index 000000000..e16597e6c --- /dev/null +++ b/eval/src/main.rs @@ -0,0 +1,125 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use clap::Parser; +use iii_helpers::observability::OtelConfig; +use iii_sdk::protocol::RegisterTriggerInput; +use iii_sdk::runtime::WorkerMetadata; +use iii_sdk::trigger::Trigger; +use iii_sdk::{register_worker, InitOptions}; +use serde_json::json; + +use eval::events::EvalEvents; +use eval::locks::EvalLocks; +use eval::runtime::Deps; +use eval::{functions, manifest, queue, state, ui}; + +#[derive(Parser, Debug)] +#[command( + name = "eval", + about = "Durable same-model A/B prompt evaluation over harness turns." +)] +struct Cli { + #[arg(long, env = "III_URL", default_value = "ws://127.0.0.1:49134")] + url: String, + #[arg(long)] + manifest: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + if cli.manifest { + println!( + "{}", + serde_json::to_string_pretty(&manifest::build_manifest()).unwrap() + ); + return Ok(()); + } + + let iii = Arc::new(register_worker( + &cli.url, + InitOptions { + metadata: Some(WorkerMetadata { + runtime: "rust".into(), + version: env!("CARGO_PKG_VERSION").into(), + name: "eval".into(), + os: std::env::consts::OS.into(), + pid: Some(std::process::id()), + telemetry: None, + ..WorkerMetadata::default() + }), + otel: Some(OtelConfig::default()), + ..InitOptions::default() + }, + )); + + let events = EvalEvents::register(&iii); + let deps = Deps { + iii: iii.clone(), + locks: EvalLocks::default(), + events, + }; + functions::register_all(&iii, &deps); + ui::register(&iii); + queue::ensure_run_queue(&iii) + .await + .context("ensuring eval-run queue")?; + + let _trigger_handles = bind_triggers(&iii); + + match state::list_jobs(&iii).await { + Ok(jobs) => { + for job in jobs.iter().filter(|job| !job.status.is_terminal()) { + if let Err(error) = queue::enqueue_step(&iii, &job.evaluation_id, job.step).await { + tracing::warn!( + evaluation_id = %job.evaluation_id, + %error, + "eval startup recovery could not enqueue step" + ); + } + } + } + Err(error) => tracing::warn!(%error, "eval startup recovery could not list jobs"), + } + + tracing::info!("eval ready"); + tokio::signal::ctrl_c().await?; + iii.shutdown_async().await; + Ok(()) +} + +fn bind_triggers(iii: &Arc) -> Vec { + let mut handles = Vec::new(); + for (trigger_type, function_id, config) in [ + ("harness::turn-completed", functions::WAKE_ID, json!({})), + ( + "cron", + functions::SWEEP_ID, + json!({ "expression": "*/15 * * * * *" }), + ), + ] { + match iii.register_trigger(RegisterTriggerInput { + trigger_type: trigger_type.into(), + function_id: function_id.into(), + config, + metadata: None, + }) { + Ok(handle) => handles.push(handle), + Err(error) => tracing::warn!( + trigger_type, + function_id, + %error, + "eval trigger binding failed; recovery remains available on restart" + ), + } + } + handles +} diff --git a/eval/src/manifest.rs b/eval/src/manifest.rs new file mode 100644 index 000000000..c6d16e044 --- /dev/null +++ b/eval/src/manifest.rs @@ -0,0 +1,22 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct ModuleManifest { + pub name: String, + pub version: String, + pub description: String, + pub default_config: serde_json::Value, + pub supported_targets: Vec, +} + +pub fn build_manifest() -> ModuleManifest { + ModuleManifest { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + description: + "Durable same-model A/B evaluation for prompts and system prompts over harness turns." + .to_string(), + default_config: serde_json::json!({}), + supported_targets: vec![env!("TARGET").to_string()], + } +} diff --git a/eval/src/queue.rs b/eval/src/queue.rs new file mode 100644 index 000000000..bc723d548 --- /dev/null +++ b/eval/src/queue.rs @@ -0,0 +1,89 @@ +use std::time::Duration; + +use iii_sdk::protocol::TriggerRequest; +use iii_sdk::{IIIClient, TriggerAction}; +use serde_json::{json, Value}; + +use crate::contract::StepRequestV1; +use crate::error::EvalError; + +pub const RUN_QUEUE: &str = "eval-run"; +const DEFINE_TIMEOUT_MS: u64 = 5_000; +const DEFINE_ATTEMPTS: u32 = 20; +const DEFINE_RETRY_BACKOFF_MS: u64 = 250; + +pub async fn ensure_run_queue(iii: &IIIClient) -> Result<(), EvalError> { + let payload = run_queue_definition(); + let mut last_error = String::new(); + for attempt in 1..=DEFINE_ATTEMPTS { + match iii + .trigger(TriggerRequest { + function_id: "queue::define".into(), + payload: payload.clone(), + action: None, + timeout_ms: Some(DEFINE_TIMEOUT_MS), + }) + .await + { + Ok(_) => return Ok(()), + Err(error) => { + last_error = error.to_string(); + if attempt < DEFINE_ATTEMPTS { + tokio::time::sleep(Duration::from_millis(DEFINE_RETRY_BACKOFF_MS)).await; + } + } + } + } + Err(EvalError::Dependency(format!( + "queue::define failed after {DEFINE_ATTEMPTS} attempts: {last_error}" + ))) +} + +pub async fn enqueue_step( + iii: &IIIClient, + evaluation_id: &str, + step: u64, +) -> Result<(), EvalError> { + let payload = serde_json::to_value(StepRequestV1 { + evaluation_id: evaluation_id.into(), + step, + })?; + iii.trigger(TriggerRequest { + function_id: "eval::step".into(), + payload, + action: Some(TriggerAction::Enqueue { + queue: RUN_QUEUE.into(), + }), + timeout_ms: None, + }) + .await + .map(|_| ()) + .map_err(|error| EvalError::Dependency(format!("enqueue eval::step: {error}"))) +} + +fn run_queue_definition() -> Value { + json!({ + "queue": RUN_QUEUE, + "config": { + "type": "fifo", + "message_group_field": "evaluation_id", + "concurrency": 4, + "max_retries": 3, + "backoff_ms": 1_000, + "poll_interval_ms": 100 + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn eval_queue_is_fifo_and_grouped_by_evaluation() { + let definition = run_queue_definition(); + assert_eq!(definition["queue"], "eval-run"); + assert_eq!(definition["config"]["message_group_field"], "evaluation_id"); + assert_eq!(definition["config"]["type"], "fifo"); + } +} diff --git a/eval/src/report.rs b/eval/src/report.rs new file mode 100644 index 000000000..4bd09430e --- /dev/null +++ b/eval/src/report.rs @@ -0,0 +1,699 @@ +use harness::functions::metrics::SessionMetricsResponseV1; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::contract::{ + ComparisonDimensionV1, EvalModelConfigV1, EvalRunStatusV1, ExecutionOrderV1, + NormalizedEvalRequestV1, VariantRoleV1, +}; +use crate::error::EvalFailureV1; +use crate::ids; +use crate::limits::EvalLimitsV1; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalBenchmarkV1 { + pub wall_time_ms: u64, + pub sessions: u64, + pub turns: u64, + pub function_calls: u64, + pub function_call_errors: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub total_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_read_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_write_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cost_usd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub trace_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub span_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_span_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub trace_duration_ms: Option, +} + +impl EvalBenchmarkV1 { + pub fn from_metrics(metrics: &SessionMetricsResponseV1, wall_time_ms: u64) -> Option { + metrics.complete.then(|| Self { + wall_time_ms, + sessions: metrics.by_session.len() as u64, + turns: metrics.totals.turns, + function_calls: metrics.totals.function_calls, + function_call_errors: metrics.totals.function_call_errors, + input_tokens: metrics.totals.input_tokens, + output_tokens: metrics.totals.output_tokens, + total_tokens: metrics + .totals + .input_tokens + .zip(metrics.totals.output_tokens) + .map(|(input, output)| input.saturating_add(output)), + cache_read_tokens: metrics.totals.cache_read_tokens, + cache_write_tokens: metrics.totals.cache_write_tokens, + reasoning_tokens: metrics.totals.reasoning_tokens, + cost_usd: metrics.totals.cost_usd, + trace_count: metrics.traces.as_ref().map(|traces| traces.trace_count), + span_count: metrics.traces.as_ref().map(|traces| traces.span_count), + error_span_count: metrics + .traces + .as_ref() + .map(|traces| traces.error_span_count), + trace_duration_ms: metrics.traces.as_ref().map(|traces| traces.duration_ms), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalRunReportV1 { + pub run_id: String, + pub role: VariantRoleV1, + pub iteration: u32, + #[serde(default)] + pub execution_position: u32, + #[serde(default)] + pub pair_position: u8, + pub status: EvalRunStatusV1, + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub passed: Option, + pub started_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metrics: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub evaluation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub benchmark: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub failures: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct VariantArtifactV1 { + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + pub prompt_sha256: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt_sha256: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvaluatorArtifactV1 { + pub function_id: String, + pub arguments_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SharedArtifactsV1 { + pub model_sha256: String, + pub function_policy_sha256: String, + pub limits_sha256: String, + pub output_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RoleOrderSensitivityV1 { + pub first_runs: u32, + pub first_passed: u32, + pub second_runs: u32, + pub second_passed: u32, + pub differs: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct OrderSensitivityV1 { + pub detected: bool, + pub control: RoleOrderSensitivityV1, + pub treatment: RoleOrderSensitivityV1, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct VariantAggregateV1 { + pub runs: u32, + pub evaluated_runs: u32, + pub passed: u32, + pub pass_rate: f64, + pub benchmarked_runs: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_score: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_wall_time_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_input_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_total_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_reasoning_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_function_calls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_function_call_errors: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_cost_usd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_trace_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_span_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_error_span_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_trace_duration_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AggregateDeltaV1 { + pub pass_rate: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_score: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_wall_time_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_total_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_reasoning_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_function_calls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_function_call_errors: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_cost_usd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_trace_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_span_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_error_span_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_trace_duration_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalReportV1 { + pub schema_version: String, + pub evaluation_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_evaluation_id: Option, + pub execution_order_policy: ExecutionOrderV1, + pub effective_execution_order: Vec, + pub dimension: ComparisonDimensionV1, + pub model: EvalModelConfigV1, + pub limits: EvalLimitsV1, + #[serde(skip_serializing_if = "Option::is_none")] + pub evaluator: Option, + pub control: VariantArtifactV1, + pub treatment: VariantArtifactV1, + pub shared_artifacts: SharedArtifactsV1, + pub control_aggregate: VariantAggregateV1, + pub treatment_aggregate: VariantAggregateV1, + /// Treatment minus control. Negative efficiency deltas are improvements. + pub delta: AggregateDeltaV1, + pub order_sensitivity: OrderSensitivityV1, + #[serde(skip_serializing_if = "Option::is_none")] + pub eligible: Option, + pub runs: Vec, + pub created_at: i64, + pub completed_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EvalProgressV1 { + pub effective_execution_order: Vec, + pub control_aggregate: VariantAggregateV1, + pub treatment_aggregate: VariantAggregateV1, + pub delta: AggregateDeltaV1, + pub order_sensitivity: OrderSensitivityV1, + pub runs: Vec, +} + +pub fn build_progress(runs: &[EvalRunReportV1]) -> EvalProgressV1 { + let control_aggregate = aggregate(runs, VariantRoleV1::Control); + let treatment_aggregate = aggregate(runs, VariantRoleV1::Treatment); + EvalProgressV1 { + effective_execution_order: effective_order(runs), + delta: delta(&control_aggregate, &treatment_aggregate), + order_sensitivity: order_sensitivity(runs), + control_aggregate, + treatment_aggregate, + runs: runs.to_vec(), + } +} + +pub fn build_report( + evaluation_id: &str, + request: &NormalizedEvalRequestV1, + runs: Vec, + created_at: i64, + completed_at: i64, +) -> EvalReportV1 { + let control_aggregate = aggregate(&runs, VariantRoleV1::Control); + let treatment_aggregate = aggregate(&runs, VariantRoleV1::Treatment); + let eligible = request.evaluator.as_ref().map(|_| { + treatment_aggregate.runs > 0 + && treatment_aggregate.evaluated_runs == treatment_aggregate.runs + && treatment_aggregate.passed == treatment_aggregate.evaluated_runs + && treatment_aggregate.passed >= control_aggregate.passed + }); + let delta = delta(&control_aggregate, &treatment_aggregate); + + EvalReportV1 { + schema_version: "1".into(), + evaluation_id: evaluation_id.into(), + source_evaluation_id: request.source_evaluation_id.clone(), + execution_order_policy: request.execution_order, + effective_execution_order: effective_order(&runs), + dimension: request.dimension, + model: request.model.clone(), + limits: request.limits, + evaluator: request + .evaluator + .as_ref() + .map(|evaluator| EvaluatorArtifactV1 { + function_id: evaluator.function_id.clone(), + arguments_sha256: ids::sha256_json(&evaluator.arguments), + }), + control: artifact(&request.control), + treatment: artifact(&request.treatment), + shared_artifacts: SharedArtifactsV1 { + model_sha256: ids::sha256_json( + &serde_json::to_value(&request.model).unwrap_or_default(), + ), + function_policy_sha256: ids::sha256_json( + &serde_json::to_value(&request.functions).unwrap_or_default(), + ), + limits_sha256: ids::sha256_json( + &serde_json::to_value(request.limits).unwrap_or_default(), + ), + output_sha256: ids::sha256_json( + &serde_json::to_value(&request.output).unwrap_or_default(), + ), + }, + control_aggregate, + treatment_aggregate, + delta, + order_sensitivity: order_sensitivity(&runs), + eligible, + runs, + created_at, + completed_at, + } +} + +pub fn aggregate(runs: &[EvalRunReportV1], role: VariantRoleV1) -> VariantAggregateV1 { + let selected: Vec<_> = runs + .iter() + .filter(|run| run.role == role && run.status.is_terminal()) + .collect(); + let benchmarks: Vec<_> = selected + .iter() + .filter_map(|run| run.benchmark.as_ref()) + .collect(); + let run_count = selected.len() as u32; + let evaluated_runs = selected.iter().filter(|run| run.passed.is_some()).count() as u32; + let passed = selected + .iter() + .filter(|run| run.passed == Some(true)) + .count() as u32; + VariantAggregateV1 { + runs: run_count, + evaluated_runs, + passed, + pass_rate: if evaluated_runs == 0 { + 0.0 + } else { + passed as f64 / evaluated_runs as f64 + }, + benchmarked_runs: benchmarks.len() as u32, + median_score: median_f64( + selected + .iter() + .filter_map(|run| run.evaluation.as_ref()?.score), + ), + median_wall_time_ms: median_u64(benchmarks.iter().map(|value| value.wall_time_ms)), + median_input_tokens: median_u64(benchmarks.iter().filter_map(|value| value.input_tokens)), + median_output_tokens: median_u64(benchmarks.iter().filter_map(|value| value.output_tokens)), + median_total_tokens: median_u64(benchmarks.iter().filter_map(|value| value.total_tokens)), + median_reasoning_tokens: median_u64( + benchmarks.iter().filter_map(|value| value.reasoning_tokens), + ), + median_function_calls: median_u64(benchmarks.iter().map(|value| value.function_calls)), + median_function_call_errors: median_u64( + benchmarks.iter().map(|value| value.function_call_errors), + ), + median_cost_usd: median_f64(benchmarks.iter().filter_map(|value| value.cost_usd)), + median_trace_count: median_u64(benchmarks.iter().filter_map(|value| value.trace_count)), + median_span_count: median_u64(benchmarks.iter().filter_map(|value| value.span_count)), + median_error_span_count: median_u64( + benchmarks.iter().filter_map(|value| value.error_span_count), + ), + median_trace_duration_ms: median_u64( + benchmarks + .iter() + .filter_map(|value| value.trace_duration_ms), + ), + } +} + +fn effective_order(runs: &[EvalRunReportV1]) -> Vec { + runs.iter() + .map(|run| { + format!( + "{}:{}:{}", + run.execution_position, + run.role.as_str(), + run.iteration + ) + }) + .collect() +} + +fn order_sensitivity(runs: &[EvalRunReportV1]) -> OrderSensitivityV1 { + fn role(runs: &[EvalRunReportV1], role: VariantRoleV1) -> RoleOrderSensitivityV1 { + let evaluated: Vec<_> = runs + .iter() + .filter(|run| run.role == role && run.passed.is_some()) + .collect(); + let first: Vec<_> = evaluated + .iter() + .filter(|run| run.pair_position == 1) + .collect(); + let second: Vec<_> = evaluated + .iter() + .filter(|run| run.pair_position == 2) + .collect(); + let first_passed = first.iter().filter(|run| run.passed == Some(true)).count() as u32; + let second_passed = second.iter().filter(|run| run.passed == Some(true)).count() as u32; + let differs = !first.is_empty() + && !second.is_empty() + && first_passed as u64 * second.len() as u64 + != second_passed as u64 * first.len() as u64; + RoleOrderSensitivityV1 { + first_runs: first.len() as u32, + first_passed, + second_runs: second.len() as u32, + second_passed, + differs, + } + } + let control = role(runs, VariantRoleV1::Control); + let treatment = role(runs, VariantRoleV1::Treatment); + OrderSensitivityV1 { + detected: control.differs || treatment.differs, + control, + treatment, + } +} + +fn artifact(variant: &crate::contract::EvalVariantV1) -> VariantArtifactV1 { + VariantArtifactV1 { + label: variant.label.clone(), + prompt_sha256: ids::sha256_text(&variant.prompt), + system_prompt_sha256: variant.system_prompt.as_deref().map(ids::sha256_text), + } +} + +fn delta(control: &VariantAggregateV1, treatment: &VariantAggregateV1) -> AggregateDeltaV1 { + AggregateDeltaV1 { + pass_rate: treatment.pass_rate - control.pass_rate, + median_score: difference_f64(control.median_score, treatment.median_score), + median_wall_time_ms: difference_u64( + control.median_wall_time_ms, + treatment.median_wall_time_ms, + ), + median_total_tokens: difference_u64( + control.median_total_tokens, + treatment.median_total_tokens, + ), + median_reasoning_tokens: difference_u64( + control.median_reasoning_tokens, + treatment.median_reasoning_tokens, + ), + median_function_calls: difference_u64( + control.median_function_calls, + treatment.median_function_calls, + ), + median_function_call_errors: difference_u64( + control.median_function_call_errors, + treatment.median_function_call_errors, + ), + median_cost_usd: difference_f64(control.median_cost_usd, treatment.median_cost_usd), + median_trace_count: difference_u64( + control.median_trace_count, + treatment.median_trace_count, + ), + median_span_count: difference_u64(control.median_span_count, treatment.median_span_count), + median_error_span_count: difference_u64( + control.median_error_span_count, + treatment.median_error_span_count, + ), + median_trace_duration_ms: difference_u64( + control.median_trace_duration_ms, + treatment.median_trace_duration_ms, + ), + } +} + +fn difference_u64(control: Option, treatment: Option) -> Option { + control + .zip(treatment) + .map(|(control, treatment)| treatment as f64 - control as f64) +} + +fn difference_f64(control: Option, treatment: Option) -> Option { + control.zip(treatment).and_then(|(control, treatment)| { + let delta = treatment - control; + delta.is_finite().then_some(delta) + }) +} + +fn median_u64(values: impl IntoIterator) -> Option { + let mut values: Vec<_> = values.into_iter().collect(); + if values.is_empty() { + return None; + } + values.sort_unstable(); + let middle = values.len() / 2; + if values.len() % 2 == 1 { + Some(values[middle]) + } else { + Some(values[middle - 1].saturating_add(values[middle]) / 2) + } +} + +fn median_f64(values: impl IntoIterator) -> Option { + let mut values: Vec<_> = values + .into_iter() + .filter(|value| value.is_finite()) + .collect(); + if values.is_empty() { + return None; + } + values.sort_by(f64::total_cmp); + let middle = values.len() / 2; + if values.len() % 2 == 1 { + Some(values[middle]) + } else { + Some((values[middle - 1] + values[middle]) / 2.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contract::{ + ComparisonDimensionV1, EvalModelConfigV1, EvalVariantV1, EvaluatorResponseV1, + EvaluatorSpecV1, + }; + use harness::prompt::SystemPromptStrategy; + use harness::types::output::OutputContract; + use harness::types::turn::FunctionPolicy; + use serde_json::json; + + fn request(runs: u32) -> NormalizedEvalRequestV1 { + NormalizedEvalRequestV1 { + dimension: ComparisonDimensionV1::Prompt, + model: EvalModelConfigV1 { + model: "model".into(), + provider: Some("provider".into()), + system_prompt_strategy: SystemPromptStrategy::Override, + mode: None, + thinking_level: None, + provider_options: None, + }, + control: EvalVariantV1 { + label: None, + prompt: "old".into(), + system_prompt: Some("system".into()), + }, + treatment: EvalVariantV1 { + label: None, + prompt: "new".into(), + system_prompt: Some("system".into()), + }, + evaluator: Some(EvaluatorSpecV1 { + function_id: "judge".into(), + arguments: json!({}), + }), + runs, + execution_order: ExecutionOrderV1::default(), + source_evaluation_id: None, + limits: EvalLimitsV1::default(), + functions: FunctionPolicy::default(), + output: OutputContract::Text, + metadata: None, + } + } + + fn run(role: VariantRoleV1, iteration: u32, passed: bool, tokens: u64) -> EvalRunReportV1 { + EvalRunReportV1 { + run_id: format!("{}-{iteration}", role.as_str()), + role, + iteration, + execution_position: 1, + pair_position: 1, + status: EvalRunStatusV1::Completed, + session_id: "session".into(), + turn_id: Some("turn".into()), + passed: Some(passed), + started_at: 1, + completed_at: Some(2), + output: Some(json!("OK")), + metrics: None, + evaluation: Some(EvaluatorResponseV1 { + passed, + score: Some(if passed { 1.0 } else { 0.0 }), + reason: None, + details: None, + }), + benchmark: Some(EvalBenchmarkV1 { + wall_time_ms: tokens, + sessions: 1, + turns: 1, + function_calls: 0, + function_call_errors: 0, + input_tokens: Some(tokens), + output_tokens: Some(1), + total_tokens: Some(tokens), + cache_read_tokens: None, + cache_write_tokens: None, + reasoning_tokens: None, + cost_usd: None, + trace_count: None, + span_count: None, + error_span_count: None, + trace_duration_ms: None, + }), + failures: Vec::new(), + } + } + + #[test] + fn medians_and_treatment_minus_control_deltas_are_reported() { + let runs = vec![ + run(VariantRoleV1::Control, 1, true, 10), + run(VariantRoleV1::Treatment, 1, true, 8), + run(VariantRoleV1::Control, 2, true, 20), + run(VariantRoleV1::Treatment, 2, true, 12), + run(VariantRoleV1::Control, 3, true, 30), + run(VariantRoleV1::Treatment, 3, true, 16), + ]; + let report = build_report("eval-1", &request(3), runs, 1, 2); + assert_eq!(report.eligible, Some(true)); + assert_eq!(report.control_aggregate.median_total_tokens, Some(20)); + assert_eq!(report.treatment_aggregate.median_total_tokens, Some(12)); + assert_eq!(report.delta.median_total_tokens, Some(-8.0)); + } + + #[test] + fn treatment_must_pass_every_run() { + let runs = vec![ + run(VariantRoleV1::Control, 1, true, 10), + run(VariantRoleV1::Treatment, 1, true, 10), + run(VariantRoleV1::Control, 2, false, 10), + run(VariantRoleV1::Treatment, 2, false, 10), + ]; + assert_eq!( + build_report("eval-1", &request(2), runs, 1, 2).eligible, + Some(false) + ); + } + + #[test] + fn null_outputs_are_not_included_in_pass_rate() { + let mut control = run(VariantRoleV1::Control, 1, false, 10); + control.output = Some(Value::Null); + control.passed = None; + control.evaluation = None; + let mut treatment = run(VariantRoleV1::Treatment, 1, false, 10); + treatment.output = Some(Value::Null); + treatment.passed = None; + treatment.evaluation = None; + + let report = build_report("eval-1", &request(1), vec![control, treatment], 1, 2); + assert_eq!(report.control_aggregate.evaluated_runs, 0); + assert_eq!(report.treatment_aggregate.evaluated_runs, 0); + assert_eq!(report.control_aggregate.passed, 0); + assert_eq!(report.eligible, Some(false)); + } + + #[test] + fn report_without_evaluator_has_no_eligibility_decision() { + let mut request = request(1); + request.evaluator = None; + let report = build_report( + "eval-1", + &request, + vec![ + run(VariantRoleV1::Control, 1, false, 10), + run(VariantRoleV1::Treatment, 1, false, 10), + ], + 1, + 2, + ); + assert_eq!(report.evaluator, None); + assert_eq!(report.eligible, None); + } + + #[test] + fn progress_uses_completed_runs_and_detects_order_sensitivity() { + let mut control_first = run(VariantRoleV1::Control, 1, true, 10); + control_first.pair_position = 1; + let mut control_second = run(VariantRoleV1::Control, 2, false, 20); + control_second.pair_position = 2; + let mut pending = run(VariantRoleV1::Treatment, 2, true, 30); + pending.status = EvalRunStatusV1::Running; + pending.passed = None; + + let progress = build_progress(&[control_first, control_second, pending]); + assert_eq!(progress.control_aggregate.runs, 2); + assert_eq!(progress.treatment_aggregate.runs, 0); + assert!(progress.order_sensitivity.detected); + assert!(progress.order_sensitivity.control.differs); + } +} diff --git a/eval/src/runtime.rs b/eval/src/runtime.rs new file mode 100644 index 000000000..cca0cdd4a --- /dev/null +++ b/eval/src/runtime.rs @@ -0,0 +1,1053 @@ +use std::sync::Arc; + +use harness::functions::metrics::{SessionMetricsRequestV1, SessionMetricsResponseV1}; +use harness::functions::send::{MessageInput, SendOptions, SendRequest, SendResponse, SessionInit}; +use harness::functions::session_tree::{SessionTreeRequestV1, SessionTreeResponseV1}; +use harness::functions::status::{StatusReport, StatusRequest}; +use harness::functions::stop::{StopRequest, StopResponse}; +use harness::functions::teardown::{TeardownRequestV1, TeardownResponseV1}; +use harness::types::turn::TurnStatus; +use iii_sdk::protocol::TriggerRequest; +use iii_sdk::IIIClient; +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::contract::{ + EvalCancelResponseV1, EvalDeleteResponseV1, EvalListRequestV1, EvalListResponseV1, + EvalRerunRequestV1, EvalResultResponseV1, EvalRunStatusV1, EvalStartRequestV1, + EvalStartResponseV1, EvalStatusV1, EvaluationIdRequestV1, EvaluatorInputV1, + EvaluatorResponseV1, NormalizedEvalRequestV1, StepRequestV1, StepResponseV1, SweepResponseV1, + VariantRoleV1, WakeEventV1, WakeResponseV1, +}; +use crate::error::{EvalError, EvalFailureV1, EvalPhaseV1}; +use crate::events::EvalEvents; +use crate::locks::EvalLocks; +use crate::report::{build_progress, build_report, EvalBenchmarkV1}; +use crate::state::{EvalJobRecordV1, SessionIndexV1}; +use crate::{ids, queue, state}; + +#[derive(Clone)] +pub struct Deps { + pub iii: Arc, + pub locks: EvalLocks, + pub events: EvalEvents, +} + +pub async fn start( + deps: &Deps, + request: EvalStartRequestV1, +) -> Result { + let request = request.normalize()?; + create_job(deps, request).await +} + +async fn create_job( + deps: &Deps, + request: NormalizedEvalRequestV1, +) -> Result { + let evaluation_id = ids::evaluation_id(); + let now = ids::now_ms(); + let job = EvalJobRecordV1 { + schema_version: "1".into(), + evaluation_id: evaluation_id.clone(), + runs: state::build_run_plan(&evaluation_id, &request), + request, + status: EvalStatusV1::Queued, + step: 0, + next_index: 0, + active_index: None, + active_waited_for_descendants: false, + active_finalization_sent: false, + report: None, + error: None, + created_at: now, + updated_at: now, + completed_at: None, + }; + state::put_job(&deps.iii, &job).await?; + queue::enqueue_step(&deps.iii, &evaluation_id, 0).await?; + Ok(EvalStartResponseV1 { + evaluation_id, + status: EvalStatusV1::Queued, + }) +} + +pub async fn rerun( + deps: &Deps, + request: EvalRerunRequestV1, +) -> Result { + let source = state::get_job(&deps.iii, &request.evaluation_id) + .await? + .ok_or_else(|| EvalError::NotFound(request.evaluation_id.clone()))?; + if !source.status.is_terminal() { + return Err(EvalError::Conflict(format!( + "{} is still {:?}; rerun it after completion or cancellation", + source.evaluation_id, source.status + ))); + } + let mut persisted = source.request.clone(); + persisted.source_evaluation_id = Some(source.evaluation_id); + if request.reverse_order { + persisted.execution_order = persisted.execution_order.reversed(); + } + create_job(deps, persisted).await +} + +pub async fn status( + deps: &Deps, + request: EvaluationIdRequestV1, +) -> Result, EvalError> { + Ok(state::get_job(&deps.iii, &request.evaluation_id) + .await? + .map(|job| job.status_response())) +} + +pub async fn list( + deps: &Deps, + request: EvalListRequestV1, +) -> Result { + let limit = request.normalized_limit()?; + let mut jobs = state::list_jobs(&deps.iii).await?; + jobs.sort_by(|left, right| { + right + .created_at + .cmp(&left.created_at) + .then_with(|| right.evaluation_id.cmp(&left.evaluation_id)) + }); + Ok(EvalListResponseV1 { + evaluations: jobs + .into_iter() + .take(limit) + .map(|job| job.summary_response()) + .collect(), + }) +} + +pub async fn result( + deps: &Deps, + request: EvaluationIdRequestV1, +) -> Result, EvalError> { + Ok(state::get_job(&deps.iii, &request.evaluation_id) + .await? + .map(|job| EvalResultResponseV1 { + status: job.status, + progress: build_progress(&job.runs), + request: job.request, + report: job.report, + })) +} + +pub async fn cancel( + deps: &Deps, + request: EvaluationIdRequestV1, +) -> Result { + let _guard = deps.locks.guard(&request.evaluation_id).await; + let mut job = state::get_job(&deps.iii, &request.evaluation_id) + .await? + .ok_or_else(|| EvalError::NotFound(request.evaluation_id.clone()))?; + if job.status.is_terminal() { + return Ok(EvalCancelResponseV1 { + cancelled: false, + status: job.status, + }); + } + + if let Some(index) = job.active_index { + if let Some(run) = job.runs.get(index) { + stop_harness_tree(deps, &run.session_id, &job).await; + let _ = harness_teardown(deps, &run.session_id, &job).await; + let _ = state::delete_session_index(&deps.iii, &run.session_id).await; + } + } + + let now = ids::now_ms(); + for run in &mut job.runs { + if !run.status.is_terminal() { + run.status = EvalRunStatusV1::Cancelled; + run.completed_at = Some(now); + run.failures.push(EvalFailureV1::new( + EvalPhaseV1::Cancel, + "evaluation cancelled", + )); + } + } + job.active_index = None; + job.status = EvalStatusV1::Cancelled; + job.updated_at = now; + job.completed_at = Some(now); + job.report = Some(build_report( + &job.evaluation_id, + &job.request, + job.runs.clone(), + job.created_at, + now, + )); + state::put_job(&deps.iii, &job).await?; + deps.events + .emit_completed(&job.evaluation_id, job.status, Some(false)) + .await; + Ok(EvalCancelResponseV1 { + cancelled: true, + status: job.status, + }) +} + +pub async fn delete( + deps: &Deps, + request: EvaluationIdRequestV1, +) -> Result { + let _guard = deps.locks.guard(&request.evaluation_id).await; + let Some(job) = state::get_job(&deps.iii, &request.evaluation_id).await? else { + return Ok(EvalDeleteResponseV1 { deleted: false }); + }; + if !job.status.is_terminal() { + return Err(EvalError::Conflict(format!( + "{} is still {:?}; cancel or wait before deleting it", + job.evaluation_id, job.status + ))); + } + for run in &job.runs { + state::delete_session_index(&deps.iii, &run.session_id).await?; + } + state::delete_job(&deps.iii, &job.evaluation_id).await?; + Ok(EvalDeleteResponseV1 { deleted: true }) +} + +pub fn exact(input: EvaluatorInputV1) -> Result { + let expected = input.arguments.get("expected").ok_or_else(|| { + EvalError::InvalidRequest( + "eval::assert::exact requires evaluator arguments.expected".into(), + ) + })?; + let passed = &input.output == expected; + Ok(EvaluatorResponseV1 { + passed, + score: Some(if passed { 1.0 } else { 0.0 }), + reason: Some(if passed { + "output exactly matched arguments.expected".into() + } else { + format!( + "output did not exactly match arguments.expected (actual={}, expected={})", + compact(&input.output), + compact(expected) + ) + }), + details: None, + }) +} + +pub fn normalized_text(input: EvaluatorInputV1) -> Result { + let expected = input + .arguments + .get("expected") + .and_then(Value::as_str) + .ok_or_else(|| { + EvalError::InvalidRequest( + "eval::assert::normalized_text requires string evaluator arguments.expected".into(), + ) + })?; + let Some(actual) = input.output.as_str() else { + return Ok(EvaluatorResponseV1 { + passed: false, + score: Some(0.0), + reason: Some(format!( + "output was not text (actual={}, expected={})", + compact(&input.output), + compact(&Value::String(expected.into())) + )), + details: None, + }); + }; + let actual_normalized = normalize_text(actual); + let expected_normalized = normalize_text(expected); + let passed = actual_normalized == expected_normalized; + Ok(EvaluatorResponseV1 { + passed, + score: Some(if passed { 1.0 } else { 0.0 }), + reason: Some(if passed { + "normalized output matched arguments.expected".into() + } else { + format!( + "normalized output did not match arguments.expected (actual={}, expected={})", + compact(&Value::String(actual_normalized)), + compact(&Value::String(expected_normalized)) + ) + }), + details: None, + }) +} + +fn normalize_text(value: &str) -> String { + value + .split_whitespace() + .collect::>() + .join(" ") + .trim_matches(|character: char| character.is_ascii_punctuation()) + .to_lowercase() +} + +pub async fn wake(deps: &Deps, event: WakeEventV1) -> Result { + if !event.terminal || !event.session_id.starts_with("eval_") { + return Ok(WakeResponseV1 { woke: false }); + } + let Some(index) = state::get_session_index(&deps.iii, &event.session_id).await? else { + return Ok(WakeResponseV1 { woke: false }); + }; + let Some(job) = state::get_job(&deps.iii, &index.evaluation_id).await? else { + return Ok(WakeResponseV1 { woke: false }); + }; + if job.status.is_terminal() { + return Ok(WakeResponseV1 { woke: false }); + } + queue::enqueue_step(&deps.iii, &job.evaluation_id, job.step).await?; + Ok(WakeResponseV1 { woke: true }) +} + +pub async fn sweep(deps: &Deps) -> Result { + let jobs = state::list_jobs(&deps.iii).await?; + let mut swept = 0; + for job in jobs.iter().filter(|job| !job.status.is_terminal()) { + match queue::enqueue_step(&deps.iii, &job.evaluation_id, job.step).await { + Ok(()) => swept += 1, + Err(error) => { + tracing::warn!( + evaluation_id = %job.evaluation_id, + %error, + "eval recovery sweep could not enqueue step" + ); + } + } + } + Ok(SweepResponseV1 { swept }) +} + +pub async fn step(deps: &Deps, request: StepRequestV1) -> Result { + let _guard = deps.locks.guard(&request.evaluation_id).await; + let Some(mut job) = state::get_job(&deps.iii, &request.evaluation_id).await? else { + return Ok(StepResponseV1 { + skipped: true, + status: EvalStatusV1::Failed, + }); + }; + if job.status.is_terminal() || request.step != job.step { + return Ok(StepResponseV1 { + skipped: true, + status: job.status, + }); + } + + if job.status == EvalStatusV1::Queued { + job.status = EvalStatusV1::Running; + job.updated_at = ids::now_ms(); + state::put_job(&deps.iii, &job).await?; + } + + if let Some(index) = job.active_index { + if run_timed_out(&job, index) { + time_out_active(deps, &mut job, index).await?; + return advance(deps, job).await; + } + if job.runs[index].turn_id.is_none() { + launch_active(deps, &mut job, index).await?; + if job.runs[index].status.is_terminal() { + return advance(deps, job).await; + } + return Ok(StepResponseV1 { + skipped: false, + status: job.status, + }); + } + match reconcile_active(deps, &mut job, index).await? { + ReconcileOutcome::StillRunning => { + return Ok(StepResponseV1 { + skipped: false, + status: job.status, + }) + } + ReconcileOutcome::Terminal => return advance(deps, job).await, + } + } + + if job.next_index < job.runs.len() { + let index = job.next_index; + job.next_index += 1; + job.active_index = Some(index); + job.runs[index].status = EvalRunStatusV1::Running; + job.runs[index].started_at = ids::now_ms(); + job.updated_at = ids::now_ms(); + let session_index = SessionIndexV1 { + evaluation_id: job.evaluation_id.clone(), + run_id: job.runs[index].run_id.clone(), + }; + state::put_session_index(&deps.iii, &job.runs[index].session_id, &session_index).await?; + state::put_job(&deps.iii, &job).await?; + launch_active(deps, &mut job, index).await?; + if job.runs[index].status.is_terminal() { + return advance(deps, job).await; + } + return Ok(StepResponseV1 { + skipped: false, + status: job.status, + }); + } + + finalize(deps, &mut job).await?; + Ok(StepResponseV1 { + skipped: false, + status: job.status, + }) +} + +async fn launch_active( + deps: &Deps, + job: &mut EvalJobRecordV1, + index: usize, +) -> Result<(), EvalError> { + let run = job.runs[index].clone(); + let variant = match run.role { + VariantRoleV1::Control => &job.request.control, + VariantRoleV1::Treatment => &job.request.treatment, + }; + let request = SendRequest { + session_id: Some(run.session_id.clone()), + message: MessageInput::Text(variant.prompt.clone()), + model: Some(job.request.model.model.clone()), + provider: job.request.model.provider.clone(), + idempotency_key: Some(ids::send_idempotency_key( + &job.evaluation_id, + run.role, + run.iteration, + )), + session: Some(SessionInit { + title: Some(format!( + "Evaluation {}: {} {}", + job.evaluation_id, + run.role.as_str(), + run.iteration + )), + metadata: Some(json!({ + "evaluation_id": job.evaluation_id, + "eval_run_id": run.run_id, + "eval_role": run.role, + "eval_iteration": run.iteration, + })), + }), + options: Some(send_options(job, variant)), + }; + + let response: Result = trigger( + deps, + "harness::send", + request, + job.request.limits.execution.invocation_timeout_seconds, + ) + .await; + match response { + Ok(response) if response.accepted && response.session_id == job.runs[index].session_id => { + job.runs[index].turn_id = Some(response.turn_id); + job.updated_at = ids::now_ms(); + state::put_job(&deps.iii, job).await + } + Ok(response) => { + fail_run( + &mut job.runs[index], + EvalRunStatusV1::Failed, + EvalFailureV1::function( + EvalPhaseV1::Send, + "harness::send", + format!( + "unexpected response: accepted={}, session_id={}", + response.accepted, response.session_id + ), + ), + ); + state::put_job(&deps.iii, job).await + } + Err(error) => { + // If the response was lost after acceptance, the deterministic + // session may already exist. Preserve it for reconciliation. + let status = harness_status(deps, &job.runs[index].session_id, job).await?; + if let Some(status) = status { + job.runs[index].turn_id = status.turn_id; + state::put_job(&deps.iii, job).await + } else { + fail_run( + &mut job.runs[index], + EvalRunStatusV1::Failed, + EvalFailureV1::function(EvalPhaseV1::Send, "harness::send", error.to_string()), + ); + state::put_job(&deps.iii, job).await + } + } + } +} + +enum ReconcileOutcome { + StillRunning, + Terminal, +} + +async fn reconcile_active( + deps: &Deps, + job: &mut EvalJobRecordV1, + index: usize, +) -> Result { + let session_id = job.runs[index].session_id.clone(); + let Some(status) = harness_status(deps, &session_id, job).await? else { + return Ok(ReconcileOutcome::StillRunning); + }; + if !status.status.is_terminal() { + return Ok(ReconcileOutcome::StillRunning); + } + + let now = ids::now_ms(); + let wall_time_ms = elapsed_ms(job.runs[index].started_at, now); + let metrics = harness_metrics(deps, &session_id, job).await?; + if !metrics.complete { + if !job.active_waited_for_descendants { + job.active_waited_for_descendants = true; + job.updated_at = now; + state::put_job(&deps.iii, job).await?; + } + return Ok(ReconcileOutcome::StillRunning); + } + + if needs_finalization( + status.status, + status.result_error.is_none(), + status.expects_wake, + job.active_waited_for_descendants, + job.active_finalization_sent, + ) { + send_finalization(deps, job, index).await?; + return Ok(ReconcileOutcome::StillRunning); + } + + let output = status.result.clone().unwrap_or(Value::Null); + job.runs[index].output = Some(output.clone()); + job.runs[index].completed_at = Some(now); + match status.status { + TurnStatus::Completed if status.result_error.is_none() => { + let limit_failures = job.request.limits.failures(&metrics); + job.runs[index].benchmark = EvalBenchmarkV1::from_metrics(&metrics, wall_time_ms); + job.runs[index].metrics = Some(metrics.clone()); + if output.is_null() || job.request.evaluator.is_none() { + job.runs[index].passed = None; + job.runs[index].failures.extend(limit_failures); + job.runs[index].status = EvalRunStatusV1::Completed; + } else { + let evaluator = job + .request + .evaluator + .clone() + .expect("evaluator checked above"); + let evaluator_input = EvaluatorInputV1 { + evaluation_id: job.evaluation_id.clone(), + run_id: job.runs[index].run_id.clone(), + role: job.runs[index].role, + session_id: session_id.clone(), + output, + metrics: metrics.clone(), + arguments: evaluator.arguments, + }; + let evaluation: Result = trigger( + deps, + &evaluator.function_id, + evaluator_input, + job.request.limits.execution.invocation_timeout_seconds, + ) + .await; + match evaluation.and_then(EvaluatorResponseV1::validate) { + Ok(evaluation) => { + job.runs[index].passed = + Some(evaluation.passed && limit_failures.is_empty()); + job.runs[index].evaluation = Some(evaluation); + job.runs[index].failures.extend(limit_failures); + job.runs[index].status = EvalRunStatusV1::Completed; + } + Err(error) => { + fail_run( + &mut job.runs[index], + EvalRunStatusV1::Failed, + EvalFailureV1::function( + EvalPhaseV1::Evaluate, + evaluator.function_id, + error.to_string(), + ), + ); + } + } + } + } + TurnStatus::Cancelled => { + store_metrics(job, index, metrics, wall_time_ms); + fail_run( + &mut job.runs[index], + EvalRunStatusV1::Cancelled, + EvalFailureV1::function( + EvalPhaseV1::Await, + "harness::status", + status + .result_error + .or_else(|| Some("harness run was cancelled".into())) + .unwrap_or_default(), + ), + ); + } + _ => { + store_metrics(job, index, metrics, wall_time_ms); + fail_run( + &mut job.runs[index], + EvalRunStatusV1::Failed, + EvalFailureV1::function( + EvalPhaseV1::Await, + "harness::status", + status + .result_error + .unwrap_or_else(|| format!("harness run ended as {:?}", status.status)), + ), + ); + } + } + job.updated_at = now; + state::put_job(&deps.iii, job).await?; + Ok(ReconcileOutcome::Terminal) +} + +fn needs_finalization( + status: TurnStatus, + result_ok: bool, + expects_wake: bool, + waited_for_descendants: bool, + finalization_sent: bool, +) -> bool { + status == TurnStatus::Completed + && result_ok + && (expects_wake || waited_for_descendants) + && !finalization_sent +} + +async fn send_finalization( + deps: &Deps, + job: &mut EvalJobRecordV1, + index: usize, +) -> Result<(), EvalError> { + let run = job.runs[index].clone(); + let variant = match run.role { + VariantRoleV1::Control => &job.request.control, + VariantRoleV1::Treatment => &job.request.treatment, + }; + let response: SendResponse = trigger( + deps, + "harness::send", + SendRequest { + session_id: Some(run.session_id.clone()), + message: MessageInput::Text( + "[eval-finalize] All descendant sessions are now terminal. Inspect the durable \ + outcomes, re-check every completion condition, finish any required final report, \ + remove every trigger or subscription created by this run, and return the \ + definitive result. Do not return another progress update or start unrelated work." + .into(), + ), + model: Some(job.request.model.model.clone()), + provider: job.request.model.provider.clone(), + idempotency_key: Some(ids::finalization_idempotency_key( + &job.evaluation_id, + run.role, + run.iteration, + )), + session: None, + options: Some(send_options(job, variant)), + }, + job.request.limits.execution.invocation_timeout_seconds, + ) + .await?; + if !response.accepted || response.session_id != run.session_id { + return Err(EvalError::Dependency(format!( + "harness::send finalization returned accepted={}, session_id={}", + response.accepted, response.session_id + ))); + } + job.runs[index].turn_id = Some(response.turn_id); + job.active_finalization_sent = true; + job.updated_at = ids::now_ms(); + state::put_job(&deps.iii, job).await +} + +fn send_options(job: &EvalJobRecordV1, variant: &crate::contract::EvalVariantV1) -> SendOptions { + SendOptions { + system_prompt: variant.system_prompt.clone(), + system_prompt_strategy: if variant.system_prompt.is_none() { + harness::prompt::SystemPromptStrategy::Disabled + } else { + job.request.model.system_prompt_strategy + }, + mode: job.request.model.mode, + max_turns: Some(job.request.limits.execution.max_turns), + max_output_tokens: Some(job.request.limits.execution.max_output_tokens_per_call), + max_total_tokens: job.request.limits.execution.max_total_tokens, + max_cost_usd: job.request.limits.execution.max_cost_usd, + thinking_level: job.request.model.thinking_level, + provider_options: job.request.model.provider_options.clone(), + output: Some(job.request.output.clone()), + functions: Some(job.request.functions.clone()), + metadata: job.request.metadata.clone(), + } +} + +fn store_metrics( + job: &mut EvalJobRecordV1, + index: usize, + metrics: SessionMetricsResponseV1, + wall_time_ms: u64, +) { + job.runs[index].benchmark = EvalBenchmarkV1::from_metrics(&metrics, wall_time_ms); + job.runs[index].metrics = Some(metrics); +} + +async fn time_out_active( + deps: &Deps, + job: &mut EvalJobRecordV1, + index: usize, +) -> Result<(), EvalError> { + let run = job.runs[index].clone(); + stop_harness_tree(deps, &run.session_id, job).await; + fail_run( + &mut job.runs[index], + EvalRunStatusV1::Failed, + EvalFailureV1::new( + EvalPhaseV1::Await, + format!( + "run exceeded {}s wall-clock limit", + job.request.limits.execution.scenario_timeout_seconds + ), + ), + ); + state::put_job(&deps.iii, job).await +} + +async fn advance(deps: &Deps, mut job: EvalJobRecordV1) -> Result { + if let Some(index) = job.active_index.take() { + let session_id = job.runs[index].session_id.clone(); + if let Err(error) = harness_teardown(deps, &session_id, &job).await { + job.runs[index].failures.push(EvalFailureV1::function( + EvalPhaseV1::Collect, + "harness::teardown", + error.to_string(), + )); + } + state::delete_session_index(&deps.iii, &job.runs[index].session_id).await?; + } + job.active_waited_for_descendants = false; + job.active_finalization_sent = false; + job.step = job.step.saturating_add(1); + job.updated_at = ids::now_ms(); + if job.next_index >= job.runs.len() { + finalize(deps, &mut job).await?; + } else { + state::put_job(&deps.iii, &job).await?; + queue::enqueue_step(&deps.iii, &job.evaluation_id, job.step).await?; + } + Ok(StepResponseV1 { + skipped: false, + status: job.status, + }) +} + +async fn finalize(deps: &Deps, job: &mut EvalJobRecordV1) -> Result<(), EvalError> { + let now = ids::now_ms(); + job.status = EvalStatusV1::Completed; + job.updated_at = now; + job.completed_at = Some(now); + job.report = Some(build_report( + &job.evaluation_id, + &job.request, + job.runs.clone(), + job.created_at, + now, + )); + state::put_job(&deps.iii, job).await?; + let eligible = job.report.as_ref().and_then(|report| report.eligible); + deps.events + .emit_completed(&job.evaluation_id, job.status, eligible) + .await; + Ok(()) +} + +fn fail_run( + run: &mut crate::report::EvalRunReportV1, + status: EvalRunStatusV1, + failure: EvalFailureV1, +) { + run.status = status; + run.passed = None; + run.completed_at = Some(ids::now_ms()); + run.failures.push(failure); +} + +fn run_timed_out(job: &EvalJobRecordV1, index: usize) -> bool { + let started_at = job.runs[index].started_at; + started_at > 0 + && ids::now_ms().saturating_sub(started_at) + > job.request.limits.execution.scenario_timeout_seconds as i64 * 1_000 +} + +async fn harness_status( + deps: &Deps, + session_id: &str, + job: &EvalJobRecordV1, +) -> Result, EvalError> { + trigger( + deps, + "harness::status", + StatusRequest { + session_id: session_id.into(), + }, + job.request.limits.execution.invocation_timeout_seconds, + ) + .await +} + +async fn harness_metrics( + deps: &Deps, + session_id: &str, + job: &EvalJobRecordV1, +) -> Result { + trigger( + deps, + "harness::metrics", + SessionMetricsRequestV1 { + root_session_id: session_id.into(), + }, + job.request.limits.execution.invocation_timeout_seconds, + ) + .await +} + +async fn harness_teardown( + deps: &Deps, + session_id: &str, + job: &EvalJobRecordV1, +) -> Result { + trigger( + deps, + "harness::teardown", + TeardownRequestV1 { + root_session_id: session_id.into(), + }, + job.request.limits.execution.invocation_timeout_seconds, + ) + .await +} + +async fn stop_harness_tree(deps: &Deps, root_session_id: &str, job: &EvalJobRecordV1) { + let timeout_seconds = job.request.limits.execution.invocation_timeout_seconds; + let tree: Result = trigger( + deps, + "harness::session-tree", + SessionTreeRequestV1 { + root_session_id: root_session_id.into(), + }, + timeout_seconds, + ) + .await; + let mut session_ids = tree + .map(|tree| { + tree.sessions + .into_iter() + .rev() + .map(|node| node.session_id) + .collect::>() + }) + .unwrap_or_else(|_| vec![root_session_id.into()]); + if !session_ids + .iter() + .any(|session_id| session_id == root_session_id) + { + session_ids.push(root_session_id.into()); + } + for session_id in session_ids { + let _: Result = trigger( + deps, + "harness::stop", + StopRequest { + session_id, + turn_id: None, + }, + timeout_seconds, + ) + .await; + } +} + +async fn trigger( + deps: &Deps, + function_id: &str, + input: I, + timeout_seconds: u64, +) -> Result +where + I: Serialize, + O: DeserializeOwned, +{ + let timeout = std::time::Duration::from_secs(timeout_seconds); + let request = TriggerRequest { + function_id: function_id.into(), + payload: serde_json::to_value(input)?, + action: None, + timeout_ms: Some(timeout.as_millis().min(u64::MAX as u128) as u64), + }; + let value = match tokio::time::timeout(timeout, deps.iii.trigger(request)).await { + Ok(Ok(value)) => value, + Ok(Err(error)) => { + return Err(EvalError::Dependency(format!( + "{function_id} failed: {error}" + ))) + } + Err(_) => { + return Err(EvalError::Dependency(format!( + "{function_id} exceeded {timeout_seconds}s invocation timeout" + ))) + } + }; + serde_json::from_value(value) + .map_err(|error| EvalError::Serialization(format!("{function_id} response: {error}"))) +} + +fn elapsed_ms(started_at: i64, completed_at: i64) -> u64 { + completed_at.saturating_sub(started_at).max(0) as u64 +} + +fn compact(value: &Value) -> String { + let rendered = serde_json::to_string(value).unwrap_or_else(|_| "".into()); + const LIMIT: usize = 512; + if rendered.chars().count() <= LIMIT { + rendered + } else { + format!("{}…", rendered.chars().take(LIMIT).collect::()) + } +} + +#[cfg(test)] +mod tests { + use harness::functions::metrics::{SessionMetricsResponseV1, SessionUsageTotalsV1}; + + use super::*; + + fn evaluator_input(output: Value, expected: Value) -> EvaluatorInputV1 { + EvaluatorInputV1 { + evaluation_id: "eval".into(), + run_id: "run".into(), + role: VariantRoleV1::Control, + session_id: "session".into(), + output, + metrics: SessionMetricsResponseV1 { + root_session_id: "session".into(), + complete: true, + totals: SessionUsageTotalsV1::default(), + by_session: Vec::new(), + traces: None, + }, + arguments: json!({ "expected": expected }), + } + } + + #[test] + fn exact_evaluator_compares_json_values() { + assert!( + exact(evaluator_input(json!({"ok": true}), json!({"ok": true}))) + .unwrap() + .passed + ); + assert!( + !exact(evaluator_input(json!("OK"), json!("NO"))) + .unwrap() + .passed + ); + } + + #[test] + fn exact_evaluator_requires_expected_argument() { + let mut input = evaluator_input(json!("OK"), json!("OK")); + input.arguments = json!({}); + assert!(exact(input).is_err()); + } + + #[test] + fn normalized_text_ignores_case_whitespace_and_surrounding_punctuation() { + let result = normalized_text(evaluator_input( + json!(" Olá, mundo! "), + json!("olá, mundo"), + )) + .unwrap(); + assert!(result.passed); + } + + #[test] + fn normalized_text_rejects_different_text_and_non_text_output() { + assert!( + !normalized_text(evaluator_input(json!("hello"), json!("goodbye"))) + .unwrap() + .passed + ); + assert!( + !normalized_text(evaluator_input(json!({"text": "hello"}), json!("hello"))) + .unwrap() + .passed + ); + } + + #[test] + fn normalized_text_requires_string_expected_argument() { + assert!( + normalized_text(evaluator_input(json!("hello"), json!({"text": "hello"}))).is_err() + ); + } + + #[test] + fn finalization_runs_once_after_an_early_root_result() { + assert!(needs_finalization( + TurnStatus::Completed, + true, + false, + true, + false + )); + assert!(!needs_finalization( + TurnStatus::Completed, + true, + false, + false, + false + )); + assert!(needs_finalization( + TurnStatus::Completed, + true, + true, + false, + false + )); + assert!(!needs_finalization( + TurnStatus::Completed, + true, + true, + true, + true + )); + assert!(!needs_finalization( + TurnStatus::Failed, + true, + true, + true, + false + )); + assert!(!needs_finalization( + TurnStatus::Completed, + false, + true, + true, + false + )); + } +} diff --git a/eval/src/state.rs b/eval/src/state.rs new file mode 100644 index 000000000..87da8eeea --- /dev/null +++ b/eval/src/state.rs @@ -0,0 +1,443 @@ +use iii_sdk::protocol::TriggerRequest; +use iii_sdk::IIIClient; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::contract::{ + ActiveRunV1, EvalRunStatusV1, EvalStatusResponseV1, EvalStatusV1, EvalSummaryV1, + NormalizedEvalRequestV1, +}; +use crate::error::EvalError; +use crate::report::{EvalReportV1, EvalRunReportV1}; + +pub const JOB_SCOPE: &str = "eval_job"; +pub const SESSION_SCOPE: &str = "eval_session"; +const DISPATCH_TIMEOUT_MS: u64 = 30_000; + +fn is_false(value: &bool) -> bool { + !*value +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EvalJobRecordV1 { + pub schema_version: String, + pub evaluation_id: String, + pub request: NormalizedEvalRequestV1, + pub status: EvalStatusV1, + pub step: u64, + pub next_index: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_index: Option, + /// The active root returned while descendants were still running. Once + /// they settle, the eval asks that same root for one definitive result. + #[serde(default, skip_serializing_if = "is_false")] + pub active_waited_for_descendants: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub active_finalization_sent: bool, + pub runs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub report: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + pub created_at: i64, + pub updated_at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completed_at: Option, +} + +impl EvalJobRecordV1 { + pub fn status_response(&self) -> EvalStatusResponseV1 { + let terminal_runs = self + .runs + .iter() + .filter(|run| run.status.is_terminal()) + .count() as u32; + let passed_runs = self + .runs + .iter() + .filter(|run| run.passed == Some(true)) + .count() as u32; + let failed_runs = self + .runs + .iter() + .filter(|run| run.status.is_terminal() && run.passed == Some(false)) + .count() as u32; + let active = self.active_index.and_then(|index| { + self.runs.get(index).map(|run| ActiveRunV1 { + run_id: run.run_id.clone(), + role: run.role, + iteration: run.iteration, + session_id: run.session_id.clone(), + turn_id: run.turn_id.clone(), + started_at: run.started_at, + }) + }); + EvalStatusResponseV1 { + evaluation_id: self.evaluation_id.clone(), + status: self.status, + total_runs: self.runs.len() as u32, + terminal_runs, + passed_runs, + failed_runs, + active, + created_at: self.created_at, + updated_at: self.updated_at, + completed_at: self.completed_at, + error: self.error.clone(), + } + } + + pub fn summary_response(&self) -> EvalSummaryV1 { + let progress = self.status_response(); + EvalSummaryV1 { + evaluation_id: self.evaluation_id.clone(), + status: self.status, + dimension: self.request.dimension, + model: self.request.model.model.clone(), + provider: self.request.model.provider.clone(), + control_label: self.request.control.label.clone(), + treatment_label: self.request.treatment.label.clone(), + total_runs: progress.total_runs, + terminal_runs: progress.terminal_runs, + passed_runs: progress.passed_runs, + failed_runs: progress.failed_runs, + eligible: self.report.as_ref().and_then(|report| report.eligible), + created_at: self.created_at, + updated_at: self.updated_at, + completed_at: self.completed_at, + error: self.error.clone(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionIndexV1 { + pub evaluation_id: String, + pub run_id: String, +} + +pub fn build_run_plan( + evaluation_id: &str, + request: &NormalizedEvalRequestV1, +) -> Vec { + let mut runs = Vec::with_capacity(request.runs as usize * 2); + for iteration in 1..=request.runs { + for (pair_index, role) in request + .execution_order + .roles(iteration) + .into_iter() + .enumerate() + { + runs.push(EvalRunReportV1 { + run_id: crate::ids::run_id(role, iteration), + role, + iteration, + execution_position: runs.len() as u32 + 1, + pair_position: pair_index as u8 + 1, + status: EvalRunStatusV1::Pending, + session_id: crate::ids::session_id(evaluation_id, role, iteration), + turn_id: None, + passed: None, + started_at: 0, + completed_at: None, + output: None, + metrics: None, + evaluation: None, + benchmark: None, + failures: Vec::new(), + }); + } + } + runs +} + +pub async fn get_job( + iii: &IIIClient, + evaluation_id: &str, +) -> Result, EvalError> { + let value = state_get(iii, JOB_SCOPE, evaluation_id).await?; + if value.is_null() { + return Ok(None); + } + serde_json::from_value(value) + .map(Some) + .map_err(|error| EvalError::State(format!("parse {JOB_SCOPE}/{evaluation_id}: {error}"))) +} + +pub async fn put_job(iii: &IIIClient, job: &EvalJobRecordV1) -> Result<(), EvalError> { + state_set( + iii, + JOB_SCOPE, + &job.evaluation_id, + serde_json::to_value(job)?, + ) + .await +} + +pub async fn list_jobs(iii: &IIIClient) -> Result, EvalError> { + let value = state_list(iii, JOB_SCOPE).await?; + Ok(parse_list(&value)) +} + +pub async fn delete_job(iii: &IIIClient, evaluation_id: &str) -> Result<(), EvalError> { + state_delete(iii, JOB_SCOPE, evaluation_id).await +} + +pub async fn put_session_index( + iii: &IIIClient, + session_id: &str, + index: &SessionIndexV1, +) -> Result<(), EvalError> { + state_set(iii, SESSION_SCOPE, session_id, serde_json::to_value(index)?).await +} + +pub async fn get_session_index( + iii: &IIIClient, + session_id: &str, +) -> Result, EvalError> { + let value = state_get(iii, SESSION_SCOPE, session_id).await?; + if value.is_null() { + return Ok(None); + } + serde_json::from_value(value) + .map(Some) + .map_err(|error| EvalError::State(format!("parse {SESSION_SCOPE}/{session_id}: {error}"))) +} + +pub async fn delete_session_index(iii: &IIIClient, session_id: &str) -> Result<(), EvalError> { + state_delete(iii, SESSION_SCOPE, session_id).await +} + +async fn state_get(iii: &IIIClient, scope: &str, key: &str) -> Result { + iii.trigger(TriggerRequest { + function_id: "state::get".into(), + payload: json!({ "scope": scope, "key": key }), + action: None, + timeout_ms: Some(DISPATCH_TIMEOUT_MS), + }) + .await + .map_err(|error| EvalError::State(format!("state::get {scope}/{key}: {error}"))) +} + +async fn state_set(iii: &IIIClient, scope: &str, key: &str, value: Value) -> Result<(), EvalError> { + iii.trigger(TriggerRequest { + function_id: "state::set".into(), + payload: json!({ "scope": scope, "key": key, "value": value }), + action: None, + timeout_ms: Some(DISPATCH_TIMEOUT_MS), + }) + .await + .map(|_| ()) + .map_err(|error| EvalError::State(format!("state::set {scope}/{key}: {error}"))) +} + +async fn state_list(iii: &IIIClient, scope: &str) -> Result { + iii.trigger(TriggerRequest { + function_id: "state::list".into(), + payload: json!({ "scope": scope }), + action: None, + timeout_ms: Some(DISPATCH_TIMEOUT_MS), + }) + .await + .map_err(|error| EvalError::State(format!("state::list {scope}: {error}"))) +} + +async fn state_delete(iii: &IIIClient, scope: &str, key: &str) -> Result<(), EvalError> { + iii.trigger(TriggerRequest { + function_id: "state::delete".into(), + payload: json!({ "scope": scope, "key": key }), + action: None, + timeout_ms: Some(DISPATCH_TIMEOUT_MS), + }) + .await + .map(|_| ()) + .map_err(|error| EvalError::State(format!("state::delete {scope}/{key}: {error}"))) +} + +fn parse_list(value: &Value) -> Vec { + let candidates: Vec<&Value> = match value { + Value::Array(items) => items.iter().collect(), + Value::Object(map) => { + if let Some(Value::Array(items)) = map.get("values").or_else(|| map.get("items")) { + items.iter().collect() + } else { + map.values().collect() + } + } + _ => return Vec::new(), + }; + candidates + .into_iter() + .filter_map(|value| serde_json::from_value(value.clone()).ok()) + .collect() +} + +#[cfg(test)] +mod tests { + use harness::prompt::SystemPromptStrategy; + use harness::types::output::OutputContract; + use harness::types::turn::FunctionPolicy; + use serde_json::json; + + use super::*; + use crate::contract::{ + ComparisonDimensionV1, EvalModelConfigV1, EvalVariantV1, EvaluatorSpecV1, VariantRoleV1, + }; + use crate::limits::EvalLimitsV1; + + fn request(runs: u32) -> NormalizedEvalRequestV1 { + NormalizedEvalRequestV1 { + dimension: ComparisonDimensionV1::Prompt, + model: EvalModelConfigV1 { + model: "model".into(), + provider: None, + system_prompt_strategy: SystemPromptStrategy::Override, + mode: None, + thinking_level: None, + provider_options: None, + }, + control: EvalVariantV1 { + label: None, + prompt: "a".into(), + system_prompt: Some("system".into()), + }, + treatment: EvalVariantV1 { + label: None, + prompt: "b".into(), + system_prompt: Some("system".into()), + }, + evaluator: Some(EvaluatorSpecV1 { + function_id: "judge".into(), + arguments: json!({}), + }), + runs, + execution_order: crate::contract::ExecutionOrderV1::default(), + source_evaluation_id: None, + limits: EvalLimitsV1::default(), + functions: FunctionPolicy::default(), + output: OutputContract::Text, + metadata: None, + } + } + + #[test] + fn run_plan_alternates_pair_order() { + let runs = build_run_plan("eval_1", &request(3)); + let roles: Vec<_> = runs.iter().map(|run| run.role).collect(); + assert_eq!( + roles, + [ + VariantRoleV1::Control, + VariantRoleV1::Treatment, + VariantRoleV1::Treatment, + VariantRoleV1::Control, + VariantRoleV1::Control, + VariantRoleV1::Treatment, + ] + ); + assert_eq!(runs[0].session_id, "eval_1_c_1"); + assert_eq!(runs[2].session_id, "eval_1_t_2"); + assert_eq!(runs[0].pair_position, 1); + assert_eq!(runs[1].pair_position, 2); + assert_eq!(runs[5].execution_position, 6); + } + + #[test] + fn reversed_run_plan_preserves_roles_and_inverts_each_pair() { + let mut request = request(2); + request.execution_order = crate::contract::ExecutionOrderV1::BalancedTreatmentFirst; + let runs = build_run_plan("eval_2", &request); + let roles: Vec<_> = runs.iter().map(|run| run.role).collect(); + assert_eq!( + roles, + [ + VariantRoleV1::Treatment, + VariantRoleV1::Control, + VariantRoleV1::Control, + VariantRoleV1::Treatment, + ] + ); + assert_eq!(runs[0].run_id, "treatment-1"); + assert_eq!(runs[1].run_id, "control-1"); + } + + #[test] + fn list_parser_accepts_all_state_shapes() { + let index = json!({"evaluation_id": "e", "run_id": "r"}); + assert_eq!(parse_list::(&json!([index])).len(), 1); + assert_eq!( + parse_list::(&json!({"values": [index]})).len(), + 1 + ); + assert_eq!( + parse_list::(&json!({"key": index})).len(), + 1 + ); + } + + #[test] + fn summary_projects_progress_without_the_full_job() { + let mut request = request(1); + request.control.label = Some("baseline".into()); + request.treatment.label = Some("candidate".into()); + let mut runs = build_run_plan("eval_1", &request); + runs[0].status = EvalRunStatusV1::Completed; + runs[0].passed = Some(true); + let job = EvalJobRecordV1 { + schema_version: "1".into(), + evaluation_id: "eval_1".into(), + request, + status: EvalStatusV1::Running, + step: 1, + next_index: 1, + active_index: Some(1), + active_waited_for_descendants: false, + active_finalization_sent: false, + runs, + report: None, + error: None, + created_at: 10, + updated_at: 20, + completed_at: None, + }; + + let summary = job.summary_response(); + assert_eq!(summary.control_label.as_deref(), Some("baseline")); + assert_eq!(summary.treatment_label.as_deref(), Some("candidate")); + assert_eq!(summary.total_runs, 2); + assert_eq!(summary.terminal_runs, 1); + assert_eq!(summary.passed_runs, 1); + assert_eq!(summary.failed_runs, 0); + assert_eq!(summary.eligible, None); + } + + #[test] + fn completed_run_without_evaluation_is_neither_passed_nor_failed() { + let request = request(1); + let mut runs = build_run_plan("eval_1", &request); + runs[0].status = EvalRunStatusV1::Completed; + runs[0].output = Some(Value::Null); + let job = EvalJobRecordV1 { + schema_version: "1".into(), + evaluation_id: "eval_1".into(), + request, + status: EvalStatusV1::Running, + step: 1, + next_index: 1, + active_index: Some(1), + active_waited_for_descendants: false, + active_finalization_sent: false, + runs, + report: None, + error: None, + created_at: 10, + updated_at: 20, + completed_at: None, + }; + + let status = job.status_response(); + assert_eq!(status.terminal_runs, 1); + assert_eq!(status.passed_runs, 0); + assert_eq!(status.failed_runs, 0); + } +} diff --git a/eval/src/surface.rs b/eval/src/surface.rs new file mode 100644 index 000000000..ff266cdb1 --- /dev/null +++ b/eval/src/surface.rs @@ -0,0 +1,49 @@ +use schemars::JsonSchema; + +use crate::contract::{ + EvalCancelResponseV1, EvalDeleteResponseV1, EvalListRequestV1, EvalListResponseV1, + EvalRerunRequestV1, EvalResultResponseV1, EvalStartRequestV1, EvalStartResponseV1, + EvalStatusResponseV1, EvaluationIdRequestV1, EvaluatorInputV1, EvaluatorResponseV1, + StepRequestV1, StepResponseV1, SweepEventV1, SweepResponseV1, WakeEventV1, WakeResponseV1, +}; +use crate::functions::{ + CANCEL_ID, DELETE_ID, EXACT_ID, LIST_ID, NORMALIZED_TEXT_ID, RERUN_ID, RESULT_ID, START_ID, + STATUS_ID, STEP_ID, SWEEP_ID, WAKE_ID, +}; + +pub struct FunctionSpec { + pub function_id: &'static str, + pub request_schema: schemars::schema::RootSchema, + pub response_schema: schemars::schema::RootSchema, +} + +fn schema_of() -> schemars::schema::RootSchema { + schemars::r#gen::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::() +} + +fn spec(function_id: &'static str) -> FunctionSpec { + FunctionSpec { + function_id, + request_schema: schema_of::(), + response_schema: schema_of::(), + } +} + +pub fn catalog() -> Vec { + vec![ + spec::(START_ID), + spec::(RERUN_ID), + spec::(LIST_ID), + spec::>(STATUS_ID), + spec::>(RESULT_ID), + spec::(CANCEL_ID), + spec::(DELETE_ID), + spec::(EXACT_ID), + spec::(NORMALIZED_TEXT_ID), + spec::(STEP_ID), + spec::(WAKE_ID), + spec::(SWEEP_ID), + ] +} diff --git a/eval/src/ui.rs b/eval/src/ui.rs new file mode 100644 index 000000000..a4285cc1e --- /dev/null +++ b/eval/src/ui.rs @@ -0,0 +1,46 @@ +//! Injectable console page for creating and inspecting prompt evaluations. + +use std::sync::Arc; + +use iii_console_ui::ConsoleUi; +use iii_sdk::IIIClient; + +pub const PAGE_PATH: &str = "eval/page.js"; +pub const STYLES_PATH: &str = "eval/styles.css"; + +const PAGE_JS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/page.js")); +const STYLES_CSS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/styles.css")); + +fn console_ui() -> ConsoleUi { + ConsoleUi::new("eval") + .script(PAGE_PATH, PAGE_JS) + .style(STYLES_PATH, STYLES_CSS) +} + +pub fn register(iii: &Arc) { + console_ui().register(iii); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_builder_accepts_the_assets() { + let _ = console_ui(); + } + + #[test] + fn embedded_page_is_nonempty_esm() { + assert!(PAGE_JS.contains("export"), "built page.js looks wrong"); + } + + #[test] + fn embedded_styles_are_scoped() { + assert!( + STYLES_CSS.contains(r#"[data-iii-ui="eval"]"#) + || STYLES_CSS.contains("[data-iii-ui=eval]"), + "built styles.css must be scoped under the eval UI attribute" + ); + } +} diff --git a/eval/tests/golden/schemas/eval.assert.exact.json b/eval/tests/golden/schemas/eval.assert.exact.json new file mode 100644 index 000000000..9e3e0a983 --- /dev/null +++ b/eval/tests/golden/schemas/eval.assert.exact.json @@ -0,0 +1,370 @@ +{ + "function_id": "eval::assert::exact", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "SessionMetricsResponseV1": { + "additionalProperties": false, + "properties": { + "by_session": { + "items": { + "$ref": "#/definitions/SessionUsageV1" + }, + "type": "array" + }, + "complete": { + "type": "boolean" + }, + "root_session_id": { + "type": "string" + }, + "totals": { + "$ref": "#/definitions/SessionUsageTotalsV1" + }, + "traces": { + "anyOf": [ + { + "$ref": "#/definitions/SessionTraceMetricsV1" + }, + { + "type": "null" + } + ], + "description": "Trace/span aggregates when the engine's in-memory observability exporter is available. Usage metrics remain available when it is not." + } + }, + "required": [ + "by_session", + "complete", + "root_session_id", + "totals" + ], + "type": "object" + }, + "SessionTraceMetricsV1": { + "additionalProperties": false, + "properties": { + "by_session": { + "items": { + "$ref": "#/definitions/SessionTraceUsageV1" + }, + "type": "array" + }, + "duration_ms": { + "description": "Elapsed window from the first observed span to the last observed span.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "error_span_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "span_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "trace_count": { + "description": "Distinct traces across the root session and all descendants.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "by_session", + "duration_ms", + "error_span_count", + "span_count", + "trace_count" + ], + "type": "object" + }, + "SessionTraceUsageV1": { + "additionalProperties": false, + "properties": { + "depth": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "duration_ms": { + "description": "Elapsed window from the session's first observed span to its last.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "error_span_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "parent_session_id": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "span_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "trace_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "depth", + "duration_ms", + "error_span_count", + "session_id", + "span_count", + "trace_count" + ], + "type": "object" + }, + "SessionUsageTotalsV1": { + "additionalProperties": false, + "properties": { + "cache_read_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cache_write_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "function_call_errors": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "function_calls": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "input_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "reasoning_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sessions": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "turns": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "function_call_errors", + "function_calls", + "sessions", + "turns" + ], + "type": "object" + }, + "SessionUsageV1": { + "additionalProperties": false, + "properties": { + "cache_read_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cache_write_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "depth": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "function_call_errors": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "function_calls": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "input_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "parent_session_id": { + "type": [ + "string", + "null" + ] + }, + "reasoning_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "turns": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "depth", + "function_call_errors", + "function_calls", + "session_id", + "turns" + ], + "type": "object" + }, + "VariantRoleV1": { + "enum": [ + "control", + "treatment" + ], + "type": "string" + } + }, + "properties": { + "arguments": { + "default": {} + }, + "evaluation_id": { + "type": "string" + }, + "metrics": { + "$ref": "#/definitions/SessionMetricsResponseV1" + }, + "output": true, + "role": { + "$ref": "#/definitions/VariantRoleV1" + }, + "run_id": { + "type": "string" + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "evaluation_id", + "metrics", + "output", + "role", + "run_id", + "session_id" + ], + "title": "EvaluatorInputV1", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "details": true, + "passed": { + "type": "boolean" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "score": { + "format": "double", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "passed" + ], + "title": "EvaluatorResponseV1", + "type": "object" + } +} diff --git a/eval/tests/golden/schemas/eval.assert.normalized_text.json b/eval/tests/golden/schemas/eval.assert.normalized_text.json new file mode 100644 index 000000000..b8cceef70 --- /dev/null +++ b/eval/tests/golden/schemas/eval.assert.normalized_text.json @@ -0,0 +1,370 @@ +{ + "function_id": "eval::assert::normalized_text", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "SessionMetricsResponseV1": { + "additionalProperties": false, + "properties": { + "by_session": { + "items": { + "$ref": "#/definitions/SessionUsageV1" + }, + "type": "array" + }, + "complete": { + "type": "boolean" + }, + "root_session_id": { + "type": "string" + }, + "totals": { + "$ref": "#/definitions/SessionUsageTotalsV1" + }, + "traces": { + "anyOf": [ + { + "$ref": "#/definitions/SessionTraceMetricsV1" + }, + { + "type": "null" + } + ], + "description": "Trace/span aggregates when the engine's in-memory observability exporter is available. Usage metrics remain available when it is not." + } + }, + "required": [ + "by_session", + "complete", + "root_session_id", + "totals" + ], + "type": "object" + }, + "SessionTraceMetricsV1": { + "additionalProperties": false, + "properties": { + "by_session": { + "items": { + "$ref": "#/definitions/SessionTraceUsageV1" + }, + "type": "array" + }, + "duration_ms": { + "description": "Elapsed window from the first observed span to the last observed span.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "error_span_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "span_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "trace_count": { + "description": "Distinct traces across the root session and all descendants.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "by_session", + "duration_ms", + "error_span_count", + "span_count", + "trace_count" + ], + "type": "object" + }, + "SessionTraceUsageV1": { + "additionalProperties": false, + "properties": { + "depth": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "duration_ms": { + "description": "Elapsed window from the session's first observed span to its last.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "error_span_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "parent_session_id": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "span_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "trace_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "depth", + "duration_ms", + "error_span_count", + "session_id", + "span_count", + "trace_count" + ], + "type": "object" + }, + "SessionUsageTotalsV1": { + "additionalProperties": false, + "properties": { + "cache_read_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cache_write_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "function_call_errors": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "function_calls": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "input_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "reasoning_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sessions": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "turns": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "function_call_errors", + "function_calls", + "sessions", + "turns" + ], + "type": "object" + }, + "SessionUsageV1": { + "additionalProperties": false, + "properties": { + "cache_read_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cache_write_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "depth": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "function_call_errors": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "function_calls": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "input_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "parent_session_id": { + "type": [ + "string", + "null" + ] + }, + "reasoning_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "turns": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "depth", + "function_call_errors", + "function_calls", + "session_id", + "turns" + ], + "type": "object" + }, + "VariantRoleV1": { + "enum": [ + "control", + "treatment" + ], + "type": "string" + } + }, + "properties": { + "arguments": { + "default": {} + }, + "evaluation_id": { + "type": "string" + }, + "metrics": { + "$ref": "#/definitions/SessionMetricsResponseV1" + }, + "output": true, + "role": { + "$ref": "#/definitions/VariantRoleV1" + }, + "run_id": { + "type": "string" + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "evaluation_id", + "metrics", + "output", + "role", + "run_id", + "session_id" + ], + "title": "EvaluatorInputV1", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "details": true, + "passed": { + "type": "boolean" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "score": { + "format": "double", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "passed" + ], + "title": "EvaluatorResponseV1", + "type": "object" + } +} diff --git a/eval/tests/golden/schemas/eval.cancel.json b/eval/tests/golden/schemas/eval.cancel.json new file mode 100644 index 000000000..3310e83ee --- /dev/null +++ b/eval/tests/golden/schemas/eval.cancel.json @@ -0,0 +1,46 @@ +{ + "function_id": "eval::cancel", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "evaluation_id": { + "type": "string" + } + }, + "required": [ + "evaluation_id" + ], + "title": "EvaluationIdRequestV1", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "EvalStatusV1": { + "enum": [ + "queued", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + } + }, + "properties": { + "cancelled": { + "type": "boolean" + }, + "status": { + "$ref": "#/definitions/EvalStatusV1" + } + }, + "required": [ + "cancelled", + "status" + ], + "title": "EvalCancelResponseV1", + "type": "object" + } +} diff --git a/eval/tests/golden/schemas/eval.delete.json b/eval/tests/golden/schemas/eval.delete.json new file mode 100644 index 000000000..c453a337c --- /dev/null +++ b/eval/tests/golden/schemas/eval.delete.json @@ -0,0 +1,30 @@ +{ + "function_id": "eval::delete", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "evaluation_id": { + "type": "string" + } + }, + "required": [ + "evaluation_id" + ], + "title": "EvaluationIdRequestV1", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "deleted": { + "type": "boolean" + } + }, + "required": [ + "deleted" + ], + "title": "EvalDeleteResponseV1", + "type": "object" + } +} diff --git a/eval/tests/golden/schemas/eval.list.json b/eval/tests/golden/schemas/eval.list.json new file mode 100644 index 000000000..8c2715f57 --- /dev/null +++ b/eval/tests/golden/schemas/eval.list.json @@ -0,0 +1,149 @@ +{ + "function_id": "eval::list", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "limit": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "EvalListRequestV1", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "ComparisonDimensionV1": { + "enum": [ + "prompt", + "system_prompt" + ], + "type": "string" + }, + "EvalStatusV1": { + "enum": [ + "queued", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "EvalSummaryV1": { + "additionalProperties": false, + "properties": { + "completed_at": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "control_label": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "format": "int64", + "type": "integer" + }, + "dimension": { + "$ref": "#/definitions/ComparisonDimensionV1" + }, + "eligible": { + "type": [ + "boolean", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "evaluation_id": { + "type": "string" + }, + "failed_runs": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "model": { + "type": "string" + }, + "passed_runs": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "provider": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/EvalStatusV1" + }, + "terminal_runs": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "total_runs": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "treatment_label": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "created_at", + "dimension", + "evaluation_id", + "failed_runs", + "model", + "passed_runs", + "status", + "terminal_runs", + "total_runs", + "updated_at" + ], + "type": "object" + } + }, + "properties": { + "evaluations": { + "items": { + "$ref": "#/definitions/EvalSummaryV1" + }, + "type": "array" + } + }, + "required": [ + "evaluations" + ], + "title": "EvalListResponseV1", + "type": "object" + } +} diff --git a/eval/tests/golden/schemas/eval.on-turn-completed.json b/eval/tests/golden/schemas/eval.on-turn-completed.json new file mode 100644 index 000000000..5a65ab0fa --- /dev/null +++ b/eval/tests/golden/schemas/eval.on-turn-completed.json @@ -0,0 +1,32 @@ +{ + "function_id": "eval::on-turn-completed", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "session_id": { + "default": "", + "type": "string" + }, + "terminal": { + "default": false, + "type": "boolean" + } + }, + "title": "WakeEventV1", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "woke": { + "type": "boolean" + } + }, + "required": [ + "woke" + ], + "title": "WakeResponseV1", + "type": "object" + } +} diff --git a/eval/tests/golden/schemas/eval.rerun.json b/eval/tests/golden/schemas/eval.rerun.json new file mode 100644 index 000000000..802fc92bf --- /dev/null +++ b/eval/tests/golden/schemas/eval.rerun.json @@ -0,0 +1,50 @@ +{ + "function_id": "eval::rerun", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "evaluation_id": { + "type": "string" + }, + "reverse_order": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "evaluation_id" + ], + "title": "EvalRerunRequestV1", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "EvalStatusV1": { + "enum": [ + "queued", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + } + }, + "properties": { + "evaluation_id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/EvalStatusV1" + } + }, + "required": [ + "evaluation_id", + "status" + ], + "title": "EvalStartResponseV1", + "type": "object" + } +} diff --git a/eval/tests/golden/schemas/eval.result.json b/eval/tests/golden/schemas/eval.result.json new file mode 100644 index 000000000..fd603052e --- /dev/null +++ b/eval/tests/golden/schemas/eval.result.json @@ -0,0 +1,1557 @@ +{ + "function_id": "eval::result", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "evaluation_id": { + "type": "string" + } + }, + "required": [ + "evaluation_id" + ], + "title": "EvaluationIdRequestV1", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "anyOf": [ + { + "$ref": "#/definitions/EvalResultResponseV1" + }, + { + "type": "null" + } + ], + "definitions": { + "AggregateDeltaV1": { + "additionalProperties": false, + "properties": { + "median_cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "median_error_span_count": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "median_function_call_errors": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "median_function_calls": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "median_reasoning_tokens": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "median_score": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "median_span_count": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "median_total_tokens": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "median_trace_count": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "median_trace_duration_ms": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "median_wall_time_ms": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "pass_rate": { + "format": "double", + "type": "number" + } + }, + "required": [ + "pass_rate" + ], + "type": "object" + }, + "ComparisonDimensionV1": { + "enum": [ + "prompt", + "system_prompt" + ], + "type": "string" + }, + "EvalBenchmarkV1": { + "additionalProperties": false, + "properties": { + "cache_read_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cache_write_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "error_span_count": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "function_call_errors": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "function_calls": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "input_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "reasoning_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sessions": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "span_count": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "total_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "trace_count": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "trace_duration_ms": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "turns": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "wall_time_ms": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "function_call_errors", + "function_calls", + "sessions", + "turns", + "wall_time_ms" + ], + "type": "object" + }, + "EvalFailureV1": { + "additionalProperties": false, + "properties": { + "code": { + "type": [ + "string", + "null" + ] + }, + "function_id": { + "type": [ + "string", + "null" + ] + }, + "message": { + "type": "string" + }, + "phase": { + "$ref": "#/definitions/EvalPhaseV1" + } + }, + "required": [ + "message", + "phase" + ], + "type": "object" + }, + "EvalLimitsV1": { + "additionalProperties": false, + "properties": { + "evaluation": { + "allOf": [ + { + "$ref": "#/definitions/EvaluationLimitsV1" + } + ], + "default": {} + }, + "execution": { + "allOf": [ + { + "$ref": "#/definitions/ExecutionLimitsV1" + } + ], + "default": { + "invocation_timeout_seconds": 120, + "max_output_tokens_per_call": 8192, + "max_turns": 1000, + "scenario_timeout_seconds": 600 + } + } + }, + "type": "object" + }, + "EvalModelConfigV1": { + "additionalProperties": false, + "properties": { + "mode": { + "anyOf": [ + { + "$ref": "#/definitions/Mode" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "provider": { + "type": [ + "string", + "null" + ] + }, + "provider_options": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "system_prompt_strategy": { + "allOf": [ + { + "$ref": "#/definitions/SystemPromptStrategy" + } + ], + "default": "enrich" + }, + "thinking_level": { + "anyOf": [ + { + "$ref": "#/definitions/ThinkingLevel" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "EvalPhaseV1": { + "enum": [ + "setup", + "send", + "await", + "collect", + "evaluate", + "limit", + "cancel" + ], + "type": "string" + }, + "EvalProgressV1": { + "additionalProperties": false, + "properties": { + "control_aggregate": { + "$ref": "#/definitions/VariantAggregateV1" + }, + "delta": { + "$ref": "#/definitions/AggregateDeltaV1" + }, + "effective_execution_order": { + "items": { + "type": "string" + }, + "type": "array" + }, + "order_sensitivity": { + "$ref": "#/definitions/OrderSensitivityV1" + }, + "runs": { + "items": { + "$ref": "#/definitions/EvalRunReportV1" + }, + "type": "array" + }, + "treatment_aggregate": { + "$ref": "#/definitions/VariantAggregateV1" + } + }, + "required": [ + "control_aggregate", + "delta", + "effective_execution_order", + "order_sensitivity", + "runs", + "treatment_aggregate" + ], + "type": "object" + }, + "EvalReportV1": { + "additionalProperties": false, + "properties": { + "completed_at": { + "format": "int64", + "type": "integer" + }, + "control": { + "$ref": "#/definitions/VariantArtifactV1" + }, + "control_aggregate": { + "$ref": "#/definitions/VariantAggregateV1" + }, + "created_at": { + "format": "int64", + "type": "integer" + }, + "delta": { + "allOf": [ + { + "$ref": "#/definitions/AggregateDeltaV1" + } + ], + "description": "Treatment minus control. Negative efficiency deltas are improvements." + }, + "dimension": { + "$ref": "#/definitions/ComparisonDimensionV1" + }, + "effective_execution_order": { + "items": { + "type": "string" + }, + "type": "array" + }, + "eligible": { + "type": [ + "boolean", + "null" + ] + }, + "evaluation_id": { + "type": "string" + }, + "evaluator": { + "anyOf": [ + { + "$ref": "#/definitions/EvaluatorArtifactV1" + }, + { + "type": "null" + } + ] + }, + "execution_order_policy": { + "$ref": "#/definitions/ExecutionOrderV1" + }, + "limits": { + "$ref": "#/definitions/EvalLimitsV1" + }, + "model": { + "$ref": "#/definitions/EvalModelConfigV1" + }, + "order_sensitivity": { + "$ref": "#/definitions/OrderSensitivityV1" + }, + "runs": { + "items": { + "$ref": "#/definitions/EvalRunReportV1" + }, + "type": "array" + }, + "schema_version": { + "type": "string" + }, + "shared_artifacts": { + "$ref": "#/definitions/SharedArtifactsV1" + }, + "source_evaluation_id": { + "type": [ + "string", + "null" + ] + }, + "treatment": { + "$ref": "#/definitions/VariantArtifactV1" + }, + "treatment_aggregate": { + "$ref": "#/definitions/VariantAggregateV1" + } + }, + "required": [ + "completed_at", + "control", + "control_aggregate", + "created_at", + "delta", + "dimension", + "effective_execution_order", + "evaluation_id", + "execution_order_policy", + "limits", + "model", + "order_sensitivity", + "runs", + "schema_version", + "shared_artifacts", + "treatment", + "treatment_aggregate" + ], + "type": "object" + }, + "EvalResultResponseV1": { + "additionalProperties": false, + "properties": { + "progress": { + "$ref": "#/definitions/EvalProgressV1" + }, + "report": { + "anyOf": [ + { + "$ref": "#/definitions/EvalReportV1" + }, + { + "type": "null" + } + ] + }, + "request": { + "$ref": "#/definitions/NormalizedEvalRequestV1" + }, + "status": { + "$ref": "#/definitions/EvalStatusV1" + } + }, + "required": [ + "progress", + "request", + "status" + ], + "type": "object" + }, + "EvalRunReportV1": { + "additionalProperties": false, + "properties": { + "benchmark": { + "anyOf": [ + { + "$ref": "#/definitions/EvalBenchmarkV1" + }, + { + "type": "null" + } + ] + }, + "completed_at": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "evaluation": { + "anyOf": [ + { + "$ref": "#/definitions/EvaluatorResponseV1" + }, + { + "type": "null" + } + ] + }, + "execution_position": { + "default": 0, + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "failures": { + "items": { + "$ref": "#/definitions/EvalFailureV1" + }, + "type": "array" + }, + "iteration": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "metrics": { + "anyOf": [ + { + "$ref": "#/definitions/SessionMetricsResponseV1" + }, + { + "type": "null" + } + ] + }, + "output": true, + "pair_position": { + "default": 0, + "format": "uint8", + "minimum": 0.0, + "type": "integer" + }, + "passed": { + "type": [ + "boolean", + "null" + ] + }, + "role": { + "$ref": "#/definitions/VariantRoleV1" + }, + "run_id": { + "type": "string" + }, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "int64", + "type": "integer" + }, + "status": { + "$ref": "#/definitions/EvalRunStatusV1" + }, + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "iteration", + "role", + "run_id", + "session_id", + "started_at", + "status" + ], + "type": "object" + }, + "EvalRunStatusV1": { + "enum": [ + "pending", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "EvalStatusV1": { + "enum": [ + "queued", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "EvalVariantV1": { + "additionalProperties": false, + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "prompt": { + "type": "string" + }, + "system_prompt": { + "description": "`null` disables the system prompt for this variant. An empty string keeps the harness behavior of resolving the provider or built-in prompt.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "prompt" + ], + "type": "object" + }, + "EvaluationLimitsV1": { + "additionalProperties": false, + "properties": { + "max_error_spans": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "max_function_call_errors": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "EvaluatorArtifactV1": { + "additionalProperties": false, + "properties": { + "arguments_sha256": { + "type": "string" + }, + "function_id": { + "type": "string" + } + }, + "required": [ + "arguments_sha256", + "function_id" + ], + "type": "object" + }, + "EvaluatorResponseV1": { + "additionalProperties": false, + "properties": { + "details": true, + "passed": { + "type": "boolean" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "score": { + "format": "double", + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "passed" + ], + "type": "object" + }, + "EvaluatorSpecV1": { + "additionalProperties": false, + "properties": { + "arguments": { + "default": {} + }, + "function_id": { + "type": "string" + } + }, + "required": [ + "function_id" + ], + "type": "object" + }, + "ExecutionLimitsV1": { + "additionalProperties": false, + "properties": { + "invocation_timeout_seconds": { + "default": 120, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "max_cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "max_output_tokens_per_call": { + "default": 8192, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "max_total_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "max_turns": { + "default": 1000, + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "scenario_timeout_seconds": { + "default": 600, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "type": "object" + }, + "ExecutionOrderV1": { + "enum": [ + "balanced_control_first", + "balanced_treatment_first" + ], + "type": "string" + }, + "ExposeMode": { + "description": "How allowed functions reach the model (harness.md § Exposure modes).", + "enum": [ + "agent_trigger", + "native" + ], + "type": "string" + }, + "FunctionPolicy": { + "description": "The fail-closed dispatch policy (harness.md § Functions). Absent on the send => every call denied (a plain chat loop).", + "properties": { + "allow": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "deny": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "expose": { + "allOf": [ + { + "$ref": "#/definitions/ExposeMode" + } + ], + "default": "agent_trigger" + } + }, + "type": "object" + }, + "Mode": { + "description": "Console / send operating mode — prepends a short paragraph before the shared identity prompt. `ask` is also structural: the turn's dispatch policy is capped at the configured default policy, never widened.", + "enum": [ + "ask", + "agent" + ], + "type": "string" + }, + "NormalizedEvalRequestV1": { + "additionalProperties": false, + "properties": { + "control": { + "$ref": "#/definitions/EvalVariantV1" + }, + "dimension": { + "$ref": "#/definitions/ComparisonDimensionV1" + }, + "evaluator": { + "anyOf": [ + { + "$ref": "#/definitions/EvaluatorSpecV1" + }, + { + "type": "null" + } + ] + }, + "execution_order": { + "allOf": [ + { + "$ref": "#/definitions/ExecutionOrderV1" + } + ], + "default": "balanced_control_first" + }, + "functions": { + "$ref": "#/definitions/FunctionPolicy" + }, + "limits": { + "$ref": "#/definitions/EvalLimitsV1" + }, + "metadata": true, + "model": { + "$ref": "#/definitions/EvalModelConfigV1" + }, + "output": { + "$ref": "#/definitions/OutputContract" + }, + "runs": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "source_evaluation_id": { + "type": [ + "string", + "null" + ] + }, + "treatment": { + "$ref": "#/definitions/EvalVariantV1" + } + }, + "required": [ + "control", + "dimension", + "functions", + "limits", + "model", + "output", + "runs", + "treatment" + ], + "type": "object" + }, + "OrderSensitivityV1": { + "additionalProperties": false, + "properties": { + "control": { + "$ref": "#/definitions/RoleOrderSensitivityV1" + }, + "detected": { + "type": "boolean" + }, + "treatment": { + "$ref": "#/definitions/RoleOrderSensitivityV1" + } + }, + "required": [ + "control", + "detected", + "treatment" + ], + "type": "object" + }, + "OutputContract": { + "description": "Free text by default; `json` constrains the final answer to a JSON value, validated against `schema` when supplied.", + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "text" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "schema": true, + "type": { + "enum": [ + "json" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "RoleOrderSensitivityV1": { + "additionalProperties": false, + "properties": { + "differs": { + "type": "boolean" + }, + "first_passed": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "first_runs": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "second_passed": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "second_runs": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "differs", + "first_passed", + "first_runs", + "second_passed", + "second_runs" + ], + "type": "object" + }, + "SessionMetricsResponseV1": { + "additionalProperties": false, + "properties": { + "by_session": { + "items": { + "$ref": "#/definitions/SessionUsageV1" + }, + "type": "array" + }, + "complete": { + "type": "boolean" + }, + "root_session_id": { + "type": "string" + }, + "totals": { + "$ref": "#/definitions/SessionUsageTotalsV1" + }, + "traces": { + "anyOf": [ + { + "$ref": "#/definitions/SessionTraceMetricsV1" + }, + { + "type": "null" + } + ], + "description": "Trace/span aggregates when the engine's in-memory observability exporter is available. Usage metrics remain available when it is not." + } + }, + "required": [ + "by_session", + "complete", + "root_session_id", + "totals" + ], + "type": "object" + }, + "SessionTraceMetricsV1": { + "additionalProperties": false, + "properties": { + "by_session": { + "items": { + "$ref": "#/definitions/SessionTraceUsageV1" + }, + "type": "array" + }, + "duration_ms": { + "description": "Elapsed window from the first observed span to the last observed span.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "error_span_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "span_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "trace_count": { + "description": "Distinct traces across the root session and all descendants.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "by_session", + "duration_ms", + "error_span_count", + "span_count", + "trace_count" + ], + "type": "object" + }, + "SessionTraceUsageV1": { + "additionalProperties": false, + "properties": { + "depth": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "duration_ms": { + "description": "Elapsed window from the session's first observed span to its last.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "error_span_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "parent_session_id": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "span_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "trace_count": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "depth", + "duration_ms", + "error_span_count", + "session_id", + "span_count", + "trace_count" + ], + "type": "object" + }, + "SessionUsageTotalsV1": { + "additionalProperties": false, + "properties": { + "cache_read_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cache_write_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "function_call_errors": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "function_calls": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "input_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "reasoning_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sessions": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "turns": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "function_call_errors", + "function_calls", + "sessions", + "turns" + ], + "type": "object" + }, + "SessionUsageV1": { + "additionalProperties": false, + "properties": { + "cache_read_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cache_write_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "depth": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "function_call_errors": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "function_calls": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "input_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "parent_session_id": { + "type": [ + "string", + "null" + ] + }, + "reasoning_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "turns": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "depth", + "function_call_errors", + "function_calls", + "session_id", + "turns" + ], + "type": "object" + }, + "SharedArtifactsV1": { + "additionalProperties": false, + "properties": { + "function_policy_sha256": { + "type": "string" + }, + "limits_sha256": { + "type": "string" + }, + "model_sha256": { + "type": "string" + }, + "output_sha256": { + "type": "string" + } + }, + "required": [ + "function_policy_sha256", + "limits_sha256", + "model_sha256", + "output_sha256" + ], + "type": "object" + }, + "SystemPromptStrategy": { + "description": "How a caller-supplied system prompt combines with the built-in identity prompt.", + "oneOf": [ + { + "description": "Caller prompt replaces the built-in prompt verbatim.", + "enum": [ + "override" + ], + "type": "string" + }, + { + "description": "Caller prompt is appended to the built-in identity prompt.", + "enum": [ + "enrich" + ], + "type": "string" + }, + { + "description": "No system prompt is sent to the model.", + "enum": [ + "disabled" + ], + "type": "string" + } + ] + }, + "ThinkingLevel": { + "enum": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, + "VariantAggregateV1": { + "additionalProperties": false, + "properties": { + "benchmarked_runs": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "evaluated_runs": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "median_cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "median_error_span_count": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "median_function_call_errors": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "median_function_calls": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "median_input_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "median_output_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "median_reasoning_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "median_score": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "median_span_count": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "median_total_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "median_trace_count": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "median_trace_duration_ms": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "median_wall_time_ms": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "pass_rate": { + "format": "double", + "type": "number" + }, + "passed": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "runs": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "benchmarked_runs", + "evaluated_runs", + "pass_rate", + "passed", + "runs" + ], + "type": "object" + }, + "VariantArtifactV1": { + "additionalProperties": false, + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "prompt_sha256": { + "type": "string" + }, + "system_prompt_sha256": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "prompt_sha256" + ], + "type": "object" + }, + "VariantRoleV1": { + "enum": [ + "control", + "treatment" + ], + "type": "string" + } + }, + "title": "Nullable_EvalResultResponseV1" + } +} diff --git a/eval/tests/golden/schemas/eval.start.json b/eval/tests/golden/schemas/eval.start.json new file mode 100644 index 000000000..b5f5b174f --- /dev/null +++ b/eval/tests/golden/schemas/eval.start.json @@ -0,0 +1,444 @@ +{ + "function_id": "eval::start", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ComparisonDimensionV1": { + "enum": [ + "prompt", + "system_prompt" + ], + "type": "string" + }, + "EvalLimitsV1": { + "additionalProperties": false, + "properties": { + "evaluation": { + "allOf": [ + { + "$ref": "#/definitions/EvaluationLimitsV1" + } + ], + "default": {} + }, + "execution": { + "allOf": [ + { + "$ref": "#/definitions/ExecutionLimitsV1" + } + ], + "default": { + "invocation_timeout_seconds": 120, + "max_output_tokens_per_call": 8192, + "max_turns": 1000, + "scenario_timeout_seconds": 600 + } + } + }, + "type": "object" + }, + "EvalModelConfigV1": { + "additionalProperties": false, + "properties": { + "mode": { + "anyOf": [ + { + "$ref": "#/definitions/Mode" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "provider": { + "type": [ + "string", + "null" + ] + }, + "provider_options": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "system_prompt_strategy": { + "allOf": [ + { + "$ref": "#/definitions/SystemPromptStrategy" + } + ], + "default": "enrich" + }, + "thinking_level": { + "anyOf": [ + { + "$ref": "#/definitions/ThinkingLevel" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "EvalVariantV1": { + "additionalProperties": false, + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "prompt": { + "type": "string" + }, + "system_prompt": { + "description": "`null` disables the system prompt for this variant. An empty string keeps the harness behavior of resolving the provider or built-in prompt.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "prompt" + ], + "type": "object" + }, + "EvaluationLimitsV1": { + "additionalProperties": false, + "properties": { + "max_error_spans": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "max_function_call_errors": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "EvaluatorSpecV1": { + "additionalProperties": false, + "properties": { + "arguments": { + "default": {} + }, + "function_id": { + "type": "string" + } + }, + "required": [ + "function_id" + ], + "type": "object" + }, + "ExecutionLimitsV1": { + "additionalProperties": false, + "properties": { + "invocation_timeout_seconds": { + "default": 120, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "max_cost_usd": { + "format": "double", + "type": [ + "number", + "null" + ] + }, + "max_output_tokens_per_call": { + "default": 8192, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "max_total_tokens": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "max_turns": { + "default": 1000, + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "scenario_timeout_seconds": { + "default": 600, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "type": "object" + }, + "ExecutionOrderV1": { + "enum": [ + "balanced_control_first", + "balanced_treatment_first" + ], + "type": "string" + }, + "ExposeMode": { + "description": "How allowed functions reach the model (harness.md § Exposure modes).", + "enum": [ + "agent_trigger", + "native" + ], + "type": "string" + }, + "FunctionPolicy": { + "description": "The fail-closed dispatch policy (harness.md § Functions). Absent on the send => every call denied (a plain chat loop).", + "properties": { + "allow": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "deny": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "expose": { + "allOf": [ + { + "$ref": "#/definitions/ExposeMode" + } + ], + "default": "agent_trigger" + } + }, + "type": "object" + }, + "Mode": { + "description": "Console / send operating mode — prepends a short paragraph before the shared identity prompt. `ask` is also structural: the turn's dispatch policy is capped at the configured default policy, never widened.", + "enum": [ + "ask", + "agent" + ], + "type": "string" + }, + "OutputContract": { + "description": "Free text by default; `json` constrains the final answer to a JSON value, validated against `schema` when supplied.", + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "text" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "schema": true, + "type": { + "enum": [ + "json" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "SystemPromptStrategy": { + "description": "How a caller-supplied system prompt combines with the built-in identity prompt.", + "oneOf": [ + { + "description": "Caller prompt replaces the built-in prompt verbatim.", + "enum": [ + "override" + ], + "type": "string" + }, + { + "description": "Caller prompt is appended to the built-in identity prompt.", + "enum": [ + "enrich" + ], + "type": "string" + }, + { + "description": "No system prompt is sent to the model.", + "enum": [ + "disabled" + ], + "type": "string" + } + ] + }, + "ThinkingLevel": { + "enum": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + } + }, + "properties": { + "control": { + "$ref": "#/definitions/EvalVariantV1" + }, + "dimension": { + "$ref": "#/definitions/ComparisonDimensionV1" + }, + "evaluator": { + "anyOf": [ + { + "$ref": "#/definitions/EvaluatorSpecV1" + }, + { + "type": "null" + } + ] + }, + "execution_order": { + "anyOf": [ + { + "$ref": "#/definitions/ExecutionOrderV1" + }, + { + "type": "null" + } + ] + }, + "functions": { + "anyOf": [ + { + "$ref": "#/definitions/FunctionPolicy" + }, + { + "type": "null" + } + ], + "description": "Shared function policy for both variants. Omitted means deny all." + }, + "limits": { + "allOf": [ + { + "$ref": "#/definitions/EvalLimitsV1" + } + ], + "default": { + "evaluation": {}, + "execution": { + "invocation_timeout_seconds": 120, + "max_output_tokens_per_call": 8192, + "max_turns": 1000, + "scenario_timeout_seconds": 600 + } + } + }, + "metadata": { + "description": "Shared harness metadata, including an optional filesystem scope." + }, + "model": { + "$ref": "#/definitions/EvalModelConfigV1" + }, + "output": { + "anyOf": [ + { + "$ref": "#/definitions/OutputContract" + }, + { + "type": "null" + } + ], + "description": "Shared output contract for both variants. Omitted means text." + }, + "runs": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "treatment": { + "$ref": "#/definitions/EvalVariantV1" + } + }, + "required": [ + "control", + "dimension", + "model", + "treatment" + ], + "title": "EvalStartRequestV1", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "EvalStatusV1": { + "enum": [ + "queued", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + } + }, + "properties": { + "evaluation_id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/EvalStatusV1" + } + }, + "required": [ + "evaluation_id", + "status" + ], + "title": "EvalStartResponseV1", + "type": "object" + } +} diff --git a/eval/tests/golden/schemas/eval.status.json b/eval/tests/golden/schemas/eval.status.json new file mode 100644 index 000000000..5396d531b --- /dev/null +++ b/eval/tests/golden/schemas/eval.status.json @@ -0,0 +1,157 @@ +{ + "function_id": "eval::status", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "evaluation_id": { + "type": "string" + } + }, + "required": [ + "evaluation_id" + ], + "title": "EvaluationIdRequestV1", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "anyOf": [ + { + "$ref": "#/definitions/EvalStatusResponseV1" + }, + { + "type": "null" + } + ], + "definitions": { + "ActiveRunV1": { + "additionalProperties": false, + "properties": { + "iteration": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "role": { + "$ref": "#/definitions/VariantRoleV1" + }, + "run_id": { + "type": "string" + }, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "int64", + "type": "integer" + }, + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "iteration", + "role", + "run_id", + "session_id", + "started_at" + ], + "type": "object" + }, + "EvalStatusResponseV1": { + "additionalProperties": false, + "properties": { + "active": { + "anyOf": [ + { + "$ref": "#/definitions/ActiveRunV1" + }, + { + "type": "null" + } + ] + }, + "completed_at": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "created_at": { + "format": "int64", + "type": "integer" + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "evaluation_id": { + "type": "string" + }, + "failed_runs": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "passed_runs": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "status": { + "$ref": "#/definitions/EvalStatusV1" + }, + "terminal_runs": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "total_runs": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "updated_at": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "created_at", + "evaluation_id", + "failed_runs", + "passed_runs", + "status", + "terminal_runs", + "total_runs", + "updated_at" + ], + "type": "object" + }, + "EvalStatusV1": { + "enum": [ + "queued", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + "VariantRoleV1": { + "enum": [ + "control", + "treatment" + ], + "type": "string" + } + }, + "title": "Nullable_EvalStatusResponseV1" + } +} diff --git a/eval/tests/golden/schemas/eval.step.json b/eval/tests/golden/schemas/eval.step.json new file mode 100644 index 000000000..a5a756076 --- /dev/null +++ b/eval/tests/golden/schemas/eval.step.json @@ -0,0 +1,52 @@ +{ + "function_id": "eval::step", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "evaluation_id": { + "type": "string" + }, + "step": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "evaluation_id", + "step" + ], + "title": "StepRequestV1", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "EvalStatusV1": { + "enum": [ + "queued", + "running", + "completed", + "failed", + "cancelled" + ], + "type": "string" + } + }, + "properties": { + "skipped": { + "type": "boolean" + }, + "status": { + "$ref": "#/definitions/EvalStatusV1" + } + }, + "required": [ + "skipped", + "status" + ], + "title": "StepResponseV1", + "type": "object" + } +} diff --git a/eval/tests/golden/schemas/eval.sweep.json b/eval/tests/golden/schemas/eval.sweep.json new file mode 100644 index 000000000..3feccdd87 --- /dev/null +++ b/eval/tests/golden/schemas/eval.sweep.json @@ -0,0 +1,41 @@ +{ + "function_id": "eval::sweep", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "scheduled_at": { + "default": null, + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "scheduled_time": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "title": "SweepEventV1", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "swept": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "swept" + ], + "title": "SweepResponseV1", + "type": "object" + } +} diff --git a/eval/tests/manifest.rs b/eval/tests/manifest.rs new file mode 100644 index 000000000..fca48d1c2 --- /dev/null +++ b/eval/tests/manifest.rs @@ -0,0 +1,42 @@ +use std::process::Command; + +use serde_json::Value; + +#[test] +fn manifest_subcommand_emits_valid_json() { + let output = Command::new(env!("CARGO_BIN_EXE_eval")) + .arg("--manifest") + .output() + .expect("spawn eval --manifest"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let manifest: Value = serde_json::from_slice(&output.stdout).expect("valid manifest JSON"); + assert_eq!(manifest["name"], "eval"); + assert_eq!(manifest["version"], env!("CARGO_PKG_VERSION")); + assert!(manifest["description"] + .as_str() + .is_some_and(|value| !value.is_empty())); + assert!(manifest["default_config"].is_object()); + assert!(manifest["supported_targets"] + .as_array() + .is_some_and(|targets| !targets.is_empty())); +} + +#[test] +fn worker_manifest_declares_runtime_dependencies() { + let source = std::fs::read_to_string(format!("{}/iii.worker.yaml", env!("CARGO_MANIFEST_DIR"))) + .expect("read iii.worker.yaml"); + let manifest: serde_yaml::Value = serde_yaml::from_str(&source).expect("parse worker manifest"); + let dependencies = manifest["dependencies"] + .as_mapping() + .expect("dependencies map"); + for dependency in ["harness", "state", "queue", "cron", "iii-observability"] { + assert!( + dependencies.contains_key(serde_yaml::Value::String(dependency.into())), + "missing {dependency}" + ); + } +} diff --git a/eval/tests/schemas.rs b/eval/tests/schemas.rs new file mode 100644 index 000000000..d1b463797 --- /dev/null +++ b/eval/tests/schemas.rs @@ -0,0 +1,55 @@ +mod support; + +use eval::surface::catalog; + +fn golden_file_name(function_id: &str) -> String { + format!("schemas/{}.json", function_id.replace("::", ".")) +} + +#[test] +fn catalog_matches_the_registered_surface() { + let ids: Vec<_> = catalog().iter().map(|spec| spec.function_id).collect(); + assert_eq!( + ids, + [ + "eval::start", + "eval::rerun", + "eval::list", + "eval::status", + "eval::result", + "eval::cancel", + "eval::delete", + "eval::assert::exact", + "eval::assert::normalized_text", + "eval::step", + "eval::on-turn-completed", + "eval::sweep", + ] + ); +} + +#[test] +fn schemas_are_typed_and_match_goldens() { + let mut failures = Vec::new(); + for spec in catalog() { + support::assert_typed_schema( + &format!("{} request", spec.function_id), + &spec.request_schema, + ); + support::assert_typed_schema( + &format!("{} response", spec.function_id), + &spec.response_schema, + ); + let value = serde_json::json!({ + "function_id": spec.function_id, + "request_schema": spec.request_schema, + "response_schema": spec.response_schema, + }); + let mut actual = serde_json::to_string_pretty(&value).unwrap(); + actual.push('\n'); + if let Err(error) = support::check_golden(&golden_file_name(spec.function_id), &actual) { + failures.push(error); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} diff --git a/eval/tests/support/mod.rs b/eval/tests/support/mod.rs new file mode 100644 index 000000000..ecc657d4b --- /dev/null +++ b/eval/tests/support/mod.rs @@ -0,0 +1,52 @@ +#![allow(dead_code)] + +use std::fs; +use std::path::PathBuf; + +pub fn golden_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/golden") +} + +pub fn check_golden(rel: &str, actual: &str) -> Result<(), String> { + let path = golden_root().join(rel); + if std::env::var("UPDATE_GOLDENS").as_deref() == Ok("1") { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("create {}: {error}", parent.display()))?; + } + fs::write(&path, actual).map_err(|error| format!("write {}: {error}", path.display()))?; + return Ok(()); + } + let expected = fs::read_to_string(&path).map_err(|error| { + format!( + "golden file {} unreadable ({error}); run UPDATE_GOLDENS=1 cargo test", + path.display() + ) + })?; + if expected == actual { + Ok(()) + } else { + Err(format!("golden mismatch: tests/golden/{rel}")) + } +} + +pub fn assert_typed_schema(label: &str, schema: &schemars::schema::RootSchema) { + let value = serde_json::to_value(schema).expect("schema serializes"); + let object = value + .as_object() + .unwrap_or_else(|| panic!("{label}: schema is not an object")); + const DEFINING: [&str; 8] = [ + "type", + "properties", + "$ref", + "allOf", + "anyOf", + "oneOf", + "enum", + "items", + ]; + assert!( + DEFINING.iter().any(|key| object.contains_key(*key)), + "{label}: schema is untyped: {value}" + ); +} diff --git a/eval/ui/build.mjs b/eval/ui/build.mjs new file mode 100644 index 000000000..9ff59ba86 --- /dev/null +++ b/eval/ui/build.mjs @@ -0,0 +1,24 @@ +import esbuild from 'esbuild' + +const options = { + entryPoints: ['page.tsx', 'styles.css'], + bundle: true, + format: 'esm', + jsx: 'automatic', + outdir: 'dist', + external: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + '@iii-dev/console-ui', + ], + logLevel: 'info', +} + +if (process.argv.includes('--watch')) { + const context = await esbuild.context(options) + await context.watch() +} else { + await esbuild.build(options) +} diff --git a/eval/ui/package.json b/eval/ui/package.json new file mode 100644 index 000000000..088ccaa93 --- /dev/null +++ b/eval/ui/package.json @@ -0,0 +1,18 @@ +{ + "name": "@iii-workers/eval-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "tsc --noEmit && node build.mjs", + "watch": "node build.mjs --watch" + }, + "dependencies": { + "@iii-dev/console-ui": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "esbuild": "^0.25.0", + "typescript": "^5.9.2" + } +} diff --git a/eval/ui/page.tsx b/eval/ui/page.tsx new file mode 100644 index 000000000..a9c9a5ac5 --- /dev/null +++ b/eval/ui/page.tsx @@ -0,0 +1,10 @@ +import type { Host } from '@iii-dev/console-ui' +import { EvalPage } from './src/page' + +export default function setup(host: Host) { + host.pages.register({ + id: 'eval-benchmarks', + title: 'eval', + render: () => , + }) +} diff --git a/eval/ui/src/api.ts b/eval/ui/src/api.ts new file mode 100644 index 000000000..9b64c9f1f --- /dev/null +++ b/eval/ui/src/api.ts @@ -0,0 +1,99 @@ +import type { Host } from '@iii-dev/console-ui' +import type { + CatalogModel, + EvalRequest, + EvalResultResponse, + EvalStatus, + EvalStatusResponse, + EvalSummary, +} from './types' + +const TIMEOUT_MS = 30_000 + +export interface EvalApi { + list(): Promise + start(request: EvalRequest): Promise<{ + evaluation_id: string + status: EvalStatus + }> + status(evaluationId: string): Promise + result(evaluationId: string): Promise + rerun(evaluationId: string, reverseOrder: boolean): Promise<{ + evaluation_id: string + status: EvalStatus + }> + cancel(evaluationId: string): Promise<{ cancelled: boolean; status: EvalStatus }> + delete(evaluationId: string): Promise<{ deleted: boolean }> + models(): Promise + systemPrompt(provider?: string): Promise +} + +export function createEvalApi(host: Host): EvalApi { + const trigger = (functionId: string, payload: Record) => + host.iii.trigger(functionId, payload, { timeoutMs: TIMEOUT_MS }) + + return { + async list() { + const response = await trigger<{ evaluations: EvalSummary[] }>( + 'eval::list', + { limit: 50 }, + ) + return response.evaluations + }, + start(request) { + return trigger('eval::start', request as unknown as Record) + }, + status(evaluationId) { + return trigger('eval::status', { evaluation_id: evaluationId }) + }, + result(evaluationId) { + return trigger('eval::result', { evaluation_id: evaluationId }) + }, + rerun(evaluationId, reverseOrder) { + return trigger('eval::rerun', { + evaluation_id: evaluationId, + reverse_order: reverseOrder, + }) + }, + cancel(evaluationId) { + return trigger('eval::cancel', { evaluation_id: evaluationId }) + }, + delete(evaluationId) { + return trigger('eval::delete', { evaluation_id: evaluationId }) + }, + async models() { + const response = await trigger<{ models: CatalogModel[] }>( + 'router::models::list', + {}, + ) + return response.models + }, + async systemPrompt(provider) { + const response = await trigger<{ + system_prompt?: string | null + }>( + 'router::system_prompt::get', + provider ? { provider } : {}, + ) + return response.system_prompt?.trim() || null + }, + } +} + +export function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message + if (typeof error === 'string') return error + try { + return JSON.stringify(error) + } catch { + return 'unknown error' + } +} + +export function isTerminal(status: EvalStatus): boolean { + return ( + status === 'completed' || + status === 'failed' || + status === 'cancelled' + ) +} diff --git a/eval/ui/src/components.tsx b/eval/ui/src/components.tsx new file mode 100644 index 000000000..34320374d --- /dev/null +++ b/eval/ui/src/components.tsx @@ -0,0 +1,72 @@ +import type { ReactNode, TextareaHTMLAttributes } from 'react' +import { Badge } from '@iii-dev/console-ui' +import type { EvalStatus } from './types' + +export function Field({ + label, + hint, + error, + children, + className = '', +}: { + label?: string + hint?: string + error?: string + children: ReactNode + className?: string +}) { + return ( + + ) +} + +export function TextArea({ + className = '', + ...props +}: TextareaHTMLAttributes) { + return