From 122bce1b28e50910df17f0fce38fc8a49281f134 Mon Sep 17 00:00:00 2001 From: Gilbert Kalombo Date: Sat, 17 Oct 2026 12:36:25 -0400 Subject: [PATCH 1/2] feat(compute): MVP-A inference execution contract (mbongo-infer:v1, kaayu-cv-extract:v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Freezes what a ComputeTask means when it asks for the MVP inference, as an off-chain convention on the opaque execution_spec of RFC 0005 (§2.10, §2.12): a fixed 47-byte layout "mbongo-infer:v1" || BLAKE3 job-spec digest (domain mbongo:infer-job-spec:v1), a constrained canonical JSON with every rule local and tested, InferenceJobSpecV1 (exact field set, identity rules, generation and limit bounds), and the frozen kaayu-cv-extract:v1 profile: llama.cpp llama-server v0.7.0 @ 620d2225 (MIT), Qwen3-4B-Instruct-2507 GGUF Q4_K_M pinned by SHA-256 (Apache-2.0, unsloth repository revision a06e946b), a verbatim system-prompt template and an identity-free output JSON Schema both pinned by digest, temperature 0 / top_p 1.0 / seed 42 / max 400 tokens, 10,000-byte input and 8,000-byte output limits. The worker gate is executable: preflight (parse spec, resolve and re-hash the job spec, validate it, runtime tag and commit, model digest, input commitment, input contract) and finish (JSON, schema, canonical bytes, output_commitment). Seventeen error codes, none consensus. Golden vector and negative vectors in test-vectors/inference/; the committed job-spec document in docs/product/inference/kaayu-cv-extract-v1/; test group inference_contract (A01–A24 + three negative controls) with CI step "Mbongo MVP Inference Contract"; .gitattributes keeps the digest-pinned artifacts byte-stable on every checkout. No model runs here. No ComputeTask, receipt, RPC or SDK wire change; the reference worker, conformance suite, J, K and the SNP adapter are untouched. Refs #150, #148 Co-Authored-By: Claude Fable 5.1 --- .gitattributes | 2 + .github/workflows/ci.yml | 6 + .../mbongo-compute/src/inference/canonical.rs | 435 ++++++++++ .../src/inference/execution_spec.rs | 85 ++ .../mbongo-compute/src/inference/job_spec.rs | 526 +++++++++++ crates/mbongo-compute/src/inference/kaayu.rs | 511 +++++++++++ crates/mbongo-compute/src/inference/mod.rs | 315 +++++++ crates/mbongo-compute/src/lib.rs | 1 + .../tests/inference_contract.rs | 821 ++++++++++++++++++ docs/INDEX.md | 1 + docs/product/MVP_INFERENCE_CONTRACT_v1.md | 328 +++++++ .../kaayu-cv-extract-v1/job-spec.json | 1 + .../kaayu-cv-extract-v1/output-schema.json | 52 ++ .../kaayu-cv-extract-v1/prompt-template.txt | 15 + test-vectors/inference/README.md | 26 + .../inference/execution-spec-negative-v1.json | 47 + test-vectors/inference/input-example-v1.json | 7 + .../inference/job-spec-negative-v1.json | 575 ++++++++++++ .../inference/kaayu-cv-extract-v1.json | 57 ++ .../inference/output-canonicalization-v1.json | 50 ++ 20 files changed, 3861 insertions(+) create mode 100644 .gitattributes create mode 100644 crates/mbongo-compute/src/inference/canonical.rs create mode 100644 crates/mbongo-compute/src/inference/execution_spec.rs create mode 100644 crates/mbongo-compute/src/inference/job_spec.rs create mode 100644 crates/mbongo-compute/src/inference/kaayu.rs create mode 100644 crates/mbongo-compute/src/inference/mod.rs create mode 100644 crates/mbongo-compute/tests/inference_contract.rs create mode 100644 docs/product/MVP_INFERENCE_CONTRACT_v1.md create mode 100644 docs/product/inference/kaayu-cv-extract-v1/job-spec.json create mode 100644 docs/product/inference/kaayu-cv-extract-v1/output-schema.json create mode 100644 docs/product/inference/kaayu-cv-extract-v1/prompt-template.txt create mode 100644 test-vectors/inference/README.md create mode 100644 test-vectors/inference/execution-spec-negative-v1.json create mode 100644 test-vectors/inference/input-example-v1.json create mode 100644 test-vectors/inference/job-spec-negative-v1.json create mode 100644 test-vectors/inference/kaayu-cv-extract-v1.json create mode 100644 test-vectors/inference/output-canonicalization-v1.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..37277b8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +docs/product/inference/** -text +test-vectors/inference/** -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d51e630..ca13bb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,12 @@ jobs: # GitHub-hosted runners have no SEV-SNP guest. See the gated job below. - name: Mbongo Compute Hardware Attestation run: cargo test -p mbongo-compute --test hardware_attestation --locked + # MVP-A: the mbongo-infer:v1 execution contract and the kaayu-cv-extract:v1 + # profile (docs/product/MVP_INFERENCE_CONTRACT_v1.md). Pins the golden + # vector in test-vectors/inference/ and the worker validation order. No + # model runs here; MVP-B adds execution. + - name: Mbongo MVP Inference Contract + run: cargo test -p mbongo-compute --test inference_contract --locked # --------------------------------------------------------- # Mbongo Compute v0.4 Vertical (run on PR + push) diff --git a/crates/mbongo-compute/src/inference/canonical.rs b/crates/mbongo-compute/src/inference/canonical.rs new file mode 100644 index 0000000..35b550d --- /dev/null +++ b/crates/mbongo-compute/src/inference/canonical.rs @@ -0,0 +1,435 @@ +//! Constrained canonical JSON for the inference contract (MVP-A §7, §18). +//! +//! This is **not** RFC 8785. It is a deliberately narrow subset whose every +//! rule is stated here and tested, so that two implementations produce the +//! same bytes for the same document without an external specification: +//! +//! | Construct | Rule | +//! |---|---| +//! | object | members sorted by key, byte-wise ascending over the key's UTF-8; no duplicate keys | +//! | array | element order preserved | +//! | string | UTF-8; `"` → `\"`, `\` → `\\`, U+0000–U+001F → `\u00xx` with lowercase hex; every other scalar raw | +//! | integer | non-negative, decimal, no leading zeros, at most `u64::MAX`; the only number form | +//! | whitespace | none | +//! | `true`, `false`, `null`, negative or fractional numbers, exponents | **rejected** on parse, never produced | +//! | nesting | at most [`MAX_DEPTH`] levels | +//! +//! The parser accepts any RFC 8259 document that stays inside those +//! constraints (whitespace, escapes and surrogate pairs included) and +//! rejects everything else, so `canonicalize(text)` is total over the +//! accepted set and idempotent: `canonicalize(serialize(v)) == serialize(v)`. +//! Nothing here is consensus; the chain never sees these bytes +//! (RFC 0005 §2.12). + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +/// Maximum nesting depth accepted by the parser. +pub const MAX_DEPTH: usize = 16; + +/// A value in the constrained canonical model. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Value { + /// A UTF-8 string. + String(String), + /// A non-negative integer. + Uint(u64), + /// An ordered list. + Array(Vec), + /// A map with byte-sorted keys. + Object(BTreeMap), +} + +/// Why a document is outside the constrained model. Carries positions, never +/// content. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CanonicalError { + /// Not well-formed JSON, or a construct the model excludes. + #[error("invalid JSON at byte {0}: {1}")] + Syntax(usize, &'static str), + /// A key appears twice in one object. + #[error("duplicate key at byte {0}")] + DuplicateKey(usize), + /// Nesting deeper than [`MAX_DEPTH`]. + #[error("nesting deeper than {MAX_DEPTH}")] + TooDeep, + /// Document longer than the caller's bound. + #[error("document longer than {0} bytes")] + TooLong(usize), +} + +/// Parses `text` into the constrained model. `max_bytes` bounds the input. +pub fn parse(text: &str, max_bytes: usize) -> Result { + if text.len() > max_bytes { + return Err(CanonicalError::TooLong(max_bytes)); + } + let mut p = Parser { + s: text.as_bytes(), + i: 0, + }; + p.ws(); + let v = p.value(0)?; + p.ws(); + if p.i != p.s.len() { + return Err(CanonicalError::Syntax(p.i, "trailing characters")); + } + Ok(v) +} + +/// Serializes `v` to its one canonical byte form. +pub fn serialize(v: &Value) -> Vec { + let mut out = String::new(); + write_value(&mut out, v); + out.into_bytes() +} + +/// `serialize(parse(text))`: the canonical bytes of any accepted document. +pub fn canonicalize(text: &str, max_bytes: usize) -> Result, CanonicalError> { + Ok(serialize(&parse(text, max_bytes)?)) +} + +/// Whether `bytes` are already canonical: they parse, and re-serialize to +/// themselves. +pub fn is_canonical(bytes: &[u8], max_bytes: usize) -> bool { + std::str::from_utf8(bytes) + .ok() + .and_then(|t| parse(t, max_bytes).ok()) + .is_some_and(|v| serialize(&v) == bytes) +} + +fn write_value(out: &mut String, v: &Value) { + match v { + Value::String(s) => write_string(out, s), + Value::Uint(n) => { + let _ = write!(out, "{n}"); + } + Value::Array(items) => { + out.push('['); + for (k, item) in items.iter().enumerate() { + if k > 0 { + out.push(','); + } + write_value(out, item); + } + out.push(']'); + } + Value::Object(members) => { + out.push('{'); + for (k, (key, item)) in members.iter().enumerate() { + if k > 0 { + out.push(','); + } + write_string(out, key); + out.push(':'); + write_value(out, item); + } + out.push('}'); + } + } +} + +fn write_string(out: &mut String, s: &str) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c if (c as u32) < 0x20 => { + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); +} + +struct Parser<'a> { + s: &'a [u8], + i: usize, +} + +impl Parser<'_> { + fn ws(&mut self) { + while self.i < self.s.len() && matches!(self.s[self.i], b' ' | b'\t' | b'\n' | b'\r') { + self.i += 1; + } + } + + fn peek(&self) -> Option { + self.s.get(self.i).copied() + } + + fn expect(&mut self, b: u8, what: &'static str) -> Result<(), CanonicalError> { + if self.peek() == Some(b) { + self.i += 1; + Ok(()) + } else { + Err(CanonicalError::Syntax(self.i, what)) + } + } + + fn value(&mut self, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(CanonicalError::TooDeep); + } + match self.peek() { + Some(b'{') => self.object(depth), + Some(b'[') => self.array(depth), + Some(b'"') => self.string().map(Value::String), + Some(b'0'..=b'9') => self.uint().map(Value::Uint), + Some(b't' | b'f' | b'n') => Err(CanonicalError::Syntax( + self.i, + "true, false and null are outside the constrained model", + )), + Some(b'-') => Err(CanonicalError::Syntax( + self.i, + "negative numbers are not allowed", + )), + Some(_) => Err(CanonicalError::Syntax(self.i, "unexpected character")), + None => Err(CanonicalError::Syntax(self.i, "unexpected end of input")), + } + } + + fn object(&mut self, depth: usize) -> Result { + self.expect(b'{', "expected '{'")?; + let mut members = BTreeMap::new(); + self.ws(); + if self.peek() == Some(b'}') { + self.i += 1; + return Ok(Value::Object(members)); + } + loop { + self.ws(); + let key_at = self.i; + if self.peek() != Some(b'"') { + return Err(CanonicalError::Syntax(self.i, "expected a string key")); + } + let key = self.string()?; + self.ws(); + self.expect(b':', "expected ':'")?; + self.ws(); + let v = self.value(depth + 1)?; + if members.insert(key, v).is_some() { + return Err(CanonicalError::DuplicateKey(key_at)); + } + self.ws(); + match self.peek() { + Some(b',') => self.i += 1, + Some(b'}') => { + self.i += 1; + return Ok(Value::Object(members)); + } + _ => return Err(CanonicalError::Syntax(self.i, "expected ',' or '}'")), + } + } + } + + fn array(&mut self, depth: usize) -> Result { + self.expect(b'[', "expected '['")?; + let mut items = Vec::new(); + self.ws(); + if self.peek() == Some(b']') { + self.i += 1; + return Ok(Value::Array(items)); + } + loop { + self.ws(); + items.push(self.value(depth + 1)?); + self.ws(); + match self.peek() { + Some(b',') => self.i += 1, + Some(b']') => { + self.i += 1; + return Ok(Value::Array(items)); + } + _ => return Err(CanonicalError::Syntax(self.i, "expected ',' or ']'")), + } + } + } + + fn uint(&mut self) -> Result { + let start = self.i; + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.i += 1; + } + let digits = &self.s[start..self.i]; + if digits.len() > 1 && digits[0] == b'0' { + return Err(CanonicalError::Syntax(start, "leading zero")); + } + if matches!(self.peek(), Some(b'.' | b'e' | b'E')) { + return Err(CanonicalError::Syntax( + self.i, + "fractions and exponents are not allowed", + )); + } + std::str::from_utf8(digits) + .ok() + .and_then(|d| d.parse::().ok()) + .ok_or(CanonicalError::Syntax(start, "integer out of range")) + } + + fn string(&mut self) -> Result { + self.expect(b'"', "expected '\"'")?; + let mut out = String::new(); + loop { + let at = self.i; + match self.peek() { + None => return Err(CanonicalError::Syntax(at, "unterminated string")), + Some(b'"') => { + self.i += 1; + return Ok(out); + } + Some(b'\\') => { + self.i += 1; + let esc = + self.peek().ok_or(CanonicalError::Syntax(at, "unterminated escape"))?; + self.i += 1; + match esc { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let hi = self.hex4(at)?; + let c = if (0xD800..0xDC00).contains(&hi) { + // High surrogate: a `\u` low surrogate must follow. + if self.peek() != Some(b'\\') + || self.s.get(self.i + 1) != Some(&b'u') + { + return Err(CanonicalError::Syntax(at, "lone high surrogate")); + } + self.i += 2; + let lo = self.hex4(at)?; + if !(0xDC00..0xE000).contains(&lo) { + return Err(CanonicalError::Syntax( + at, + "invalid low surrogate", + )); + } + 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00) + } else if (0xDC00..0xE000).contains(&hi) { + return Err(CanonicalError::Syntax(at, "lone low surrogate")); + } else { + hi + }; + out.push( + char::from_u32(c) + .ok_or(CanonicalError::Syntax(at, "invalid code point"))?, + ); + } + _ => return Err(CanonicalError::Syntax(at, "invalid escape")), + } + } + Some(b) if b < 0x20 => { + return Err(CanonicalError::Syntax( + at, + "raw control character in string", + )) + } + Some(_) => { + // Copy one whole UTF-8 scalar; the input is a `&str`, so + // the sequence is valid by construction. + let rest = std::str::from_utf8(&self.s[self.i..]) + .map_err(|_| CanonicalError::Syntax(at, "invalid UTF-8"))?; + let c = rest + .chars() + .next() + .ok_or(CanonicalError::Syntax(at, "unexpected end of input"))?; + out.push(c); + self.i += c.len_utf8(); + } + } + } + } + + fn hex4(&mut self, at: usize) -> Result { + let end = self.i + 4; + let slice = self + .s + .get(self.i..end) + .ok_or(CanonicalError::Syntax(at, "truncated \\u escape"))?; + let text = + std::str::from_utf8(slice).map_err(|_| CanonicalError::Syntax(at, "bad \\u escape"))?; + let v = u32::from_str_radix(text, 16) + .map_err(|_| CanonicalError::Syntax(at, "bad \\u escape"))?; + self.i = end; + Ok(v) + } +} + +/// Convenience constructors for building values in code. +pub mod build { + use super::Value; + use std::collections::BTreeMap; + + /// A string value. + pub fn s(v: &str) -> Value { + Value::String(v.to_owned()) + } + /// An integer value. + pub fn u(v: u64) -> Value { + Value::Uint(v) + } + /// An object from `(key, value)` pairs; later duplicates overwrite. + pub fn obj(pairs: Vec<(&str, Value)>) -> Value { + let mut m = BTreeMap::new(); + for (k, v) in pairs { + m.insert(k.to_owned(), v); + } + Value::Object(m) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sorts_keys_and_strips_whitespace() { + let bytes = canonicalize("{ \"b\" : 1 , \"a\" : [ \"x\" , {} ] }", 1024).unwrap(); + assert_eq!(bytes, br#"{"a":["x",{}],"b":1}"#); + assert!(is_canonical(&bytes, 1024)); + } + + #[test] + fn escapes_exactly_as_specified() { + let v = Value::String("q\"b\\n\n\u{1}é😀".into()); + assert_eq!(serialize(&v), "\"q\\\"b\\\\n\\u000a\\u0001é😀\"".as_bytes()); + let round = parse(std::str::from_utf8(&serialize(&v)).unwrap(), 1024).unwrap(); + assert_eq!(round, v); + } + + #[test] + fn rejects_excluded_constructs() { + for bad in [ + "true", + "null", + "-1", + "1.5", + "1e3", + "01", + "{\"a\":1,\"a\":2}", + "[1,]", + "\"\\x\"", + "\"\\ud800\"", + "{\"a\":1} x", + "\"\u{1}\"", + "18446744073709551616", + ] { + assert!(parse(bad, 1024).is_err(), "accepted {bad:?}"); + } + assert!(parse("1", 0).is_err()); + let deep = "[".repeat(MAX_DEPTH + 2) + &"]".repeat(MAX_DEPTH + 2); + assert_eq!(parse(&deep, 1024), Err(CanonicalError::TooDeep)); + } + + #[test] + fn surrogate_pairs_decode() { + let v = parse("\"\\ud83d\\ude00\"", 64).unwrap(); + assert_eq!(v, Value::String("😀".into())); + } +} diff --git a/crates/mbongo-compute/src/inference/execution_spec.rs b/crates/mbongo-compute/src/inference/execution_spec.rs new file mode 100644 index 0000000..9862ffd --- /dev/null +++ b/crates/mbongo-compute/src/inference/execution_spec.rs @@ -0,0 +1,85 @@ +//! The `mbongo-infer:v1` `execution_spec` layout (MVP-A §5). +//! +//! `ComputeTask.execution_spec` is opaque, bounded at 1,024 bytes and +//! application-versioned by convention (RFC 0005 §2.10, §2.12). The +//! inference convention is a **fixed 47-byte** value: +//! +//! ```text +//! offset len content +//! 0 15 ASCII tag "mbongo-infer:v1" (the version lives in the tag) +//! 15 32 job_spec_digest (BLAKE3, see job_spec::digest) +//! ``` +//! +//! No length prefix is needed because every field is fixed-size; no +//! separator is needed because the tag is a fixed prefix. Any other length, +//! any other prefix and any other version are rejected before anything else +//! happens (worker validation step 1). The chain enforces none of this and +//! never sees the job spec itself. + +use super::ContractError; + +/// The tag that selects the inference contract, version 1. +pub const INFER_TAG_V1: &[u8] = b"mbongo-infer:v1"; +/// The family prefix shared by every version of the inference contract. +pub const INFER_PREFIX: &[u8] = b"mbongo-infer:"; +/// Length of the job-spec digest. +pub const DIGEST_LEN: usize = 32; +/// Exact length of a version-1 inference `execution_spec`. +pub const EXECUTION_SPEC_LEN: usize = 15 + DIGEST_LEN; + +/// A decoded version-1 inference `execution_spec`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InferenceExecutionSpec { + /// Commitment to the off-chain job spec (`job_spec::digest`). + pub job_spec_digest: [u8; DIGEST_LEN], +} + +impl InferenceExecutionSpec { + /// The 47 bytes to place in `ComputeTask.execution_spec`. + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(EXECUTION_SPEC_LEN); + out.extend_from_slice(INFER_TAG_V1); + out.extend_from_slice(&self.job_spec_digest); + out + } + + /// Parses `execution_spec` bytes. Order of checks: family prefix, then + /// version, then exact length. + pub fn decode(bytes: &[u8]) -> Result { + if !bytes.starts_with(INFER_PREFIX) { + return Err(ContractError::MalformedExecutionSpec( + "not an mbongo-infer execution_spec", + )); + } + if !bytes.starts_with(INFER_TAG_V1) { + return Err(ContractError::UnsupportedInferenceSpecVersion); + } + if bytes.len() != EXECUTION_SPEC_LEN { + return Err(ContractError::MalformedExecutionSpec( + "mbongo-infer:v1 execution_spec must be exactly 47 bytes", + )); + } + let mut job_spec_digest = [0u8; DIGEST_LEN]; + job_spec_digest.copy_from_slice(&bytes[INFER_TAG_V1.len()..]); + Ok(Self { job_spec_digest }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layout_is_47_bytes_tag_then_digest() { + assert_eq!(INFER_TAG_V1.len(), 15); + assert_eq!(EXECUTION_SPEC_LEN, 47); + let spec = InferenceExecutionSpec { + job_spec_digest: [0xAB; 32], + }; + let bytes = spec.encode(); + assert_eq!(bytes.len(), 47); + assert_eq!(&bytes[..15], b"mbongo-infer:v1"); + assert_eq!(&bytes[15..], &[0xAB; 32]); + assert_eq!(InferenceExecutionSpec::decode(&bytes).unwrap(), spec); + } +} diff --git a/crates/mbongo-compute/src/inference/job_spec.rs b/crates/mbongo-compute/src/inference/job_spec.rs new file mode 100644 index 0000000..193b07e --- /dev/null +++ b/crates/mbongo-compute/src/inference/job_spec.rs @@ -0,0 +1,526 @@ +//! `InferenceJobSpecV1`: the off-chain execution contract a task commits to +//! (MVP-A §6–§10, §14, §19–§22). +//! +//! The job spec says *how* to execute — runtime, model artifact, profile, +//! generation parameters, limits — and never *what* (the CV text is the +//! private input, committed separately by `input_commitment`). It is +//! serialized with the constrained canonical JSON of [`super::canonical`] +//! and identified by +//! +//! ```text +//! job_spec_digest = BLAKE3( "mbongo:infer-job-spec:v1" || canonical_job_spec_bytes ) +//! ``` +//! +//! an application-level commitment, not a consensus hash. Every string in a +//! job spec is restricted to printable ASCII without `"` or `\`, so the +//! canonical bytes contain no escapes at all. + +use super::canonical::{self, build, Value}; +use super::ContractError; + +/// Domain prefix of the job-spec digest. Distinct from every RFC 0005 and +/// RFC 0002 domain. +pub const DOMAIN_JOB_SPEC: &[u8] = b"mbongo:infer-job-spec:v1"; +/// The value of the `contract` field. +pub const CONTRACT_V1: &str = "mbongo-infer:v1"; +/// Bound on the canonical job-spec bytes. +pub const MAX_JOB_SPEC_BYTES: usize = 4096; + +/// Generation-parameter bounds frozen for the MVP (§20). +pub mod bounds { + /// `temperature_milli` ∈ `[0, 200]` (0.0 to 0.2). + pub const TEMPERATURE_MILLI_MAX: u64 = 200; + /// `top_p_milli` ∈ `[500, 1000]`. + pub const TOP_P_MILLI_MIN: u64 = 500; + /// `top_p_milli` ∈ `[500, 1000]`. + pub const TOP_P_MILLI_MAX: u64 = 1000; + /// `seed` fits a 32-bit sampler seed. + pub const SEED_MAX: u64 = u32::MAX as u64; + /// `max_tokens` ∈ `[1, 400]`. + pub const MAX_TOKENS_MAX: u64 = 400; + /// `limits.max_input_bytes` ∈ `[1, 10000]`. + pub const MAX_INPUT_BYTES_MAX: u64 = 10_000; + /// `limits.max_output_bytes` ∈ `[1, 8000]`. + pub const MAX_OUTPUT_BYTES_MAX: u64 = 8_000; +} + +/// Runtime identity (§8). `revision` is an exact release tag and `commit` +/// the commit it resolves to; floating names are refused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeIdentity { + /// `"llama.cpp"`. + pub family: String, + /// `"llama-server"`. + pub mode: String, + /// Exact release tag, e.g. `"v0.7.0"`. + pub revision: String, + /// The 40-hex commit the tag resolves to. + pub commit: String, + /// SPDX identifier of the runtime license. + pub license: String, +} + +/// Model identity (§9, §10). `artifact_sha256` is authoritative; the names +/// are for humans and logs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelIdentity { + /// Model family, e.g. `"Qwen3"`. + pub family: String, + /// Human model name, e.g. `"Qwen3-4B-Instruct-2507"`. + pub name: String, + /// Where the artifact was obtained (URL of the repository). + pub source: String, + /// The repository revision the artifact was taken from. + pub source_revision: String, + /// Artifact file name. + pub artifact_name: String, + /// Lowercase hex SHA-256 of the exact artifact bytes. + pub artifact_sha256: String, + /// Artifact size in bytes. + pub artifact_size_bytes: u64, + /// `"gguf"`. + pub format: String, + /// `"Q4_K_M"`. + pub quantization: String, + /// SPDX identifier of the model license. + pub license: String, +} + +/// The application profile layered on the generic contract (§12–§15, §42). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProfileIdentity { + /// Profile id, e.g. `"kaayu-cv-extract"`. + pub id: String, + /// Profile version; changes whenever any execution-identity field of the + /// profile changes. + pub version: u64, + /// Input contract tag, e.g. `"kaayu-cv-extract-input:v1"`. + pub input_contract: String, + /// Output contract tag, e.g. `"kaayu-cv-profile:v1"`. + pub output_contract: String, + /// Lowercase hex SHA-256 of the prompt template bytes. + pub prompt_template_sha256: String, + /// Lowercase hex SHA-256 of the output JSON Schema bytes. + pub output_schema_sha256: String, +} + +/// Generation parameters (§19). Fractions are integer thousandths so the +/// job spec contains no floating-point number. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GenerationParams { + /// Sampling temperature × 1000. + pub temperature_milli: u64, + /// Nucleus `top_p` × 1000. + pub top_p_milli: u64, + /// Sampler seed. + pub seed: u64, + /// Maximum generated tokens. + pub max_tokens: u64, +} + +/// Size limits (§20). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + /// Maximum canonical input-object bytes. + pub max_input_bytes: u64, + /// Maximum raw model-output bytes before canonicalization. + pub max_output_bytes: u64, +} + +/// The job spec, version 1. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InferenceJobSpecV1 { + /// Runtime identity. + pub runtime: RuntimeIdentity, + /// Model identity. + pub model: ModelIdentity, + /// Application profile. + pub profile: ProfileIdentity, + /// Generation parameters. + pub generation: GenerationParams, + /// Limits. + pub limits: Limits, +} + +impl InferenceJobSpecV1 { + /// The constrained canonical model of this spec. + // The bindings mirror the job-spec sections; the names are the section names. + #[allow(clippy::many_single_char_names)] + pub fn to_value(&self) -> Value { + let r = &self.runtime; + let m = &self.model; + let p = &self.profile; + let g = self.generation; + let l = self.limits; + build::obj(vec![ + ("contract", build::s(CONTRACT_V1)), + ( + "generation", + build::obj(vec![ + ("max_tokens", build::u(g.max_tokens)), + ("seed", build::u(g.seed)), + ("temperature_milli", build::u(g.temperature_milli)), + ("top_p_milli", build::u(g.top_p_milli)), + ]), + ), + ( + "limits", + build::obj(vec![ + ("max_input_bytes", build::u(l.max_input_bytes)), + ("max_output_bytes", build::u(l.max_output_bytes)), + ]), + ), + ( + "model", + build::obj(vec![ + ("artifact_name", build::s(&m.artifact_name)), + ("artifact_sha256", build::s(&m.artifact_sha256)), + ("artifact_size_bytes", build::u(m.artifact_size_bytes)), + ("family", build::s(&m.family)), + ("format", build::s(&m.format)), + ("license", build::s(&m.license)), + ("name", build::s(&m.name)), + ("quantization", build::s(&m.quantization)), + ("source", build::s(&m.source)), + ("source_revision", build::s(&m.source_revision)), + ]), + ), + ( + "profile", + build::obj(vec![ + ("id", build::s(&p.id)), + ("input_contract", build::s(&p.input_contract)), + ("output_contract", build::s(&p.output_contract)), + ("output_schema_sha256", build::s(&p.output_schema_sha256)), + ( + "prompt_template_sha256", + build::s(&p.prompt_template_sha256), + ), + ("version", build::u(p.version)), + ]), + ), + ( + "runtime", + build::obj(vec![ + ("commit", build::s(&r.commit)), + ("family", build::s(&r.family)), + ("license", build::s(&r.license)), + ("mode", build::s(&r.mode)), + ("revision", build::s(&r.revision)), + ]), + ), + ]) + } + + /// The canonical bytes (§7). + pub fn canonical_bytes(&self) -> Vec { + canonical::serialize(&self.to_value()) + } + + /// `BLAKE3(DOMAIN_JOB_SPEC || canonical_bytes)` (§8). + pub fn digest(&self) -> [u8; 32] { + digest_of_canonical_bytes(&self.canonical_bytes()) + } + + /// Parses canonical or non-canonical job-spec JSON text and validates + /// it (§6 field set, §8–§10 identity rules, §20 bounds). Unknown and + /// missing fields are errors. + pub fn from_json(text: &str) -> Result { + let v = canonical::parse(text, MAX_JOB_SPEC_BYTES) + .map_err(|e| ContractError::JobSpecInvalid(format!("not constrained JSON: {e}")))?; + let spec = Self::from_value(&v)?; + spec.validate()?; + Ok(spec) + } + + // The bindings mirror the job-spec sections; the names are the section names. + #[allow(clippy::many_single_char_names)] + fn from_value(v: &Value) -> Result { + let top = Fields::new( + v, + "job spec", + &[ + "contract", + "generation", + "limits", + "model", + "profile", + "runtime", + ], + )?; + if top.str("contract")? != CONTRACT_V1 { + return Err(ContractError::UnsupportedInferenceSpecVersion); + } + let g = Fields::new( + top.get("generation")?, + "generation", + &["max_tokens", "seed", "temperature_milli", "top_p_milli"], + )?; + let l = Fields::new( + top.get("limits")?, + "limits", + &["max_input_bytes", "max_output_bytes"], + )?; + let m = Fields::new( + top.get("model")?, + "model", + &[ + "artifact_name", + "artifact_sha256", + "artifact_size_bytes", + "family", + "format", + "license", + "name", + "quantization", + "source", + "source_revision", + ], + )?; + let p = Fields::new( + top.get("profile")?, + "profile", + &[ + "id", + "input_contract", + "output_contract", + "output_schema_sha256", + "prompt_template_sha256", + "version", + ], + )?; + let r = Fields::new( + top.get("runtime")?, + "runtime", + &["commit", "family", "license", "mode", "revision"], + )?; + Ok(Self { + runtime: RuntimeIdentity { + family: r.str("family")?.to_owned(), + mode: r.str("mode")?.to_owned(), + revision: r.str("revision")?.to_owned(), + commit: r.str("commit")?.to_owned(), + license: r.str("license")?.to_owned(), + }, + model: ModelIdentity { + family: m.str("family")?.to_owned(), + name: m.str("name")?.to_owned(), + source: m.str("source")?.to_owned(), + source_revision: m.str("source_revision")?.to_owned(), + artifact_name: m.str("artifact_name")?.to_owned(), + artifact_sha256: m.str("artifact_sha256")?.to_owned(), + artifact_size_bytes: m.uint("artifact_size_bytes")?, + format: m.str("format")?.to_owned(), + quantization: m.str("quantization")?.to_owned(), + license: m.str("license")?.to_owned(), + }, + profile: ProfileIdentity { + id: p.str("id")?.to_owned(), + version: p.uint("version")?, + input_contract: p.str("input_contract")?.to_owned(), + output_contract: p.str("output_contract")?.to_owned(), + prompt_template_sha256: p.str("prompt_template_sha256")?.to_owned(), + output_schema_sha256: p.str("output_schema_sha256")?.to_owned(), + }, + generation: GenerationParams { + temperature_milli: g.uint("temperature_milli")?, + top_p_milli: g.uint("top_p_milli")?, + seed: g.uint("seed")?, + max_tokens: g.uint("max_tokens")?, + }, + limits: Limits { + max_input_bytes: l.uint("max_input_bytes")?, + max_output_bytes: l.uint("max_output_bytes")?, + }, + }) + } + + /// Validates identity rules and bounds. Called by [`Self::from_json`]; + /// also applicable to a spec built in code. + // The bindings mirror the job-spec sections; the names are the section names. + #[allow(clippy::many_single_char_names)] + pub fn validate(&self) -> Result<(), ContractError> { + let r = &self.runtime; + let m = &self.model; + let p = &self.profile; + for (name, s) in [ + ("runtime.family", &r.family), + ("runtime.mode", &r.mode), + ("runtime.revision", &r.revision), + ("runtime.commit", &r.commit), + ("runtime.license", &r.license), + ("model.family", &m.family), + ("model.name", &m.name), + ("model.source", &m.source), + ("model.source_revision", &m.source_revision), + ("model.artifact_name", &m.artifact_name), + ("model.artifact_sha256", &m.artifact_sha256), + ("model.format", &m.format), + ("model.quantization", &m.quantization), + ("model.license", &m.license), + ("profile.id", &p.id), + ("profile.input_contract", &p.input_contract), + ("profile.output_contract", &p.output_contract), + ("profile.prompt_template_sha256", &p.prompt_template_sha256), + ("profile.output_schema_sha256", &p.output_schema_sha256), + ] { + check_spec_string(name, s)?; + } + if r.family != "llama.cpp" || r.mode != "llama-server" { + return Err(ContractError::UnsupportedRuntime); + } + if FLOATING_REVISIONS.contains(&r.revision.as_str()) || !r.revision.starts_with('v') { + return Err(ContractError::JobSpecInvalid( + "runtime.revision must be an exact release tag (vX.Y.Z), not a floating name" + .into(), + )); + } + check_hex("runtime.commit", &r.commit, 40)?; + check_hex("model.artifact_sha256", &m.artifact_sha256, 64)?; + check_hex("model.source_revision", &m.source_revision, 40)?; + check_hex( + "profile.prompt_template_sha256", + &p.prompt_template_sha256, + 64, + )?; + check_hex("profile.output_schema_sha256", &p.output_schema_sha256, 64)?; + if m.format != "gguf" { + return Err(ContractError::JobSpecInvalid( + "model.format must be gguf".into(), + )); + } + if m.artifact_size_bytes == 0 { + return Err(ContractError::JobSpecInvalid( + "model.artifact_size_bytes must be positive".into(), + )); + } + if p.version == 0 { + return Err(ContractError::JobSpecInvalid( + "profile.version must be positive".into(), + )); + } + let g = self.generation; + if g.temperature_milli > bounds::TEMPERATURE_MILLI_MAX { + return Err(ContractError::InvalidGenerationParameters( + "temperature_milli above 200".into(), + )); + } + if g.top_p_milli < bounds::TOP_P_MILLI_MIN || g.top_p_milli > bounds::TOP_P_MILLI_MAX { + return Err(ContractError::InvalidGenerationParameters( + "top_p_milli outside [500, 1000]".into(), + )); + } + if g.seed > bounds::SEED_MAX { + return Err(ContractError::InvalidGenerationParameters( + "seed above u32::MAX".into(), + )); + } + if g.max_tokens == 0 || g.max_tokens > bounds::MAX_TOKENS_MAX { + return Err(ContractError::InvalidGenerationParameters( + "max_tokens outside [1, 400]".into(), + )); + } + let l = self.limits; + if l.max_input_bytes == 0 || l.max_input_bytes > bounds::MAX_INPUT_BYTES_MAX { + return Err(ContractError::JobSpecInvalid( + "limits.max_input_bytes outside [1, 10000]".into(), + )); + } + if l.max_output_bytes == 0 || l.max_output_bytes > bounds::MAX_OUTPUT_BYTES_MAX { + return Err(ContractError::JobSpecInvalid( + "limits.max_output_bytes outside [1, 8000]".into(), + )); + } + if self.canonical_bytes().len() > MAX_JOB_SPEC_BYTES { + return Err(ContractError::JobSpecInvalid( + "canonical job spec exceeds 4096 bytes".into(), + )); + } + Ok(()) + } +} + +/// Names that are never an exact runtime revision. +pub const FLOATING_REVISIONS: &[&str] = + &["", "latest", "current", "stable", "main", "master", "HEAD"]; + +/// `BLAKE3(DOMAIN_JOB_SPEC || bytes)` over bytes already known to be +/// canonical. +pub fn digest_of_canonical_bytes(canonical: &[u8]) -> [u8; 32] { + mbongo_core::crypto::blake3_hash_multi(&[DOMAIN_JOB_SPEC, canonical]) +} + +fn check_spec_string(name: &str, s: &str) -> Result<(), ContractError> { + if s.is_empty() { + return Err(ContractError::JobSpecInvalid(format!("{name} is empty"))); + } + if s.len() > 256 { + return Err(ContractError::JobSpecInvalid(format!( + "{name} exceeds 256 bytes" + ))); + } + if !s.bytes().all(|b| (0x20..=0x7E).contains(&b) && b != b'"' && b != b'\\') { + return Err(ContractError::JobSpecInvalid(format!( + "{name} must be printable ASCII without quote or backslash" + ))); + } + Ok(()) +} + +fn check_hex(name: &str, s: &str, len: usize) -> Result<(), ContractError> { + if s.len() != len || !s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { + return Err(ContractError::JobSpecInvalid(format!( + "{name} must be {len} lowercase hex characters" + ))); + } + Ok(()) +} + +/// Strict field access over an object: exact key set, typed getters. +struct Fields<'a> { + members: &'a std::collections::BTreeMap, + what: &'static str, +} + +impl<'a> Fields<'a> { + fn new(v: &'a Value, what: &'static str, expected: &[&str]) -> Result { + let Value::Object(members) = v else { + return Err(ContractError::JobSpecInvalid(format!( + "{what} must be an object" + ))); + }; + let keys: Vec<&str> = members.keys().map(String::as_str).collect(); + if keys != expected { + return Err(ContractError::JobSpecInvalid(format!( + "{what} must have exactly the fields {expected:?}" + ))); + } + Ok(Self { members, what }) + } + + fn get(&self, key: &str) -> Result<&'a Value, ContractError> { + self.members + .get(key) + .ok_or_else(|| ContractError::JobSpecInvalid(format!("{} is missing {key}", self.what))) + } + + fn str(&self, key: &str) -> Result<&'a str, ContractError> { + match self.get(key)? { + Value::String(s) => Ok(s), + _ => Err(ContractError::JobSpecInvalid(format!( + "{}.{key} must be a string", + self.what + ))), + } + } + + fn uint(&self, key: &str) -> Result { + match self.get(key)? { + Value::Uint(n) => Ok(*n), + _ => Err(ContractError::JobSpecInvalid(format!( + "{}.{key} must be an integer", + self.what + ))), + } + } +} diff --git a/crates/mbongo-compute/src/inference/kaayu.rs b/crates/mbongo-compute/src/inference/kaayu.rs new file mode 100644 index 0000000..0ac3600 --- /dev/null +++ b/crates/mbongo-compute/src/inference/kaayu.rs @@ -0,0 +1,511 @@ +//! `kaayu-cv-extract:v1`: the frozen MVP profile (MVP-A §11–§17, §42–§44). +//! +//! The generic contract `mbongo-infer:v1` says how a job spec is encoded, +//! committed and verified. This profile fills it in for the one MVP +//! workload: **CV text → identity-free JSON candidate profile**. It is a +//! repository-defined constant, not a registry entry anywhere else. +//! +//! | Artifact | Where | Pinned by | +//! |---|---|---| +//! | prompt template (the system message, verbatim) | `docs/product/inference/kaayu-cv-extract-v1/prompt-template.txt` | `profile.prompt_template_sha256` | +//! | output JSON Schema (`kaayu-cv-profile:v1`) | `docs/product/inference/kaayu-cv-extract-v1/output-schema.json` | `profile.output_schema_sha256` | +//! | job spec (canonical bytes) | `docs/product/inference/kaayu-cv-extract-v1/job-spec.json` | the digest in `execution_spec` | +//! +//! The typed output validator below is the executable form of the schema +//! file; a test asserts the two agree on their field set, bounds and +//! enumerations. + +use sha2::{Digest, Sha256}; + +use super::canonical::{self, Value}; +use super::job_spec::{ + GenerationParams, InferenceJobSpecV1, Limits, ModelIdentity, ProfileIdentity, RuntimeIdentity, +}; +use super::ContractError; + +/// The prompt template: used verbatim as the chat **system** message. +pub const PROMPT_TEMPLATE: &str = + include_str!("../../../../docs/product/inference/kaayu-cv-extract-v1/prompt-template.txt"); +/// The output JSON Schema (`kaayu-cv-profile:v1`), passed to llama-server +/// as `response_format` and mirrored by [`CandidateProfileV1::validate`]. +pub const OUTPUT_SCHEMA: &str = + include_str!("../../../../docs/product/inference/kaayu-cv-extract-v1/output-schema.json"); + +/// Profile id. +pub const PROFILE_ID: &str = "kaayu-cv-extract"; +/// Profile version. +pub const PROFILE_VERSION: u64 = 1; +/// Input contract tag. +pub const INPUT_CONTRACT_V1: &str = "kaayu-cv-extract-input:v1"; +/// Output contract tag. +pub const OUTPUT_CONTRACT_V1: &str = "kaayu-cv-profile:v1"; +/// Canonical input-object bound, aligned with ≤ 2,500 input tokens at a +/// conservative four bytes per token. Token counts are never a contract +/// rule; bytes are. +pub const MAX_INPUT_BYTES: usize = 10_000; +/// Raw model-output bound before canonicalization. +pub const MAX_OUTPUT_BYTES: usize = 8_000; + +/// Lowercase hex SHA-256 of the prompt template bytes. +pub fn prompt_template_sha256() -> String { + hex::encode(Sha256::digest(PROMPT_TEMPLATE.as_bytes())) +} + +/// Lowercase hex SHA-256 of the output schema bytes. +pub fn output_schema_sha256() -> String { + hex::encode(Sha256::digest(OUTPUT_SCHEMA.as_bytes())) +} + +/// The frozen MVP job spec. Every value here is a product decision of +/// `docs/product/MVP_v0.1.md` §5–§6 made exact; changing any of them +/// changes the digest and therefore the `execution_spec` of every task. +pub fn job_spec() -> InferenceJobSpecV1 { + InferenceJobSpecV1 { + runtime: RuntimeIdentity { + family: "llama.cpp".into(), + mode: "llama-server".into(), + revision: "v0.7.0".into(), + commit: "620d2225cc436ab0b001e3200e590e3b286ef9ab".into(), + license: "MIT".into(), + }, + model: ModelIdentity { + family: "Qwen3".into(), + name: "Qwen3-4B-Instruct-2507".into(), + source: "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF".into(), + source_revision: "a06e946bb6b655725eafa393f4a9745d460374c9".into(), + artifact_name: "Qwen3-4B-Instruct-2507-Q4_K_M.gguf".into(), + artifact_sha256: "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597" + .into(), + artifact_size_bytes: 2_497_281_120, + format: "gguf".into(), + quantization: "Q4_K_M".into(), + license: "Apache-2.0".into(), + }, + profile: ProfileIdentity { + id: PROFILE_ID.into(), + version: PROFILE_VERSION, + input_contract: INPUT_CONTRACT_V1.into(), + output_contract: OUTPUT_CONTRACT_V1.into(), + prompt_template_sha256: prompt_template_sha256(), + output_schema_sha256: output_schema_sha256(), + }, + generation: GenerationParams { + temperature_milli: 0, + top_p_milli: 1000, + seed: 42, + max_tokens: 400, + }, + limits: Limits { + max_input_bytes: MAX_INPUT_BYTES as u64, + max_output_bytes: MAX_OUTPUT_BYTES as u64, + }, + } +} + +// ── input ──────────────────────────────────────────────────────────────── + +/// The private input object (§12): `{"contract":"kaayu-cv-extract-input:v1","cv_text":"…"}` +/// in canonical form. Its canonical bytes are what `input_commitment` +/// commits to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CvExtractionInputV1 { + /// The CV as UTF-8 text. + pub cv_text: String, +} + +impl CvExtractionInputV1 { + /// Validates the text (non-empty; only `\n`, `\r`, `\t` among control + /// characters) and the canonical size bound. + pub fn new(cv_text: String) -> Result { + if cv_text.trim().is_empty() { + return Err(ContractError::InvalidInputFormat("cv_text is empty")); + } + if cv_text.chars().any(|c| (c as u32) < 0x20 && !matches!(c, '\n' | '\r' | '\t')) { + return Err(ContractError::InvalidInputFormat( + "cv_text contains a control character other than newline, carriage return or tab", + )); + } + let input = Self { cv_text }; + if input.canonical_bytes().len() > MAX_INPUT_BYTES { + return Err(ContractError::InputTooLarge); + } + Ok(input) + } + + /// The canonical input-object bytes. + pub fn canonical_bytes(&self) -> Vec { + canonical::serialize(&canonical::build::obj(vec![ + ("contract", canonical::build::s(INPUT_CONTRACT_V1)), + ("cv_text", canonical::build::s(&self.cv_text)), + ])) + } + + /// Parses the input object as fetched from the private data plane + /// (canonical or not) and validates it. + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() > MAX_INPUT_BYTES { + return Err(ContractError::InputTooLarge); + } + let text = std::str::from_utf8(bytes) + .map_err(|_| ContractError::InvalidInputFormat("input is not UTF-8"))?; + let v = canonical::parse(text, MAX_INPUT_BYTES).map_err(|_| { + ContractError::InvalidInputFormat("input is not a constrained JSON object") + })?; + let Value::Object(m) = &v else { + return Err(ContractError::InvalidInputFormat( + "input must be a JSON object", + )); + }; + let keys: Vec<&str> = m.keys().map(String::as_str).collect(); + if keys != ["contract", "cv_text"] { + return Err(ContractError::InvalidInputFormat( + "input must have exactly the fields contract and cv_text", + )); + } + match m.get("contract") { + Some(Value::String(c)) if c == INPUT_CONTRACT_V1 => {} + _ => { + return Err(ContractError::InvalidInputFormat( + "unsupported input contract", + )) + } + } + let Some(Value::String(text)) = m.get("cv_text") else { + return Err(ContractError::InvalidInputFormat( + "cv_text must be a string", + )); + }; + Self::new(text.clone()) + } + + /// RFC 0005 §2.4 convention: `BLAKE3("mbongo:compute-input:v1" || canonical bytes)`. + pub fn input_commitment(&self) -> [u8; 32] { + crate::execution::reference_input_commitment(&self.canonical_bytes()) + } +} + +/// The chat prompt (§13): the template is the system message verbatim and +/// the CV text is the user message. No substitution, no other message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Prompt<'a> { + /// System message: [`PROMPT_TEMPLATE`]. + pub system: &'static str, + /// User message: the CV text. + pub user: &'a str, +} + +/// Builds the prompt for an input. +pub fn build_prompt(input: &CvExtractionInputV1) -> Prompt<'_> { + Prompt { + system: PROMPT_TEMPLATE, + user: &input.cv_text, + } +} + +// ── output ─────────────────────────────────────────────────────────────── + +/// Allowed `seniority` values. +pub const SENIORITY: &[&str] = &["unknown", "junior", "mid", "senior", "lead", "executive"]; +/// Allowed language `level` values. +pub const LANGUAGE_LEVEL: &[&str] = &["unknown", "basic", "intermediate", "fluent", "native"]; + +/// A position (`experience[]`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Experience { + /// ≤ 120 characters. + pub title: String, + /// ≤ 120 characters. + pub organization: String, + /// ≤ 10 characters, as written. + pub start: String, + /// ≤ 10 characters, as written, or `"present"`. + pub end: String, + /// ≤ 300 characters. + pub description: String, +} + +/// A degree or programme (`education[]`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Education { + /// ≤ 120 characters. + pub degree: String, + /// ≤ 120 characters. + pub institution: String, + /// ≤ 10 characters. + pub year: String, +} + +/// A language (`languages[]`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Language { + /// ≤ 40 characters. + pub language: String, + /// One of [`LANGUAGE_LEVEL`]. + pub level: String, +} + +/// The typed form of `kaayu-cv-profile:v1`. Field bounds are character +/// counts (Unicode scalar values), matching JSON Schema `maxLength`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CandidateProfileV1 { + /// ≤ 600 characters. + pub summary: String, + /// One of [`SENIORITY`]. + pub seniority: String, + /// ≤ 120 characters; city, region or country only. + pub location: String, + /// ≤ 40 items of ≤ 60 characters. + pub skills: Vec, + /// ≤ 15 items. + pub experience: Vec, + /// ≤ 10 items. + pub education: Vec, + /// ≤ 10 items. + pub languages: Vec, + /// ≤ 15 items of ≤ 100 characters. + pub certifications: Vec, +} + +impl CandidateProfileV1 { + /// Parses raw model output (any JSON inside the constrained model) and + /// validates it against the schema. Errors name the field, never the + /// content. + pub fn from_model_output(raw: &str) -> Result { + if raw.len() > MAX_OUTPUT_BYTES { + return Err(ContractError::OutputSchemaInvalid( + "output exceeds max_output_bytes".into(), + )); + } + let v = canonical::parse(raw, MAX_OUTPUT_BYTES) + .map_err(|e| ContractError::OutputNotJson(e.to_string()))?; + Self::from_value(&v) + } + + /// Validates a parsed value against the schema. + pub fn from_value(v: &Value) -> Result { + let o = Obj::new( + v, + "profile", + &[ + "certifications", + "education", + "experience", + "languages", + "location", + "seniority", + "skills", + "summary", + ], + )?; + let profile = Self { + summary: o.str("summary", 600)?, + seniority: o.enumerated("seniority", SENIORITY)?, + location: o.str("location", 120)?, + skills: o.str_array("skills", 40, 60)?, + experience: o + .array("experience", 15)? + .iter() + .map(|e| { + let e = Obj::new( + e, + "experience[]", + &["description", "end", "organization", "start", "title"], + )?; + Ok(Experience { + title: e.str("title", 120)?, + organization: e.str("organization", 120)?, + start: e.str("start", 10)?, + end: e.str("end", 10)?, + description: e.str("description", 300)?, + }) + }) + .collect::>()?, + education: o + .array("education", 10)? + .iter() + .map(|e| { + let e = Obj::new(e, "education[]", &["degree", "institution", "year"])?; + Ok(Education { + degree: e.str("degree", 120)?, + institution: e.str("institution", 120)?, + year: e.str("year", 10)?, + }) + }) + .collect::>()?, + languages: o + .array("languages", 10)? + .iter() + .map(|e| { + let e = Obj::new(e, "languages[]", &["language", "level"])?; + Ok(Language { + language: e.str("language", 40)?, + level: e.enumerated("level", LANGUAGE_LEVEL)?, + }) + }) + .collect::>()?, + certifications: o.str_array("certifications", 15, 100)?, + }; + Ok(profile) + } + + /// The constrained canonical model of this profile. + pub fn to_value(&self) -> Value { + use canonical::build::{obj, s}; + obj(vec![ + ( + "certifications", + Value::Array(self.certifications.iter().map(|c| s(c)).collect()), + ), + ( + "education", + Value::Array( + self.education + .iter() + .map(|e| { + obj(vec![ + ("degree", s(&e.degree)), + ("institution", s(&e.institution)), + ("year", s(&e.year)), + ]) + }) + .collect(), + ), + ), + ( + "experience", + Value::Array( + self.experience + .iter() + .map(|e| { + obj(vec![ + ("description", s(&e.description)), + ("end", s(&e.end)), + ("organization", s(&e.organization)), + ("start", s(&e.start)), + ("title", s(&e.title)), + ]) + }) + .collect(), + ), + ), + ( + "languages", + Value::Array( + self.languages + .iter() + .map(|l| obj(vec![("language", s(&l.language)), ("level", s(&l.level))])) + .collect(), + ), + ), + ("location", s(&self.location)), + ("seniority", s(&self.seniority)), + ( + "skills", + Value::Array(self.skills.iter().map(|c| s(c)).collect()), + ), + ("summary", s(&self.summary)), + ]) + } + + /// The canonical result bytes: what the worker persists and what + /// `output_commitment` commits to (§15, §17). + pub fn canonical_bytes(&self) -> Vec { + canonical::serialize(&self.to_value()) + } + + /// RFC 0005 §2.4 convention: `BLAKE3("mbongo:compute-output:v1" || canonical bytes)`. + pub fn output_commitment(&self) -> [u8; 32] { + crate::execution::reference_output_commitment(&self.canonical_bytes()) + } +} + +/// Strict object access for the output validator. +struct Obj<'a> { + members: &'a std::collections::BTreeMap, + what: &'static str, +} + +impl<'a> Obj<'a> { + fn new(v: &'a Value, what: &'static str, expected: &[&str]) -> Result { + let Value::Object(members) = v else { + return Err(ContractError::OutputSchemaInvalid(format!( + "{what} must be an object" + ))); + }; + let keys: Vec<&str> = members.keys().map(String::as_str).collect(); + if keys != expected { + return Err(ContractError::OutputSchemaInvalid(format!( + "{what} must have exactly the fields {expected:?}" + ))); + } + Ok(Self { members, what }) + } + + fn get(&self, key: &str) -> Result<&'a Value, ContractError> { + self.members.get(key).ok_or_else(|| { + ContractError::OutputSchemaInvalid(format!("{}.{key} missing", self.what)) + }) + } + + fn str(&self, key: &str, max_chars: usize) -> Result { + match self.get(key)? { + Value::String(s) if s.chars().count() <= max_chars => Ok(s.clone()), + Value::String(_) => Err(ContractError::OutputSchemaInvalid(format!( + "{}.{key} exceeds {max_chars} characters", + self.what + ))), + _ => Err(ContractError::OutputSchemaInvalid(format!( + "{}.{key} must be a string", + self.what + ))), + } + } + + fn enumerated(&self, key: &str, allowed: &[&str]) -> Result { + let s = self.str(key, 32)?; + if allowed.contains(&s.as_str()) { + Ok(s) + } else { + Err(ContractError::OutputSchemaInvalid(format!( + "{}.{key} is not one of the allowed values", + self.what + ))) + } + } + + fn array(&self, key: &str, max_items: usize) -> Result<&'a [Value], ContractError> { + match self.get(key)? { + Value::Array(items) if items.len() <= max_items => Ok(items), + Value::Array(_) => Err(ContractError::OutputSchemaInvalid(format!( + "{}.{key} exceeds {max_items} items", + self.what + ))), + _ => Err(ContractError::OutputSchemaInvalid(format!( + "{}.{key} must be an array", + self.what + ))), + } + } + + fn str_array( + &self, + key: &str, + max_items: usize, + max_chars: usize, + ) -> Result, ContractError> { + self.array(key, max_items)? + .iter() + .map(|item| match item { + Value::String(s) if s.chars().count() <= max_chars => Ok(s.clone()), + Value::String(_) => Err(ContractError::OutputSchemaInvalid(format!( + "{}.{key}[] exceeds {max_chars} characters", + self.what + ))), + _ => Err(ContractError::OutputSchemaInvalid(format!( + "{}.{key}[] must be a string", + self.what + ))), + }) + .collect() + } +} diff --git a/crates/mbongo-compute/src/inference/mod.rs b/crates/mbongo-compute/src/inference/mod.rs new file mode 100644 index 0000000..4e4c38f --- /dev/null +++ b/crates/mbongo-compute/src/inference/mod.rs @@ -0,0 +1,315 @@ +//! The MVP inference execution contract, `mbongo-infer:v1` (MVP-A). +//! +//! **Non-consensus.** This module is the executable form of +//! `docs/product/MVP_INFERENCE_CONTRACT_v1.md`: how a `ComputeTask` says +//! *which* inference it means — contract version, runtime, model artifact, +//! application profile, generation parameters — without any change to the +//! frozen v0.4 protocol. The chain sees 47 opaque bytes in `execution_spec` +//! (RFC 0005 §2.12); everything else lives off-chain. +//! +//! | Layer | Module | What it fixes | +//! |---|---|---| +//! | `execution_spec` layout | [`execution_spec`] | tag `mbongo-infer:v1` ‖ 32-byte job-spec digest | +//! | canonical bytes | [`canonical`] | the constrained canonical JSON both job specs and results use | +//! | job spec | [`job_spec`] | `InferenceJobSpecV1`, its canonical form, digest and validation | +//! | the MVP profile | [`kaayu`] | `kaayu-cv-extract:v1`: pinned runtime, model, prompt, schema, parameters; input and output types | +//! | worker gate | [`preflight`], [`finish`] | the exact pre-execution validation order and the post-execution result rule | +//! +//! Nothing here runs a model. MVP-B implements the execution profile on +//! top of [`preflight`] and [`finish`]; it must not reinterpret anything +//! this module decides. + +pub mod canonical; +pub mod execution_spec; +pub mod job_spec; +pub mod kaayu; + +use execution_spec::InferenceExecutionSpec; +use job_spec::InferenceJobSpecV1; +use kaayu::{CandidateProfileV1, CvExtractionInputV1}; + +/// Application-level failure classes (§16). None is a consensus error; the +/// worker maps every one of them to E's `FailureClass` as MVP-B decides. +/// Variants never carry input, prompt or output content. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ContractError { + /// The `execution_spec` names an inference contract version this + /// implementation does not support. + #[error("UNSUPPORTED_INFERENCE_SPEC_VERSION")] + UnsupportedInferenceSpecVersion, + /// The `execution_spec` is not a well-formed inference spec. + #[error("MALFORMED_EXECUTION_SPEC: {0}")] + MalformedExecutionSpec(&'static str), + /// No job spec with the committed digest is known to this worker. + #[error("JOB_SPEC_NOT_FOUND")] + JobSpecNotFound, + /// The resolved job spec does not hash to the committed digest. + #[error("JOB_SPEC_DIGEST_MISMATCH")] + JobSpecDigestMismatch, + /// The job spec violates the schema or an identity rule. + #[error("JOB_SPEC_INVALID: {0}")] + JobSpecInvalid(String), + /// The job spec names a runtime family or mode this worker cannot run. + #[error("UNSUPPORTED_RUNTIME")] + UnsupportedRuntime, + /// The worker's runtime revision or commit differs from the job spec. + #[error("RUNTIME_REVISION_MISMATCH")] + RuntimeRevisionMismatch, + /// The worker has no model artifact configured. + #[error("MODEL_NOT_FOUND")] + ModelNotFound, + /// The worker's model artifact digest differs from the job spec. + #[error("MODEL_DIGEST_MISMATCH")] + ModelDigestMismatch, + /// The private input is not a valid input object for the profile. + #[error("INVALID_INPUT_FORMAT: {0}")] + InvalidInputFormat(&'static str), + /// The private input exceeds the profile's byte bound. + #[error("INPUT_TOO_LARGE")] + InputTooLarge, + /// The private input does not hash to `task.input_commitment`. + #[error("INPUT_COMMITMENT_MISMATCH")] + InputCommitmentMismatch, + /// Generation parameters outside the frozen bounds. + #[error("INVALID_GENERATION_PARAMETERS: {0}")] + InvalidGenerationParameters(String), + /// The runtime failed to produce an output. + #[error("INFERENCE_FAILED: {0}")] + InferenceFailed(String), + /// The model output is not JSON inside the constrained model. + #[error("OUTPUT_NOT_JSON: {0}")] + OutputNotJson(String), + /// The model output is JSON but violates the output schema. + #[error("OUTPUT_SCHEMA_INVALID: {0}")] + OutputSchemaInvalid(String), + /// The canonical result could not be persisted. + #[error("RESULT_PERSIST_FAILED: {0}")] + ResultPersistFailed(String), +} + +impl ContractError { + /// The stable error code (the taxonomy name without detail). + pub fn code(&self) -> &'static str { + match self { + Self::UnsupportedInferenceSpecVersion => "UNSUPPORTED_INFERENCE_SPEC_VERSION", + Self::MalformedExecutionSpec(_) => "MALFORMED_EXECUTION_SPEC", + Self::JobSpecNotFound => "JOB_SPEC_NOT_FOUND", + Self::JobSpecDigestMismatch => "JOB_SPEC_DIGEST_MISMATCH", + Self::JobSpecInvalid(_) => "JOB_SPEC_INVALID", + Self::UnsupportedRuntime => "UNSUPPORTED_RUNTIME", + Self::RuntimeRevisionMismatch => "RUNTIME_REVISION_MISMATCH", + Self::ModelNotFound => "MODEL_NOT_FOUND", + Self::ModelDigestMismatch => "MODEL_DIGEST_MISMATCH", + Self::InvalidInputFormat(_) => "INVALID_INPUT_FORMAT", + Self::InputTooLarge => "INPUT_TOO_LARGE", + Self::InputCommitmentMismatch => "INPUT_COMMITMENT_MISMATCH", + Self::InvalidGenerationParameters(_) => "INVALID_GENERATION_PARAMETERS", + Self::InferenceFailed(_) => "INFERENCE_FAILED", + Self::OutputNotJson(_) => "OUTPUT_NOT_JSON", + Self::OutputSchemaInvalid(_) => "OUTPUT_SCHEMA_INVALID", + Self::ResultPersistFailed(_) => "RESULT_PERSIST_FAILED", + } + } +} + +/// Where a worker finds the job spec a digest commits to. In the MVP the +/// only registry is the repository-defined profile set +/// ([`RepositoryProfiles`]); a network registry is out of scope. +pub trait JobSpecRegistry { + /// The job spec committed by `digest`, if this worker knows it. + fn resolve(&self, digest: &[u8; 32]) -> Option; +} + +/// The repository-defined profiles: exactly `kaayu-cv-extract:v1`. +#[derive(Debug, Default, Clone, Copy)] +pub struct RepositoryProfiles; + +impl JobSpecRegistry for RepositoryProfiles { + fn resolve(&self, digest: &[u8; 32]) -> Option { + let spec = kaayu::job_spec(); + (spec.digest() == *digest).then_some(spec) + } +} + +/// What a worker knows about itself at start-up (§8–§10, §18): the runtime +/// it is running and the digest of the model file it loaded. Both are +/// compared to the job spec before any execution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerInferenceConfig { + /// Runtime family the worker runs. + pub runtime_family: String, + /// Runtime mode. + pub runtime_mode: String, + /// Exact runtime release tag. + pub runtime_revision: String, + /// Commit the runtime was built from. + pub runtime_commit: String, + /// Lowercase hex SHA-256 of the model file the worker loaded, if any. + pub model_artifact_sha256: Option, +} + +/// Everything [`preflight`] established; MVP-B executes from this and from +/// nothing else. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Preflight { + /// The decoded `execution_spec`. + pub execution_spec: InferenceExecutionSpec, + /// The committed job spec, verified against the digest. + pub job_spec: InferenceJobSpecV1, + /// The validated private input. + pub input: CvExtractionInputV1, +} + +// ── the individual gates, in contract order ────────────────────────────── + +/// Step 1: parse the `execution_spec`. +pub fn decode_execution_spec( + execution_spec: &[u8], +) -> Result { + InferenceExecutionSpec::decode(execution_spec) +} + +/// Steps 2–4: resolve the job spec, verify its digest against the committed +/// one, validate it. +pub fn resolve_job_spec( + registry: &dyn JobSpecRegistry, + spec: &InferenceExecutionSpec, +) -> Result { + let job_spec = registry.resolve(&spec.job_spec_digest).ok_or(ContractError::JobSpecNotFound)?; + if job_spec.digest() != spec.job_spec_digest { + return Err(ContractError::JobSpecDigestMismatch); + } + job_spec.validate()?; + Ok(job_spec) +} + +/// Step 5: the worker's runtime must be exactly the job spec's. +pub fn check_runtime( + job_spec: &InferenceJobSpecV1, + cfg: &WorkerInferenceConfig, +) -> Result<(), ContractError> { + let r = &job_spec.runtime; + if cfg.runtime_family != r.family || cfg.runtime_mode != r.mode { + return Err(ContractError::UnsupportedRuntime); + } + if cfg.runtime_revision != r.revision || cfg.runtime_commit != r.commit { + return Err(ContractError::RuntimeRevisionMismatch); + } + Ok(()) +} + +/// Step 6: the loaded model file must hash to the job spec's artifact +/// digest. +pub fn check_model( + job_spec: &InferenceJobSpecV1, + cfg: &WorkerInferenceConfig, +) -> Result<(), ContractError> { + let loaded = cfg.model_artifact_sha256.as_deref().ok_or(ContractError::ModelNotFound)?; + if loaded != job_spec.model.artifact_sha256 { + return Err(ContractError::ModelDigestMismatch); + } + Ok(()) +} + +/// Step 7: the fetched input must hash to `task.input_commitment` under the +/// RFC 0005 §2.4 convention. The worker lifecycle already enforces this +/// before the profile runs (F §7); the contract re-checks it so that the +/// profile is safe to call on its own. +pub fn check_input_commitment( + input: &[u8], + input_commitment: &[u8; 32], +) -> Result<(), ContractError> { + if crate::execution::reference_input_commitment(input) != *input_commitment { + return Err(ContractError::InputCommitmentMismatch); + } + Ok(()) +} + +/// Step 8: the input must be a valid input object of the profile and within +/// the job spec's byte limit. +pub fn validate_input( + job_spec: &InferenceJobSpecV1, + input: &[u8], +) -> Result { + if job_spec.profile.input_contract != kaayu::INPUT_CONTRACT_V1 { + return Err(ContractError::InvalidInputFormat( + "profile input contract not supported", + )); + } + if input.len() as u64 > job_spec.limits.max_input_bytes { + return Err(ContractError::InputTooLarge); + } + CvExtractionInputV1::from_bytes(input) +} + +/// The complete pre-execution gate in contract order (§17): +/// +/// 1. parse `execution_spec` +/// 2. resolve the job spec by digest +/// 3. verify the resolved spec's digest equals the committed digest +/// 4. validate the job spec (schema, identity rules, generation bounds) +/// 5. runtime family, mode, revision and commit match the worker +/// 6. model artifact digest matches the loaded model +/// 7. input commitment matches the fetched bytes +/// 8. input object valid for the profile and within its limit +/// +/// Any failure means: no execution, no result, no receipt. +pub fn preflight( + execution_spec: &[u8], + registry: &dyn JobSpecRegistry, + cfg: &WorkerInferenceConfig, + input: &[u8], + input_commitment: &[u8; 32], +) -> Result { + let spec = decode_execution_spec(execution_spec)?; + let job_spec = resolve_job_spec(registry, &spec)?; + check_runtime(&job_spec, cfg)?; + check_model(&job_spec, cfg)?; + check_input_commitment(input, input_commitment)?; + let input = validate_input(&job_spec, input)?; + Ok(Preflight { + execution_spec: spec, + job_spec, + input, + }) +} + +/// The canonical result of a successful execution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalResult { + /// The validated profile. + pub profile: CandidateProfileV1, + /// The canonical bytes the worker persists. + pub bytes: Vec, + /// `BLAKE3("mbongo:compute-output:v1" || bytes)`. + pub output_commitment: [u8; 32], +} + +/// The post-execution rule (§15, §17 steps 9–10): the raw model output must +/// be JSON, must satisfy the output schema, and is then canonicalized. The +/// canonical bytes — not the raw output — are what the worker persists and +/// what the receipt's `output_commitment` commits to. Any failure means no +/// result and no receipt. +pub fn finish( + job_spec: &InferenceJobSpecV1, + raw_output: &str, +) -> Result { + if job_spec.profile.output_contract != kaayu::OUTPUT_CONTRACT_V1 { + return Err(ContractError::OutputSchemaInvalid( + "profile output contract not supported".into(), + )); + } + if raw_output.len() as u64 > job_spec.limits.max_output_bytes { + return Err(ContractError::OutputSchemaInvalid( + "output exceeds max_output_bytes".into(), + )); + } + let profile = CandidateProfileV1::from_model_output(raw_output)?; + let bytes = profile.canonical_bytes(); + let output_commitment = profile.output_commitment(); + Ok(CanonicalResult { + profile, + bytes, + output_commitment, + }) +} diff --git a/crates/mbongo-compute/src/lib.rs b/crates/mbongo-compute/src/lib.rs index b308edc..7288971 100644 --- a/crates/mbongo-compute/src/lib.rs +++ b/crates/mbongo-compute/src/lib.rs @@ -62,5 +62,6 @@ pub mod control_plane; pub mod data_plane; pub mod execution; pub mod identity; +pub mod inference; pub mod policy; pub mod worker; diff --git a/crates/mbongo-compute/tests/inference_contract.rs b/crates/mbongo-compute/tests/inference_contract.rs new file mode 100644 index 0000000..80cc54a --- /dev/null +++ b/crates/mbongo-compute/tests/inference_contract.rs @@ -0,0 +1,821 @@ +//! Mbongo MVP Inference Contract: the named test group for +//! `docs/product/MVP_INFERENCE_CONTRACT_v1.md` (`mbongo-infer:v1`, +//! `kaayu-cv-extract:v1`). Pins the golden vector in +//! `test-vectors/inference/`, drives every validation step of the worker +//! gate, and proves that three deliberately blind pipelines accept what the +//! strict gate refuses. No model runs here. + +use std::collections::BTreeMap; + +use mbongo_compute::execution::{reference_input_commitment, REVERSE_BYTES_SPEC}; +use mbongo_compute::inference::canonical::{self, Value}; +use mbongo_compute::inference::execution_spec::{ + InferenceExecutionSpec, EXECUTION_SPEC_LEN, INFER_TAG_V1, +}; +use mbongo_compute::inference::job_spec::{ + digest_of_canonical_bytes, InferenceJobSpecV1, DOMAIN_JOB_SPEC, MAX_JOB_SPEC_BYTES, +}; +use mbongo_compute::inference::kaayu::{ + self, CandidateProfileV1, CvExtractionInputV1, MAX_INPUT_BYTES, MAX_OUTPUT_BYTES, +}; +use mbongo_compute::inference::{ + check_input_commitment, check_model, check_runtime, decode_execution_spec, finish, preflight, + resolve_job_spec, validate_input, ContractError, JobSpecRegistry, RepositoryProfiles, + WorkerInferenceConfig, +}; +use mbongo_core::{ComputeTask, Receipt, MAX_EXECUTION_SPEC_BYTES}; + +const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../test-vectors/inference"); + +fn fixture(name: &str) -> serde_json::Value { + let path = format!("{FIXTURES}/{name}"); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}")); + serde_json::from_str(&text).unwrap_or_else(|e| panic!("{path}: {e}")) +} + +fn hex_field(v: &serde_json::Value, key: &str) -> Vec { + hex::decode(v[key].as_str().unwrap_or_else(|| panic!("{key} missing"))).expect("hex") +} + +fn hex32(v: &serde_json::Value, key: &str) -> [u8; 32] { + hex_field(v, key).try_into().expect("32 bytes") +} + +fn worker_config() -> WorkerInferenceConfig { + let spec = kaayu::job_spec(); + WorkerInferenceConfig { + runtime_family: spec.runtime.family.clone(), + runtime_mode: spec.runtime.mode.clone(), + runtime_revision: spec.runtime.revision.clone(), + runtime_commit: spec.runtime.commit.clone(), + model_artifact_sha256: Some(spec.model.artifact_sha256.clone()), + } +} + +fn synthetic_input() -> CvExtractionInputV1 { + let g = fixture("input-example-v1.json"); + CvExtractionInputV1::new(g["cv_text"].as_str().unwrap().to_owned()).unwrap() +} + +fn execution_spec_bytes() -> Vec { + InferenceExecutionSpec { + job_spec_digest: kaayu::job_spec().digest(), + } + .encode() +} + +fn code(r: &Result) -> &'static str { + match r { + Ok(_) => "OK", + Err(e) => e.code(), + } +} + +// ── A01–A03: canonical form, digest, execution_spec ───────────────────── + +#[test] +fn a01_valid_job_spec_canonicalizes() { + let spec = kaayu::job_spec(); + let bytes = spec.canonical_bytes(); + assert!(canonical::is_canonical(&bytes, MAX_JOB_SPEC_BYTES)); + assert!(bytes.len() <= MAX_JOB_SPEC_BYTES); + // Only ASCII without escapes: the spec-class string rule holds. + assert!(bytes.iter().all(|b| (0x20..=0x7E).contains(b))); + assert!(!bytes.windows(2).any(|w| w == b"\\\"")); + // Parsing the pretty-printed form yields the same bytes. + let pretty = + serde_json::to_string_pretty(&serde_json::from_slice::(&bytes).unwrap()) + .unwrap(); + assert_ne!(pretty.as_bytes(), bytes.as_slice()); + assert_eq!(InferenceJobSpecV1::from_json(&pretty).unwrap(), spec); + assert_eq!( + InferenceJobSpecV1::from_json(&pretty).unwrap().canonical_bytes(), + bytes + ); +} + +#[test] +fn a02_digest_is_reproducible_and_domain_separated() { + let spec = kaayu::job_spec(); + let bytes = spec.canonical_bytes(); + let expected = mbongo_core::crypto::blake3_hash_multi(&[DOMAIN_JOB_SPEC, &bytes]); + assert_eq!(spec.digest(), expected); + assert_eq!(digest_of_canonical_bytes(&bytes), expected); + assert_eq!(kaayu::job_spec().digest(), spec.digest()); + assert_eq!(DOMAIN_JOB_SPEC, b"mbongo:infer-job-spec:v1"); + // Not the task, input or output domain. + assert_ne!(spec.digest(), mbongo_core::crypto::blake3_hash(&bytes)); + assert_ne!(spec.digest(), reference_input_commitment(&bytes)); +} + +#[test] +fn a03_execution_spec_round_trips_and_fits_the_bound() { + let bytes = execution_spec_bytes(); + assert_eq!(bytes.len(), EXECUTION_SPEC_LEN); + assert!(bytes.len() <= MAX_EXECUTION_SPEC_BYTES); + assert!(bytes.starts_with(INFER_TAG_V1)); + let decoded = decode_execution_spec(&bytes).unwrap(); + assert_eq!(decoded.job_spec_digest, kaayu::job_spec().digest()); + assert_eq!(decoded.encode(), bytes); + // A task carrying it is a valid RFC 0005 envelope (rule n). + let task = ComputeTask { + version: 1, + submitter: mbongo_core::Address([1; 32]), + executor: mbongo_core::Address([2; 32]), + salt: [0; 32], + input_commitment: synthetic_input().input_commitment(), + execution_spec: bytes, + }; + assert!(task.execution_spec.len() <= MAX_EXECUTION_SPEC_BYTES); + let _ = task.task_id(); +} + +// ── A04–A06: refusals before the job spec is even read ────────────────── + +#[test] +fn a04_unknown_version_rejected() { + let neg = fixture("execution-spec-negative-v1.json"); + for case in neg["cases"].as_array().unwrap() { + let bytes = hex_field(case, "execution_spec_hex"); + let expected = case["expected_error"].as_str().unwrap(); + let got = decode_execution_spec(&bytes); + assert_eq!(code(&got), expected, "case {}", case["name"]); + } + let v2 = [b"mbongo-infer:v2".as_slice(), &[0u8; 32]].concat(); + assert_eq!( + decode_execution_spec(&v2), + Err(ContractError::UnsupportedInferenceSpecVersion) + ); +} + +#[test] +fn a05_malformed_execution_spec_rejected() { + let good = execution_spec_bytes(); + assert!(matches!( + decode_execution_spec(&good[..46]), + Err(ContractError::MalformedExecutionSpec(_)) + )); + let mut long = good.clone(); + long.push(0); + assert!(matches!( + decode_execution_spec(&long), + Err(ContractError::MalformedExecutionSpec(_)) + )); + assert!(matches!( + decode_execution_spec(REVERSE_BYTES_SPEC), + Err(ContractError::MalformedExecutionSpec(_)) + )); + assert!(matches!( + decode_execution_spec(b""), + Err(ContractError::MalformedExecutionSpec(_)) + )); +} + +#[test] +fn a06_job_spec_digest_mismatch_rejected() { + let mut spec = decode_execution_spec(&execution_spec_bytes()).unwrap(); + spec.job_spec_digest[0] ^= 1; + assert_eq!( + resolve_job_spec(&RepositoryProfiles, &spec), + Err(ContractError::JobSpecNotFound) + ); + // A registry that answers with the wrong spec is caught by the digest re-check. + struct WrongAnswer; + impl JobSpecRegistry for WrongAnswer { + fn resolve(&self, _: &[u8; 32]) -> Option { + let mut s = kaayu::job_spec(); + s.generation.seed += 1; + Some(s) + } + } + let good = decode_execution_spec(&execution_spec_bytes()).unwrap(); + assert_eq!( + resolve_job_spec(&WrongAnswer, &good), + Err(ContractError::JobSpecDigestMismatch) + ); +} + +// ── A07–A10: identity exact-match ─────────────────────────────────────── + +#[test] +fn a07_runtime_revision_exact_match() { + let spec = kaayu::job_spec(); + let mut cfg = worker_config(); + assert_eq!(check_runtime(&spec, &cfg), Ok(())); + cfg.runtime_revision = "v0.7.1".into(); + assert_eq!( + check_runtime(&spec, &cfg), + Err(ContractError::RuntimeRevisionMismatch) + ); + let mut cfg = worker_config(); + cfg.runtime_commit = format!("{}0", &cfg.runtime_commit[..39]); + assert_eq!( + check_runtime(&spec, &cfg), + Err(ContractError::RuntimeRevisionMismatch) + ); + let mut cfg = worker_config(); + cfg.runtime_family = "vllm".into(); + assert_eq!( + check_runtime(&spec, &cfg), + Err(ContractError::UnsupportedRuntime) + ); + // Floating names are not revisions at all. + for floating in ["latest", "current", "stable", "main", ""] { + let mut s = kaayu::job_spec(); + s.runtime.revision = floating.into(); + assert!( + matches!(s.validate(), Err(ContractError::JobSpecInvalid(_))), + "{floating:?}" + ); + } +} + +#[test] +fn a08_model_artifact_digest_exact_match() { + let spec = kaayu::job_spec(); + let mut cfg = worker_config(); + assert_eq!(check_model(&spec, &cfg), Ok(())); + cfg.model_artifact_sha256 = Some(format!("0{}", &spec.model.artifact_sha256[1..])); + assert_eq!( + check_model(&spec, &cfg), + Err(ContractError::ModelDigestMismatch) + ); + cfg.model_artifact_sha256 = None; + assert_eq!(check_model(&spec, &cfg), Err(ContractError::ModelNotFound)); + // The same human name with a different artifact is a different model. + let neg = fixture("job-spec-negative-v1.json"); + let case = &neg["cases"] + .as_array() + .unwrap() + .iter() + .find(|c| c["name"] == "model_digest_mismatch") + .unwrap(); + let other = InferenceJobSpecV1::from_json(&case["job_spec"].to_string()).unwrap(); + assert_eq!(other.model.name, spec.model.name); + assert_ne!(other.digest(), spec.digest()); + assert_eq!( + check_model(&other, &worker_config()), + Err(ContractError::ModelDigestMismatch) + ); +} + +#[test] +fn a09_input_contract_version_exact_match() { + let spec = kaayu::job_spec(); + let input = synthetic_input(); + assert!(validate_input(&spec, &input.canonical_bytes()).is_ok()); + let mut other = spec.clone(); + other.profile.input_contract = "kaayu-cv-extract-input:v2".into(); + assert_eq!( + validate_input(&other, &input.canonical_bytes()), + Err(ContractError::InvalidInputFormat( + "profile input contract not supported" + )) + ); + let wrong_tag = br#"{"contract":"kaayu-cv-extract-input:v2","cv_text":"x"}"#; + assert!(matches!( + validate_input(&spec, wrong_tag), + Err(ContractError::InvalidInputFormat(_)) + )); +} + +#[test] +fn a10_output_schema_version_exact_match() { + let spec = kaayu::job_spec(); + let g = fixture("output-canonicalization-v1.json"); + let raw = g["raw_model_output"].as_str().unwrap(); + assert!(finish(&spec, raw).is_ok()); + let mut other = spec.clone(); + other.profile.output_contract = "kaayu-cv-profile:v2".into(); + assert!(matches!( + finish(&other, raw), + Err(ContractError::OutputSchemaInvalid(_)) + )); + // The pinned schema digest is the one in the job spec. + assert_eq!( + spec.profile.output_schema_sha256, + kaayu::output_schema_sha256() + ); + assert_eq!( + spec.profile.prompt_template_sha256, + kaayu::prompt_template_sha256() + ); +} + +// ── A11–A12: bounds ───────────────────────────────────────────────────── + +#[test] +fn a11_invalid_generation_params_rejected() { + let base = kaayu::job_spec(); + type Mutation = Box; + let cases: Vec<(&str, Mutation)> = vec![ + ( + "temperature", + Box::new(|s| s.generation.temperature_milli = 201), + ), + ("top_p low", Box::new(|s| s.generation.top_p_milli = 499)), + ("top_p high", Box::new(|s| s.generation.top_p_milli = 1001)), + ( + "seed", + Box::new(|s| s.generation.seed = u64::from(u32::MAX) + 1), + ), + ("max_tokens zero", Box::new(|s| s.generation.max_tokens = 0)), + ( + "max_tokens high", + Box::new(|s| s.generation.max_tokens = 401), + ), + ]; + for (name, mutate) in cases { + let mut s = base.clone(); + mutate(&mut s); + assert!( + matches!( + s.validate(), + Err(ContractError::InvalidGenerationParameters(_)) + ), + "{name}" + ); + } + let neg = fixture("job-spec-negative-v1.json"); + for case in neg["cases"].as_array().unwrap() { + let got = InferenceJobSpecV1::from_json(&case["job_spec"].to_string()); + let expected = case["expected"].as_str().unwrap(); + if expected == "OK" { + assert!(got.is_ok(), "case {}: {got:?}", case["name"]); + } else { + assert_eq!(code(&got), expected, "case {}", case["name"]); + } + } +} + +#[test] +fn a12_input_too_large_rejected_by_contract_validator() { + let big = "x".repeat(MAX_INPUT_BYTES); + assert_eq!( + CvExtractionInputV1::new(big.clone()), + Err(ContractError::InputTooLarge) + ); + let spec = kaayu::job_spec(); + let bytes = format!(r#"{{"contract":"kaayu-cv-extract-input:v1","cv_text":"{big}"}}"#); + assert_eq!( + validate_input(&spec, bytes.as_bytes()), + Err(ContractError::InputTooLarge) + ); + // A spec with a tighter limit rejects an input the profile would accept. + let input = synthetic_input(); + let mut tight = spec.clone(); + tight.limits.max_input_bytes = 10; + assert_eq!( + validate_input(&tight, &input.canonical_bytes()), + Err(ContractError::InputTooLarge) + ); + // Empty and control characters. + assert!(matches!( + CvExtractionInputV1::new(" \n".into()), + Err(ContractError::InvalidInputFormat(_)) + )); + assert!(matches!( + CvExtractionInputV1::new("a\u{0}b".into()), + Err(ContractError::InvalidInputFormat(_)) + )); +} + +// ── A13: output canonicalization ──────────────────────────────────────── + +#[test] +fn a13_canonical_output_is_stable() { + let spec = kaayu::job_spec(); + let g = fixture("output-canonicalization-v1.json"); + let raw = g["raw_model_output"].as_str().unwrap(); + let r = finish(&spec, raw).unwrap(); + assert_eq!(r.bytes, hex_field(&g, "canonical_bytes_hex")); + assert_eq!(r.output_commitment, hex32(&g, "output_commitment")); + assert!(canonical::is_canonical(&r.bytes, MAX_OUTPUT_BYTES)); + // Whitespace and key order do not change the commitment; content does. + let reordered = g["raw_model_output_reordered"].as_str().unwrap(); + assert_ne!(reordered, raw); + assert_eq!( + finish(&spec, reordered).unwrap().output_commitment, + r.output_commitment + ); + let mut changed = r.profile.clone(); + changed.seniority = "junior".into(); + assert_ne!(changed.output_commitment(), r.output_commitment); + // Idempotent. + let again = finish(&spec, std::str::from_utf8(&r.bytes).unwrap()).unwrap(); + assert_eq!(again.bytes, r.bytes); + // Invalid outputs. + for case in g["invalid"].as_array().unwrap() { + let raw = case["raw_model_output"].as_str().unwrap(); + assert_eq!( + code(&finish(&spec, raw)), + case["expected_error"].as_str().unwrap(), + "{}", + case["name"] + ); + } +} + +// ── A14–A17: what moves the digest, and what never enters execution_spec ─ + +#[test] +fn a14_model_or_runtime_change_changes_the_digest() { + let base = kaayu::job_spec(); + let d0 = base.digest(); + let mut s = base.clone(); + s.model.artifact_sha256 = format!("0{}", &s.model.artifact_sha256[1..]); + assert_ne!(s.digest(), d0); + let mut s = base.clone(); + s.runtime.revision = "v0.8.0".into(); + assert_ne!(s.digest(), d0); + let mut s = base.clone(); + s.profile.prompt_template_sha256 = format!("0{}", &s.profile.prompt_template_sha256[1..]); + assert_ne!(s.digest(), d0); + let mut s = base.clone(); + s.generation.seed = 43; + assert_ne!(s.digest(), d0); + let mut s = base.clone(); + s.profile.version = 2; + assert_ne!(s.digest(), d0); + // And a changed digest is a different execution_spec. + assert_ne!( + InferenceExecutionSpec { + job_spec_digest: s.digest() + } + .encode(), + execution_spec_bytes() + ); +} + +#[test] +fn a15_input_payload_does_not_affect_the_job_spec_digest() { + let spec = kaayu::job_spec(); + let a = CvExtractionInputV1::new("First synthetic CV. Skills: Rust.".into()).unwrap(); + let b = CvExtractionInputV1::new("Second synthetic CV. Skills: Go.".into()).unwrap(); + assert_ne!(a.input_commitment(), b.input_commitment()); + // The execution_spec is a function of the job spec only. + let es = InferenceExecutionSpec { + job_spec_digest: spec.digest(), + } + .encode(); + assert_eq!(es, execution_spec_bytes()); + // input_commitment and job_spec_digest are different authorities. + assert_ne!(a.input_commitment(), spec.digest()); +} + +#[test] +fn a16_private_input_absent_from_execution_spec() { + let input = synthetic_input(); + let es = execution_spec_bytes(); + let hay = String::from_utf8_lossy(&es); + for word in input.cv_text.split_whitespace().filter(|w| w.len() >= 4) { + assert!(!hay.contains(word), "input word {word:?} in execution_spec"); + } + assert!(!es.windows(8).any(|w| input.canonical_bytes().windows(8).any(|iw| iw == w))); + assert_eq!(es.len(), 47); +} + +#[test] +fn a17_private_result_absent_from_execution_spec_and_job_spec() { + let g = fixture("output-canonicalization-v1.json"); + let result = hex_field(&g, "canonical_bytes_hex"); + let es = execution_spec_bytes(); + let js = kaayu::job_spec().canonical_bytes(); + let text = String::from_utf8(result).unwrap(); + let profile = CandidateProfileV1::from_model_output(&text).unwrap(); + for s in [&profile.summary, &profile.location].into_iter().chain(profile.skills.iter()) { + if s.len() >= 6 { + assert!( + !String::from_utf8_lossy(&js).contains(s.as_str()), + "{s:?} in job spec" + ); + assert!(!String::from_utf8_lossy(&es).contains(s.as_str())); + } + } +} + +// ── A18–A19: wire shapes untouched ────────────────────────────────────── + +#[test] +fn a18_receipt_wire_unchanged() { + let v = serde_json::to_value(Receipt { + version: 1, + task_id: [0; 32], + input_commitment: [0; 32], + output_commitment: [0; 32], + executor: mbongo_core::Address([0; 32]), + metadata: vec![], + signature: [0; 64], + }) + .unwrap(); + let keys: Vec<&str> = v.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!( + keys, + [ + "executor", + "input_commitment", + "metadata", + "output_commitment", + "signature", + "task_id", + "version" + ] + ); + for forbidden in ["model", "runtime", "job_spec", "model_id", "runtime_id"] { + assert!(!v.as_object().unwrap().contains_key(forbidden)); + } +} + +#[test] +fn a19_compute_task_wire_unchanged() { + let task = ComputeTask { + version: 1, + submitter: mbongo_core::Address([1; 32]), + executor: mbongo_core::Address([2; 32]), + salt: [0; 32], + input_commitment: [3; 32], + execution_spec: execution_spec_bytes(), + }; + let v = serde_json::to_value(&task).unwrap(); + let keys: Vec<&str> = v.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!( + keys, + [ + "execution_spec", + "executor", + "input_commitment", + "salt", + "submitter", + "version" + ] + ); + assert_eq!(MAX_EXECUTION_SPEC_BYTES, 1024); + // The inference spec is just bytes to the envelope: SCALE length prefix + 47 bytes + // after the 22-byte task domain. + let preimage = task.task_id_preimage(); + assert_eq!(preimage.len(), 22 + 1 + 32 + 32 + 32 + 32 + 1 + 47); +} + +// ── A20: the golden vector ────────────────────────────────────────────── + +#[test] +fn a20_golden_vector_exact() { + let g = fixture("kaayu-cv-extract-v1.json"); + let spec = kaayu::job_spec(); + assert_eq!(g["contract"], "mbongo-infer:v1"); + assert_eq!(g["profile"], "kaayu-cv-extract:v1"); + assert_eq!( + spec.canonical_bytes(), + hex_field(&g, "job_spec_canonical_hex") + ); + assert_eq!( + String::from_utf8(spec.canonical_bytes()).unwrap(), + g["job_spec_canonical"].as_str().unwrap() + ); + assert_eq!(spec.digest(), hex32(&g, "job_spec_digest")); + assert_eq!(execution_spec_bytes(), hex_field(&g, "execution_spec_hex")); + assert_eq!( + g["job_spec_domain"], + std::str::from_utf8(DOMAIN_JOB_SPEC).unwrap() + ); + assert_eq!(g["prompt_template_sha256"], kaayu::prompt_template_sha256()); + assert_eq!(g["output_schema_sha256"], kaayu::output_schema_sha256()); + // The committed job-spec document in docs/ is byte-identical to the canonical bytes. + let committed = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../docs/product/inference/kaayu-cv-extract-v1/job-spec.json" + )) + .unwrap(); + assert_eq!(committed, spec.canonical_bytes()); + // The job spec object in the fixture parses to the same spec. + assert_eq!( + InferenceJobSpecV1::from_json(&g["job_spec"].to_string()).unwrap(), + spec + ); +} + +// ── A21–A24: the full gate, input vector, prompt, schema agreement ─────── + +#[test] +fn a21_preflight_order_and_full_pass() { + let input = synthetic_input(); + let bytes = input.canonical_bytes(); + let commitment = input.input_commitment(); + let cfg = worker_config(); + let p = preflight( + &execution_spec_bytes(), + &RepositoryProfiles, + &cfg, + &bytes, + &commitment, + ) + .unwrap(); + assert_eq!(p.job_spec, kaayu::job_spec()); + assert_eq!(p.input, input); + // Each step fails closed, in order: the first failing step wins. + let bad_spec = [b"mbongo-infer:v9".as_slice(), &[0; 32]].concat(); + let mut bad_cfg = cfg.clone(); + bad_cfg.model_artifact_sha256 = None; + assert_eq!( + preflight( + &bad_spec, + &RepositoryProfiles, + &bad_cfg, + &bytes, + &commitment + ), + Err(ContractError::UnsupportedInferenceSpecVersion) + ); + assert_eq!( + preflight( + &execution_spec_bytes(), + &RepositoryProfiles, + &bad_cfg, + b"garbage", + &commitment + ), + Err(ContractError::ModelNotFound) + ); + assert_eq!( + preflight( + &execution_spec_bytes(), + &RepositoryProfiles, + &cfg, + b"garbage", + &commitment + ), + Err(ContractError::InputCommitmentMismatch) + ); + let garbage_commitment = reference_input_commitment(b"garbage"); + assert!(matches!( + preflight( + &execution_spec_bytes(), + &RepositoryProfiles, + &cfg, + b"garbage", + &garbage_commitment + ), + Err(ContractError::InvalidInputFormat(_)) + )); + assert_eq!(check_input_commitment(&bytes, &commitment), Ok(())); +} + +#[test] +fn a22_input_vector_exact() { + let g = fixture("input-example-v1.json"); + let input = synthetic_input(); + assert_eq!( + input.canonical_bytes(), + hex_field(&g, "canonical_bytes_hex") + ); + assert_eq!(input.input_commitment(), hex32(&g, "input_commitment")); + assert_eq!( + CvExtractionInputV1::from_bytes(&input.canonical_bytes()).unwrap(), + input + ); + // Synthetic: no e-mail, no phone-looking run of digits. + assert!(!input.cv_text.contains('@')); + assert!(!input.cv_text.chars().filter(char::is_ascii_digit).count().gt(&40)); +} + +#[test] +fn a23_prompt_is_template_plus_input_only() { + let input = synthetic_input(); + let p = kaayu::build_prompt(&input); + assert_eq!(p.system, kaayu::PROMPT_TEMPLATE); + assert_eq!(p.user, input.cv_text); + assert!(p.system.contains("kaayu-cv-profile:v1")); + assert!(!p.system.contains(&input.cv_text)); +} + +#[test] +fn a24_typed_validator_agrees_with_the_schema_file() { + let schema: serde_json::Value = serde_json::from_str(kaayu::OUTPUT_SCHEMA).unwrap(); + let props = schema["properties"].as_object().unwrap(); + let mut keys: Vec<&str> = props.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!( + keys, + [ + "certifications", + "education", + "experience", + "languages", + "location", + "seniority", + "skills", + "summary" + ] + ); + let required: Vec<&str> = schema["required"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + let mut required_sorted = required.clone(); + required_sorted.sort_unstable(); + assert_eq!(required_sorted, keys); + assert_eq!(schema["additionalProperties"], false); + let sen: Vec<&str> = props["seniority"]["enum"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!(sen, kaayu::SENIORITY); + let lvl: Vec<&str> = props["languages"]["items"]["properties"]["level"]["enum"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!(lvl, kaayu::LANGUAGE_LEVEL); + assert_eq!(props["summary"]["maxLength"], 600); + assert_eq!(props["skills"]["maxItems"], 40); + assert_eq!(props["experience"]["maxItems"], 15); + // The validator enforces the same bound. + let mut v = BTreeMap::new(); + for k in [ + "certifications", + "education", + "experience", + "languages", + "skills", + ] { + v.insert(k.to_owned(), Value::Array(vec![])); + } + v.insert("location".into(), Value::String(String::new())); + v.insert("seniority".into(), Value::String("unknown".into())); + v.insert("summary".into(), Value::String("x".repeat(601))); + assert!(matches!( + CandidateProfileV1::from_value(&Value::Object(v)), + Err(ContractError::OutputSchemaInvalid(_)) + )); +} + +// ── negative controls: blind pipelines accept what the strict gate refuses ─ + +#[test] +fn negative_controls_are_caught() { + let input = synthetic_input(); + let bytes = input.canonical_bytes(); + let commitment = input.input_commitment(); + let cfg = worker_config(); + + // Digest-blind: resolves whatever the registry answers without re-hashing. + struct WrongAnswer; + impl JobSpecRegistry for WrongAnswer { + fn resolve(&self, _: &[u8; 32]) -> Option { + let mut s = kaayu::job_spec(); + s.generation.temperature_milli = 200; + Some(s) + } + } + let spec = decode_execution_spec(&execution_spec_bytes()).unwrap(); + let digest_blind = WrongAnswer.resolve(&spec.job_spec_digest).map(|s| s.validate().map(|()| s)); + assert!( + matches!(digest_blind, Some(Ok(_))), + "digest-blind pipeline accepted the substituted spec" + ); + assert_eq!( + resolve_job_spec(&WrongAnswer, &spec), + Err(ContractError::JobSpecDigestMismatch) + ); + + // Version-blind: reads a digest from any mbongo-infer:* spec. + let v2 = [b"mbongo-infer:v2".as_slice(), &spec.job_spec_digest].concat(); + let version_blind = InferenceExecutionSpec { + job_spec_digest: v2[15..].try_into().unwrap(), + }; + assert!( + resolve_job_spec(&RepositoryProfiles, &version_blind).is_ok(), + "version-blind accepted v2" + ); + assert_eq!( + decode_execution_spec(&v2), + Err(ContractError::UnsupportedInferenceSpecVersion) + ); + + // Model-digest-blind: trusts the model name, skips the artifact digest. + let mut other_artifact = cfg.clone(); + other_artifact.model_artifact_sha256 = Some("ab".repeat(32)); + let job_spec = kaayu::job_spec(); + let model_blind = check_runtime(&job_spec, &other_artifact) + .and_then(|()| check_input_commitment(&bytes, &commitment)) + .and_then(|()| validate_input(&job_spec, &bytes)); + assert!( + model_blind.is_ok(), + "model-digest-blind pipeline accepted another artifact" + ); + assert_eq!( + preflight( + &execution_spec_bytes(), + &RepositoryProfiles, + &other_artifact, + &bytes, + &commitment + ), + Err(ContractError::ModelDigestMismatch) + ); +} diff --git a/docs/INDEX.md b/docs/INDEX.md index 6e52c18..06a98ff 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -16,6 +16,7 @@ document disagrees with one of these, this table wins. |---|---|---| | Project vision and scope | [`VISION_v1.md`](VISION_v1.md) | NORMATIVE | | What Mbongo launches first — the MVP: first user, workload, runtime, model, worker, privacy level, provider model, payment, client surface, exit criteria, and the frozen defer list that separates MVP BLOCKING from POST-MVP and PARALLEL RESEARCH | [`product/MVP_v0.1.md`](product/MVP_v0.1.md) | NORMATIVE (FROZEN PRODUCT SCOPE; product decision, non-consensus — every protocol and architecture row above wins on its own subject) | +| What a `ComputeTask` means when it asks for the MVP inference: the `mbongo-infer:v1` `execution_spec` layout, the off-chain job spec and its canonical form and digest, runtime and model artifact identity, the `kaayu-cv-extract:v1` profile (input contract, prompt template, output schema, generation parameters), result canonicalization, the worker validation order and error taxonomy, what the receipt proves | [`product/MVP_INFERENCE_CONTRACT_v1.md`](product/MVP_INFERENCE_CONTRACT_v1.md) | NORMATIVE (APPLICATION CONTRACT, non-consensus — RFC 0005 wins on protocol; implementation in `crates/mbongo-compute/src/inference/`, vectors in `test-vectors/inference/`) | | Frozen protocol surfaces, versioning | [`specs/PROTOCOL_LOCK_v0.4.md`](specs/PROTOCOL_LOCK_v0.4.md) | NORMATIVE (FROZEN; tag `v0.4-devnet-stable` @ `fcec8ddc`; SDK `@mbongo/sdk` 0.2.0) | | Protocol surfaces before RFC 0005 | [`specs/PROTOCOL_LOCK_v0.3.md`](specs/PROTOCOL_LOCK_v0.3.md) | SUPERSEDED (historical record, kept intact) | | JSON-RPC contract | [`specs/rpc_v0.3.md`](specs/rpc_v0.3.md) | NORMATIVE (FROZEN — v0.2 plus the `ComputeTask` payload variant; independently audited by the v0.4 lock gate) | diff --git a/docs/product/MVP_INFERENCE_CONTRACT_v1.md b/docs/product/MVP_INFERENCE_CONTRACT_v1.md new file mode 100644 index 0000000..316d583 --- /dev/null +++ b/docs/product/MVP_INFERENCE_CONTRACT_v1.md @@ -0,0 +1,328 @@ +# MVP Inference Execution Contract v1 — `mbongo-infer:v1` + +**Status:** NORMATIVE APPLICATION CONTRACT — NON-CONSENSUS — MVP v0.1. +**Decided:** 2026-10-17 on baseline `38445ef278894ec31ac30b12660c6afe5d738f62` (protocol v0.4, frozen). +**Implements:** [`MVP_v0.1.md`](MVP_v0.1.md) §11 (gate MVP-A). **Implementation:** `crates/mbongo-compute/src/inference/`. **Vectors:** [`test-vectors/inference/`](../../test-vectors/inference/README.md). **Test group:** `inference_contract` (CI step "Mbongo MVP Inference Contract"). + +> **One sentence.** A `ComputeTask` whose `execution_spec` is the 47 bytes `"mbongo-infer:v1" ‖ BLAKE3("mbongo:infer-job-spec:v1" ‖ canonical job spec)` means: run exactly the runtime, model artifact, prompt template, output schema and generation parameters that job spec names, on the private input `input_commitment` commits to, and persist the canonical JSON result `output_commitment` commits to — with nothing added to the v0.4 protocol. + +--- + +## 1. Purpose + +MVP-B will build the inference worker. This document fixes every application-level decision that worker would otherwise have to invent: which model, which exact artifact, which runtime at which revision, what the input is, what output is required, which generation parameters are allowed, how the job spec is identified and committed, how the worker verifies it holds the right job spec, and how the client verifies the result corresponds to that contract. Same `execution_spec` → same committed job spec → same intended model, runtime and profile. + +## 2. Scope + +In scope: the `execution_spec` layout for inference; the job-spec document, its canonical form, digest and validation; runtime and model identity rules; the MVP profile `kaayu-cv-extract:v1` (input contract, prompt template, output schema, generation parameters, limits); result canonicalization; the worker's pre- and post-execution validation order; error taxonomy; retry and drift rules; privacy, logging and observability fields; what the receipt proves. + +Out of scope: running llama.cpp (MVP-B), any client transport or CLI (MVP-C), GPU, agents, pricing, metering as a payment basis, MBO, a network profile registry, output-correctness verification. + +## 3. Authority boundary + +| Requirement | Existing authority | Current meaning | Plane | Testable observable | +|---|---|---|---|---| +| `execution_spec` | [RFC 0005](../rfcs/0005-compute-task-commitment-v1.md) §2.1, §2.10 (≤ 1,024 bytes), §2.12 (opaque; application-versioned by convention) | committed, stored, hashed into `task_id`; never interpreted by consensus; public | APPLICATION | A03, A19: 47 bytes, inside the bound, task envelope unchanged | +| `task_id` | RFC 0005 §2.2 | `BLAKE3(DOMAIN_TASK ‖ SCALE(task))`; a different `execution_spec` is a different task | chain | A14: a changed job spec changes `execution_spec` and therefore `task_id` | +| `input_commitment` | RFC 0005 §2.4; reference convention `BLAKE3("mbongo:compute-input:v1" ‖ bytes)` ([reference-worker.md](../development/reference-worker.md)) | commitment to the private input object; derivation unknown to consensus | chain ↔ PRIVATE_DATA_PLANE | A15, A21, A22: input commitment is over the input object only | +| `task.executor` | RFC 0005 §2.5, rules (q)–(s) | the one address that may answer | chain | unchanged; not touched by this contract | +| private input | [F](../architecture/compute-private-data-plane-interface.md) §4, §6–§7 | an off-chain object fetched under a single-use capability and verified against `input_commitment` before execution | PRIVATE_DATA_PLANE / WORKER | A21: commitment mismatch → no execution | +| result persistence | F §10, [E](../architecture/compute-control-plane-worker-interface.md) §11 | result persisted and confirmed durable before any receipt | WORKER / PRIVATE_DATA_PLANE | unchanged lifecycle; A13 fixes which bytes are persisted | +| receipt binding | RFC 0005 rules (q)–(s); [RFC 0002](../rfcs/0002-receipt-anchoring-v0.3.md); [RECEIPT_SPEC](../specs/RECEIPT_SPEC_v0.1.md) | receipt carries `task_id`, `input_commitment`, `output_commitment`, executor, empty metadata, signature | chain | A18: receipt wire unchanged | +| execution representation | E §5–§6; [J](../architecture/compute-provider-capability-policy.md) §5.3 `execution.profile_tags` | the session advertises the profile tags it can run; eligibility requires the task's tag | CONTROL_PLANE | the inference worker advertises `mbongo-infer:v1` (§25) | +| worker profile | [reference-worker.md](../development/reference-worker.md) "REFERENCE / TEST EXECUTION PROFILE"; `execution::ExecutionProfile` | a profile answers to one `execution_spec` tag; the worker refuses any other | WORKER | A05: the reference tag is not an inference spec and vice versa | + +Nothing in this document is consensus. Where it and RFC 0005 could be read differently, RFC 0005 wins. + +## 4. Terminology + +- **job spec** — the off-chain document saying *how* to execute (`InferenceJobSpecV1`). +- **job_spec_digest** — its 32-byte BLAKE3 commitment (§8). +- **profile** — an application layer over the generic contract: input contract, prompt template, output schema, parameters. The MVP has exactly one, `kaayu-cv-extract:v1`. +- **input object** — the private bytes `input_commitment` commits to: a canonical `kaayu-cv-extract-input:v1` object (§11). +- **result** — the canonical `kaayu-cv-profile:v1` bytes `output_commitment` commits to (§15). +- **canonical** — the one byte form defined in §7. + +## 5. `execution_spec` format + +Fixed length, 47 bytes: + +| Offset | Length | Content | +|---|---|---| +| 0 | 15 | ASCII `mbongo-infer:v1` (the version lives in the tag) | +| 15 | 32 | `job_spec_digest` | + +Decoding order: the family prefix `mbongo-infer:` must match (else `MALFORMED_EXECUTION_SPEC`: not an inference spec at all); then the full tag `mbongo-infer:v1` (else `UNSUPPORTED_INFERENCE_SPEC_VERSION`); then the exact length (else `MALFORMED_EXECUTION_SPEC`). No length prefix is needed because every field is fixed-size; no separator because the tag is a fixed prefix. 47 of the 1,024 permitted bytes are used. + +The `execution_spec` carries nothing else: no CV text, no prompt, no output, no schema, no model secret, no key (A16, A17). + +## 6. Job spec schema (`InferenceJobSpecV1`) + +A flat tree of objects whose leaves are ASCII strings or non-negative integers. The field set is exact: unknown or missing fields are `JOB_SPEC_INVALID`. + +| Field | Type | Rule | +|---|---|---| +| `contract` | string | exactly `mbongo-infer:v1` (else `UNSUPPORTED_INFERENCE_SPEC_VERSION`) | +| `runtime.family` | string | `llama.cpp` (else `UNSUPPORTED_RUNTIME`) | +| `runtime.mode` | string | `llama-server` | +| `runtime.revision` | string | exact release tag `vX.Y.Z`; never `latest`, `current`, `stable`, `main`, `master`, `HEAD` or empty | +| `runtime.commit` | string | 40 lowercase hex, the commit the tag resolves to | +| `runtime.license` | string | SPDX id | +| `model.family`, `model.name` | string | human names; **not** authoritative | +| `model.source` | string | URL of the artifact repository | +| `model.source_revision` | string | 40 lowercase hex repository revision | +| `model.artifact_name` | string | file name | +| `model.artifact_sha256` | string | 64 lowercase hex — **authoritative** model identity | +| `model.artifact_size_bytes` | integer | > 0 | +| `model.format` | string | `gguf` | +| `model.quantization` | string | e.g. `Q4_K_M` | +| `model.license` | string | SPDX id | +| `profile.id` | string | e.g. `kaayu-cv-extract` | +| `profile.version` | integer | ≥ 1 | +| `profile.input_contract` | string | e.g. `kaayu-cv-extract-input:v1` | +| `profile.output_contract` | string | e.g. `kaayu-cv-profile:v1` | +| `profile.prompt_template_sha256` | string | 64 lowercase hex over the template bytes | +| `profile.output_schema_sha256` | string | 64 lowercase hex over the schema bytes | +| `generation.temperature_milli` | integer | temperature × 1000, ∈ [0, 200] | +| `generation.top_p_milli` | integer | top-p × 1000, ∈ [500, 1000] | +| `generation.seed` | integer | ≤ 2³²−1 | +| `generation.max_tokens` | integer | ∈ [1, 400] | +| `limits.max_input_bytes` | integer | ∈ [1, 10000] | +| `limits.max_output_bytes` | integer | ∈ [1, 8000] | + +Every string is non-empty, ≤ 256 bytes, printable ASCII (0x20–0x7E) without `"` or `\`, so canonical job-spec bytes contain no escape sequence. No floats exist anywhere (fractions are integer thousandths). No resource hints: provider count is one and the backend is CPU (MVP_v0.1 §6–§7); none are needed, and none are authoritative if a later profile adds them. No price: price does not affect execution identity (§23, A25). + +## 7. Job spec canonicalization (constrained canonical JSON) + +Not RFC 8785; a subset whose every rule is local and tested (`inference::canonical`): + +| Construct | Rule | +|---|---| +| object | members sorted by key, byte-wise ascending over the key's UTF-8; duplicate keys rejected | +| array | order preserved | +| string | UTF-8; `"` → `\"`, `\` → `\\`, U+0000–U+001F → `\u00xx` lowercase hex; every other scalar raw (including U+007F and non-BMP) | +| integer | non-negative decimal, no leading zeros, ≤ 2⁶⁴−1; the only number form | +| whitespace | none | +| `true`, `false`, `null`, negative or fractional numbers, exponents | rejected on parse; never produced | +| nesting | ≤ 16 levels | + +The parser accepts any RFC 8259 document inside those constraints (whitespace, escapes and surrogate pairs included); `canonicalize` is idempotent. The same canonical form serves the job spec (ASCII-only by §6), the input object and the result (full UTF-8). + +## 8. Job spec digest + +``` +job_spec_digest = BLAKE3( "mbongo:infer-job-spec:v1" ‖ canonical_job_spec_bytes ) +``` + +Domain `mbongo:infer-job-spec:v1` is distinct from `mbongo:compute-task:v1`, `mbongo:compute-input:v1`, `mbongo:compute-output:v1` and every receipt domain (A02). BLAKE3 is the repository's commitment convention (RFC 0005 §2.2, §2.4). This is an application-level commitment, not a consensus hash: no validator computes it. + +## 9. Runtime identity + +`runtime = { family: "llama.cpp", mode: "llama-server", revision: "v0.7.0", commit: "620d2225cc436ab0b001e3200e590e3b286ef9ab", license: "MIT" }`. + +Evidence: GitHub release `v0.7.0` of `ggml-org/llama.cpp`, published 2026-10-15T17:13:19Z, tag object `757cad02…` → commit `620d2225cc436ab0b001e3200e590e3b286ef9ab`; `LICENSE` at that commit is the MIT License, "Copyright (c) 2023-2026 The ggml authors". The project also publishes rolling `bNNNNN` build tags; those are not release tags and the validator refuses them as revisions (`runtime_build_number_not_release_tag`). A worker verifies at start-up that the server it runs reports exactly this tag and commit (§17 step 5, §18). Nothing of the runtime is vendored here. + +## 10. Model identity + +`model = { family: "Qwen3", name: "Qwen3-4B-Instruct-2507", source: "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", source_revision: "a06e946bb6b655725eafa393f4a9745d460374c9", artifact_name: "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", artifact_sha256: "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597", artifact_size_bytes: 2497281120, format: "gguf", quantization: "Q4_K_M", license: "Apache-2.0" }`. + +**Provenance and license.** The base model `Qwen/Qwen3-4B-Instruct-2507` is published under Apache-2.0 (model card: `license: apache-2.0`; non-thinking instruct model; llama.cpp listed among supported runtimes). No official Qwen GGUF repository for this model was reachable at decision time (the `Qwen/Qwen3-4B-Instruct-2507-GGUF` name answers 401 from the public API); the artifact is the Q4_K_M quantization published in the `unsloth` repository above (card license `apache-2.0`, `base_model: Qwen/Qwen3-4B-Instruct-2507`, most-downloaded community GGUF of this model at decision time). The digest is the Hugging Face LFS SHA-256 recorded for that file at that repository revision, obtained from the repository metadata **without downloading the artifact**; MVP-B's set-up downloads the file, recomputes SHA-256 over its bytes, and refuses to start on any difference (`MODEL_DIGEST_MISMATCH`). No model content is copied into this repository. + +**The digest is authoritative.** Two artifacts with the same human name and different bytes are different models (A08, A14); the names exist for humans and logs. + +**Tokenizer.** `TOKENIZER_SEPARATE_ID_REQUIRED = NO`: a GGUF file embeds its tokenizer (vocabulary, merges, pre-tokenizer type, special tokens) and the chat template as metadata, so the artifact digest already fixes them. A separate tokenizer identity would be redundant. + +## 11. Input contract (`kaayu-cv-extract-input:v1`) + +The private input object is the canonical form of + +``` +{"contract":"kaayu-cv-extract-input:v1","cv_text":""} +``` + +Rules: exactly those two fields; `cv_text` non-empty after trimming; the only control characters allowed are `\n`, `\r`, `\t`; canonical bytes ≤ `limits.max_input_bytes` (10,000 — aligned with the ≤ 2,500-token product bound at a conservative four bytes per token; token counts are never a contract rule, bytes are). Text extraction from PDFs stays on the client side. + +`input_commitment = BLAKE3("mbongo:compute-input:v1" ‖ canonical input bytes)` — the existing reference convention, unchanged. The job spec is **not** part of the input object and the input is **not** part of the job spec: two commitments, two authorities (A15). + +## 12. Prompt and profile identity + +Prompt construction is **worker-owned and pinned**: the chat request is exactly two messages — `system` = the prompt template bytes verbatim (`docs/product/inference/kaayu-cv-extract-v1/prompt-template.txt`, SHA-256 `6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63`), `user` = `cv_text`. No substitution, no additional message, no tool, no retrieval (A23). The model's own chat template is embedded in the GGUF and therefore covered by the artifact digest. + +`profile = { id: "kaayu-cv-extract", version: 1, input_contract: "kaayu-cv-extract-input:v1", output_contract: "kaayu-cv-profile:v1", prompt_template_sha256, output_schema_sha256 }`. A worker cannot change the instructions silently: the template digest is inside the job spec, so a changed template is a changed digest, a changed `execution_spec` and a different task. + +## 13. Output schema (`kaayu-cv-profile:v1`) + +`docs/product/inference/kaayu-cv-extract-v1/output-schema.json` (JSON Schema 2020-12, SHA-256 `81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5`). One object, `additionalProperties: false`, all fields required, **no numbers, booleans or nulls anywhere** — only strings, arrays and objects, so the canonical form of §7 is total over valid outputs: + +| Field | Type | Bound | +|---|---|---| +| `summary` | string | ≤ 600 chars | +| `seniority` | enum | `unknown` `junior` `mid` `senior` `lead` `executive` | +| `location` | string | ≤ 120 chars; city, region or country only | +| `skills` | string[] | ≤ 40 × ≤ 60 chars | +| `experience` | object[] `{title ≤120, organization ≤120, start ≤10, end ≤10, description ≤300}` | ≤ 15 | +| `education` | object[] `{degree ≤120, institution ≤120, year ≤10}` | ≤ 10 | +| `languages` | object[] `{language ≤40, level ∈ unknown basic intermediate fluent native}` | ≤ 10 | +| `certifications` | string[] | ≤ 15 × ≤ 100 chars | + +The profile is **identity-free by design**: no name, contact, address, birth date or identifier field exists, and an output that adds one is schema-invalid (`identity_field_added`). The schema uses only keywords llama.cpp's schema-to-grammar converter supports (`type`, `required`, `properties`, `additionalProperties:false`, `items`, `maxItems`, `maxLength`, `enum`); MVP-B passes it as `response_format` and measures that the grammar is honoured. Bounds are character counts, matching JSON Schema `maxLength`. The typed Rust validator `CandidateProfileV1` is the executable form of the schema; A24 asserts the field set, bounds and enumerations agree. + +## 14. Generation parameters + +`generation = { temperature_milli: 0, top_p_milli: 1000, seed: 42, max_tokens: 400 }`; allowed ranges in §6. Temperature 0 with a fixed seed is supported by llama-server (greedy decoding) and makes output variance low; it **does not** make inference bit-reproducible across CPU builds, thread counts or hardware, and this contract claims no such thing (§21). A job spec outside the ranges is `INVALID_GENERATION_PARAMETERS` before any inference (A11). + +## 15. Result canonicalization + +The worker never persists raw model bytes. Steps: parse the raw output with the §7 parser (`OUTPUT_NOT_JSON` on failure — including `null`, numbers, prose before the JSON, truncation); validate against §13 (`OUTPUT_SCHEMA_INVALID`); serialize canonically; persist exactly those bytes; `output_commitment = BLAKE3("mbongo:compute-output:v1" ‖ canonical bytes)`. Whitespace and key order therefore never change a commitment; content does (A13). The raw output is bounded by `limits.max_output_bytes` (8,000) before parsing. + +## 16. Error taxonomy + +Application-level, never consensus. Codes are the stable names; variants never carry input, prompt or output content. + +| Code | Raised when | +|---|---| +| `UNSUPPORTED_INFERENCE_SPEC_VERSION` | `mbongo-infer:` prefix with another version; or `contract` field not `mbongo-infer:v1` | +| `MALFORMED_EXECUTION_SPEC` | not an inference spec, or wrong length | +| `JOB_SPEC_NOT_FOUND` | no job spec with the committed digest is known | +| `JOB_SPEC_DIGEST_MISMATCH` | the resolved spec does not hash to the committed digest | +| `JOB_SPEC_INVALID` | schema, identity or limit rule violated | +| `UNSUPPORTED_RUNTIME` | runtime family or mode not runnable | +| `RUNTIME_REVISION_MISMATCH` | worker's tag or commit differs | +| `MODEL_NOT_FOUND` | no model loaded | +| `MODEL_DIGEST_MISMATCH` | loaded model's SHA-256 differs | +| `INVALID_INPUT_FORMAT` | input object malformed or wrong contract | +| `INPUT_TOO_LARGE` | input over the limit | +| `INPUT_COMMITMENT_MISMATCH` | fetched bytes do not hash to `task.input_commitment` | +| `INVALID_GENERATION_PARAMETERS` | parameters outside §6 ranges | +| `INFERENCE_FAILED` | the runtime produced no output | +| `OUTPUT_NOT_JSON` | raw output outside the §7 model | +| `OUTPUT_SCHEMA_INVALID` | JSON but not §13 | +| `RESULT_PERSIST_FAILED` | data plane did not confirm durability | + +MVP-B maps each to E's failure classes (`Input` for the input-side codes, `Execution` for the rest) when it reports to the control plane. + +## 17. Worker validation order + +`inference::preflight` — any failure means **no execution, no result, no receipt**: + +1. parse `execution_spec` (§5) +2. resolve the job spec by digest from the repository-defined profiles +3. verify the resolved spec's digest equals the committed digest (a registry that answers wrongly is caught: A06) +4. validate the job spec: field set, identity rules, generation and limit bounds (§6, §14) +5. runtime family, mode, revision **and commit** equal the worker's (§9) +6. model artifact SHA-256 equals the loaded model's (§10) +7. fetched input hashes to `task.input_commitment` (the worker lifecycle already enforces this before any profile runs, F §7; the contract re-checks so the profile is safe on its own) +8. input object valid for the profile and within `limits.max_input_bytes` (§11) + +then execution, then `inference::finish`: + +9. raw output ≤ `limits.max_output_bytes`; parse (§7); validate (§13); canonicalize (§15) +10. persist the canonical bytes; only after the data plane confirms durability, build the receipt with `output_commitment` over those bytes (F §10, E §11) + +A failure after inference (steps 9–10) yields no receipt of any kind: the receipt model has no failure receipt and this contract invents none. + +## 18. Retry and drift semantics + +- **Retry** happens under E's attempt semantics for the same task, the same job spec and the same input. The job spec is immutable by construction: it is identified by the digest in the task. A different model, runtime, template, schema or parameter is a different job spec, a different `execution_spec`, a different task (A14, A24). +- **Drift** fails closed. At start-up the worker records its runtime tag and commit and the SHA-256 of the model file it loaded; steps 5–6 compare them on every task. A worker running another model or revision never silently accepts the task. +- **Versioning.** `mbongo-infer:v1` changes only on a breaking change of this contract's semantics (layout, canonicalization, digest domain, job-spec schema). `kaayu-cv-extract` increments `profile.version` whenever any field that alters execution identity changes (prompt, schema, model, runtime, parameters, limits); every such change already changes the digest, and the version is the human-readable marker of it. A change of a field that does not alter execution identity has no place in the job spec. + +## 19. Privacy and logging + +| Public (on-chain, permanent) | Private (off-chain, deletable) | +|---|---| +| `ComputeTask`: submitter, executor, salt, `input_commitment`, the 47-byte `execution_spec` | CV text; the input object; the constructed prompt; the raw model response; the canonical result | +| `Receipt`: `task_id`, commitments, executor, empty metadata, signature | capabilities, leases, sessions | +| — | the job spec itself is **PUBLIC_METADATA** in classification (it names model and runtime, never CV data) but is not published on-chain; only its digest is. In the MVP it is a repository-defined document | + +Logs **may** carry: `task_id`, `job_spec_digest`, model and runtime identity, timings, token counts, error code. Logs **must not** carry, by default: CV text, the prompt, the model output, the canonical result (F §13 extended to this profile). Error variants are built so they cannot. + +## 20. Observability + +Operational fields MVP-B and MVP-C expose, none consensus: `task_id`, `status`, `runtime_id` (`family/mode/revision`), `model_id` (`name@sha256[:12]`), `job_spec_digest`, `submitted_at`, `started_at`, `completed_at`, `execution_duration_ms`, `input_tokens`, `output_tokens` (as reported by llama-server: **untrusted operational metering**, never a settlement basis), `failure_code` (§16), `receipt_tx`. The MVP's displayed reference price is configuration outside this contract (MVP_v0.1 §8). + +## 21. Receipt semantics + +The receipt proves: executor attribution (signed by the key of the executor the client named), task binding (`task_id`), input binding (`input_commitment`), output binding (`output_commitment` over the canonical result). It does **not** prove that the worker used the claimed model or runtime, that the JSON is semantically or factually correct, or that another run would produce the same bytes. In the MVP the single Mbongo-controlled worker's model and runtime execution is **operationally trusted** (MVP_v0.1 §7); the chain commitments and the receipt remain independently verifiable. Client-facing text may not call this path "verified inference" (`OUTPUT_CORRECTNESS_PROVEN = NO`). + +## 22. Golden vector + +`test-vectors/inference/kaayu-cv-extract-v1.json`, reproduced byte-exactly by test A20 and by the committed document `docs/product/inference/kaayu-cv-extract-v1/job-spec.json`: + +``` +job_spec_canonical = {"contract":"mbongo-infer:v1","generation":{"max_tokens":400,"seed":42,"temperature_milli":0,"top_p_milli":1000},"limits":{"max_input_bytes":10000,"max_output_bytes":8000},"model":{"artifact_name":"Qwen3-4B-Instruct-2507-Q4_K_M.gguf","artifact_sha256":"3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597","artifact_size_bytes":2497281120,"family":"Qwen3","format":"gguf","license":"Apache-2.0","name":"Qwen3-4B-Instruct-2507","quantization":"Q4_K_M","source":"https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF","source_revision":"a06e946bb6b655725eafa393f4a9745d460374c9"},"profile":{"id":"kaayu-cv-extract","input_contract":"kaayu-cv-extract-input:v1","output_contract":"kaayu-cv-profile:v1","output_schema_sha256":"81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5","prompt_template_sha256":"6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63","version":1},"runtime":{"commit":"620d2225cc436ab0b001e3200e590e3b286ef9ab","family":"llama.cpp","license":"MIT","mode":"llama-server","revision":"v0.7.0"}} +job_spec_digest = be9ae960334c3e917ad600e2b5220440c27dd79c5e586e998e60cbc148affe0e +execution_spec = 6d626f6e676f2d696e6665723a7631 ‖ be9ae960334c3e917ad600e2b5220440c27dd79c5e586e998e60cbc148affe0e (47 bytes) +``` + +Companion vectors: the synthetic input object and its `input_commitment` (`fca07b64…78e6`), and one schema-valid output in two non-canonical forms reducing to one canonical byte string with `output_commitment` `62f389ac…382e`. + +## 23. Invariants + +| # | Invariant | Where | +|---|---|---| +| A1 | the inference contract is non-consensus | §3; A18, A19 | +| A2 | `execution_spec` contains no private input | §5; A16 | +| A3 | `execution_spec` contains no private result | §5; A17 | +| A4 | `input_commitment` remains the input authority | §11; A15 | +| A5 | `job_spec_digest` remains the execution-contract authority | §8; A14 | +| A6 | `task.executor` remains the executor authority | §3 (untouched) | +| A7 | the job-spec digest is verified before execution | §17 step 3; A06 | +| A8 | the model artifact digest is verified before execution | step 6; A08 | +| A9 | the runtime revision (tag and commit) is verified before execution | step 5; A07 | +| A10 | model or runtime drift fails closed | §18; A07, A08, A21 | +| A11 | the input commitment is verified before execution | step 7; A21 | +| A12 | invalid input does not execute | step 8; A09, A12 | +| A13 | invalid generation parameters do not execute | step 4; A11 | +| A14 | output must be valid JSON | §15; A13 | +| A15 | output must satisfy the schema | §13; A13, A24 | +| A16 | persisted result bytes equal committed result bytes | §15, §17 step 10 | +| A17 | receipt wire unchanged | A18 | +| A18 | `ComputeTask` wire unchanged | A19 | +| A19 | inference output correctness is not proven | §21 | +| A20 | the provider may see plaintext (PUBLIC profile) | §19; MVP_v0.1 §7 | +| A21 | raw input stays off-chain | §19 | +| A22 | raw output stays off-chain | §19 | +| A23 | a retry cannot silently change the job spec | §18 | +| A24 | a different model or configuration requires a new job spec and task | §18; A14 | +| A25 | price is not part of execution identity | §6 (no price field) | +| A26 | token counts are observational only | §20 | +| A27 | the profile registry is repository-local in the MVP | `RepositoryProfiles`; §24 | +| A28 | no marketplace logic | §2 | +| A29 | no agent semantics | §2, §12 (two messages, no tools) | +| A30 | no MBO settlement | §2 | +| A31 | the same `execution_spec` always names the same job spec | §8 digest is content-derived; A20 | +| A32 | the output schema is identity-free | §13; `identity_field_added` | + +## 24. Non-goals + +A network or on-chain profile registry; a second profile, model or runtime; GPU; a plugin framework; prompt templating beyond the verbatim system message; any receipt or task field; token-economic semantics of token counts; a failure receipt; output-correctness verification (#52); hardware attestation as a requirement (K and K-HW are unchanged and remain available to a future CONFIDENTIAL profile). + +## 25. MVP-B handoff + +MVP-B implements an `ExecutionProfile` whose `spec_tag` is the 15-byte tag prefix check plus `preflight`, and must not reinterpret anything here: + +| Item | Where | +|---|---| +| `execution_spec` parser/encoder | `inference::execution_spec` | +| job-spec schema, canonical form, digest, validation | `inference::job_spec`, `inference::canonical` | +| the frozen profile, prompt, schema, parameters, limits | `inference::kaayu` and `docs/product/inference/kaayu-cv-extract-v1/` | +| golden vector and negative cases | `test-vectors/inference/` | +| runtime revision policy | §9; `check_runtime` | +| model digest policy and acquisition | §10; `check_model`; download → SHA-256 → refuse on mismatch | +| input and output types | `CvExtractionInputV1`, `CandidateProfileV1` | +| validation order | §17; `preflight`, `finish` | +| error taxonomy and E mapping | §16 | +| advertisement | the session advertises `execution.profile_tags ⊇ {"mbongo-infer:v1"}` under J; no new discovery protocol | +| what to measure | latency on the CPU reference host; that `response_format` with the §13 schema is honoured by llama-server v0.7.0 | + +The SDK wire format is unchanged (`SDK_WIRE_CHANGE = NO`); application helpers (`createInferenceExecutionSpec`, job-spec digest) are deferred to MVP-C, where they must reproduce §22 byte-for-byte. + +## See also + +- [`MVP_v0.1.md`](MVP_v0.1.md) — the frozen product scope this contract serves +- [RFC 0005](../rfcs/0005-compute-task-commitment-v1.md) §2.4, §2.10, §2.12 — commitments, the bound, opacity +- [reference-worker.md](../development/reference-worker.md) — the lifecycle MVP-B keeps +- [compute-privacy-data-plane.md](../architecture/compute-privacy-data-plane.md) §7, §13 — provider visibility, metadata privacy +- [compute-provider-capability-policy.md](../architecture/compute-provider-capability-policy.md) §5.3 — `execution.profile_tags` +- [`test-vectors/inference/README.md`](../../test-vectors/inference/README.md) diff --git a/docs/product/inference/kaayu-cv-extract-v1/job-spec.json b/docs/product/inference/kaayu-cv-extract-v1/job-spec.json new file mode 100644 index 0000000..fb185b3 --- /dev/null +++ b/docs/product/inference/kaayu-cv-extract-v1/job-spec.json @@ -0,0 +1 @@ +{"contract":"mbongo-infer:v1","generation":{"max_tokens":400,"seed":42,"temperature_milli":0,"top_p_milli":1000},"limits":{"max_input_bytes":10000,"max_output_bytes":8000},"model":{"artifact_name":"Qwen3-4B-Instruct-2507-Q4_K_M.gguf","artifact_sha256":"3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597","artifact_size_bytes":2497281120,"family":"Qwen3","format":"gguf","license":"Apache-2.0","name":"Qwen3-4B-Instruct-2507","quantization":"Q4_K_M","source":"https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF","source_revision":"a06e946bb6b655725eafa393f4a9745d460374c9"},"profile":{"id":"kaayu-cv-extract","input_contract":"kaayu-cv-extract-input:v1","output_contract":"kaayu-cv-profile:v1","output_schema_sha256":"81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5","prompt_template_sha256":"6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63","version":1},"runtime":{"commit":"620d2225cc436ab0b001e3200e590e3b286ef9ab","family":"llama.cpp","license":"MIT","mode":"llama-server","revision":"v0.7.0"}} \ No newline at end of file diff --git a/docs/product/inference/kaayu-cv-extract-v1/output-schema.json b/docs/product/inference/kaayu-cv-extract-v1/output-schema.json new file mode 100644 index 0000000..0ae0083 --- /dev/null +++ b/docs/product/inference/kaayu-cv-extract-v1/output-schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "kaayu-cv-profile:v1", + "title": "kaayu-cv-profile:v1", + "type": "object", + "additionalProperties": false, + "required": ["summary", "seniority", "location", "skills", "experience", "education", "languages", "certifications"], + "properties": { + "summary": { "type": "string", "maxLength": 600 }, + "seniority": { "type": "string", "enum": ["unknown", "junior", "mid", "senior", "lead", "executive"] }, + "location": { "type": "string", "maxLength": 120 }, + "skills": { "type": "array", "maxItems": 40, "items": { "type": "string", "maxLength": 60 } }, + "experience": { + "type": "array", "maxItems": 15, + "items": { + "type": "object", "additionalProperties": false, + "required": ["title", "organization", "start", "end", "description"], + "properties": { + "title": { "type": "string", "maxLength": 120 }, + "organization": { "type": "string", "maxLength": 120 }, + "start": { "type": "string", "maxLength": 10 }, + "end": { "type": "string", "maxLength": 10 }, + "description": { "type": "string", "maxLength": 300 } + } + } + }, + "education": { + "type": "array", "maxItems": 10, + "items": { + "type": "object", "additionalProperties": false, + "required": ["degree", "institution", "year"], + "properties": { + "degree": { "type": "string", "maxLength": 120 }, + "institution": { "type": "string", "maxLength": 120 }, + "year": { "type": "string", "maxLength": 10 } + } + } + }, + "languages": { + "type": "array", "maxItems": 10, + "items": { + "type": "object", "additionalProperties": false, + "required": ["language", "level"], + "properties": { + "language": { "type": "string", "maxLength": 40 }, + "level": { "type": "string", "enum": ["unknown", "basic", "intermediate", "fluent", "native"] } + } + } + }, + "certifications": { "type": "array", "maxItems": 15, "items": { "type": "string", "maxLength": 100 } } + } +} diff --git a/docs/product/inference/kaayu-cv-extract-v1/prompt-template.txt b/docs/product/inference/kaayu-cv-extract-v1/prompt-template.txt new file mode 100644 index 0000000..e279df6 --- /dev/null +++ b/docs/product/inference/kaayu-cv-extract-v1/prompt-template.txt @@ -0,0 +1,15 @@ +You extract a structured, identity-free candidate profile from the text of one CV. + +Rules: +1. Read only the CV text supplied in the user message. Do not use outside knowledge about any person or company. +2. Produce exactly one JSON object that conforms to the kaayu-cv-profile:v1 schema. Output nothing before or after the JSON object. No markdown, no comments, no explanations. +3. Never include the candidate's name, email address, phone number, postal address, date of birth, photo description, or any government or social identifier. "location" is a city, region or country only. +4. Never invent facts. When a field cannot be determined from the CV, use an empty string for a text field and an empty array for a list field. +5. "summary": at most three sentences describing the professional profile, written in the third person without a name. +6. "seniority": one of "unknown", "junior", "mid", "senior", "lead", "executive", judged from years of experience and titles; "unknown" when unclear. +7. "skills": distinct skill names as they appear in the CV, each a short noun phrase, most relevant first, at most 40. +8. "experience": one entry per position, most recent first, with "title", "organization", "start", "end" (use "present" for a current position, "" when unknown; dates as written, e.g. "2021-03" or "2021"), and a "description" of at most two sentences. +9. "education": one entry per degree or programme with "degree", "institution" and "year" ("" when unknown). +10. "languages": each with "language" and "level" from "unknown", "basic", "intermediate", "fluent", "native". +11. "certifications": certification or licence names only. +12. Keep every string in the language of the CV. Do not translate. diff --git a/test-vectors/inference/README.md b/test-vectors/inference/README.md new file mode 100644 index 0000000..6ed3215 --- /dev/null +++ b/test-vectors/inference/README.md @@ -0,0 +1,26 @@ +# Inference contract vectors (`mbongo-infer:v1`) + +Golden vectors for the MVP inference execution contract defined by +[`docs/product/MVP_INFERENCE_CONTRACT_v1.md`](../../docs/product/MVP_INFERENCE_CONTRACT_v1.md) +and implemented in `crates/mbongo-compute/src/inference/`. Nothing here is a +protocol vector: the chain sees only the 47 opaque `execution_spec` bytes of +RFC 0005 and never interprets them. The protocol vectors remain +`compute-task/`, `receipt/`, `rpc/` and `transaction/`. + +| File | Pins | +|---|---| +| `kaayu-cv-extract-v1.json` | the frozen MVP job spec, its canonical bytes, `job_spec_digest = BLAKE3("mbongo:infer-job-spec:v1" ‖ canonical)`, the 47-byte `execution_spec`, and the SHA-256 of the prompt template and output schema. Byte-exact (test A20). | +| `execution-spec-negative-v1.json` | `execution_spec` values the parser refuses and the error code for each (A04, A05) | +| `job-spec-negative-v1.json` | job-spec documents and the result of `InferenceJobSpecV1::from_json` for each, including a valid spec with a different model artifact digest (A08, A11) | +| `input-example-v1.json` | a **synthetic** CV text, its canonical input object and `input_commitment` (A22). No real person, no name, no contact data. | +| `output-canonicalization-v1.json` | one schema-valid model output in two non-canonical forms, the single canonical byte form, its `output_commitment`, and outputs that yield no result and no receipt (A10, A13) | + +The committed job-spec document itself is +`docs/product/inference/kaayu-cv-extract-v1/job-spec.json`: exactly the +canonical bytes, no trailing newline. The three files under that directory +are marked `-text` in `.gitattributes` so no checkout can alter the bytes +their digests pin. + +Consumers: the Rust test group `inference_contract` (CI step "Mbongo MVP +Inference Contract") today; MVP-B (worker) and MVP-C (client helpers) must +reproduce the same bytes. diff --git a/test-vectors/inference/execution-spec-negative-v1.json b/test-vectors/inference/execution-spec-negative-v1.json new file mode 100644 index 0000000..fb7bd90 --- /dev/null +++ b/test-vectors/inference/execution-spec-negative-v1.json @@ -0,0 +1,47 @@ +{ + "$comment": "Inputs decode_execution_spec must refuse, with the error code (contract sections 5 and 16).", + "cases": [ + { + "name": "reference_profile_tag", + "execution_spec_hex": "6d626f6e676f2d7265663a726576657273652d62797465733a7631", + "expected_error": "MALFORMED_EXECUTION_SPEC", + "note": "another application's tag is not an inference spec" + }, + { + "name": "empty", + "execution_spec_hex": "", + "expected_error": "MALFORMED_EXECUTION_SPEC", + "note": "empty execution_spec" + }, + { + "name": "version_v2", + "execution_spec_hex": "6d626f6e676f2d696e6665723a7632be9ae960334c3e917ad600e2b5220440c27dd79c5e586e998e60cbc148affe0e", + "expected_error": "UNSUPPORTED_INFERENCE_SPEC_VERSION", + "note": "family prefix matches, version does not" + }, + { + "name": "prefix_only", + "execution_spec_hex": "6d626f6e676f2d696e6665723a", + "expected_error": "UNSUPPORTED_INFERENCE_SPEC_VERSION", + "note": "family prefix without a version" + }, + { + "name": "too_short", + "execution_spec_hex": "6d626f6e676f2d696e6665723a7631be9ae960334c3e917ad600e2b5220440c27dd79c5e586e998e60cbc148affe", + "expected_error": "MALFORMED_EXECUTION_SPEC", + "note": "46 bytes" + }, + { + "name": "too_long", + "execution_spec_hex": "6d626f6e676f2d696e6665723a7631be9ae960334c3e917ad600e2b5220440c27dd79c5e586e998e60cbc148affe0e00", + "expected_error": "MALFORMED_EXECUTION_SPEC", + "note": "48 bytes" + }, + { + "name": "tag_only", + "execution_spec_hex": "6d626f6e676f2d696e6665723a7631", + "expected_error": "MALFORMED_EXECUTION_SPEC", + "note": "no digest" + } + ] +} diff --git a/test-vectors/inference/input-example-v1.json b/test-vectors/inference/input-example-v1.json new file mode 100644 index 0000000..da46fff --- /dev/null +++ b/test-vectors/inference/input-example-v1.json @@ -0,0 +1,7 @@ +{ + "$comment": "Synthetic CV text (no real person, no name, no contact data) and its canonical input object (contract section 11). input_commitment = BLAKE3('mbongo:compute-input:v1' || canonical_bytes).", + "contract": "kaayu-cv-extract-input:v1", + "cv_text": "Senior software engineer with nine years of experience building distributed systems in Rust and Go.\n\nExperience\nLead Backend Engineer, Example Logistics (2021-03 to present): designed the event-sourced order pipeline; mentors four engineers.\nSoftware Engineer, Sample Analytics (2017-09 to 2021-02): built streaming ingestion in Go; migrated batch jobs to Kafka.\n\nEducation\nMSc Computer Science, Fictional Institute of Technology, 2017.\n\nSkills\nRust, Go, Kafka, PostgreSQL, Kubernetes, distributed systems, observability.\n\nLanguages\nEnglish (fluent), French (native).\n\nCertifications\nCertified Kubernetes Administrator.\n", + "canonical_bytes_hex": "7b22636f6e7472616374223a226b616179752d63762d657874726163742d696e7075743a7631222c2263765f74657874223a2253656e696f7220736f66747761726520656e67696e6565722077697468206e696e65207965617273206f6620657870657269656e6365206275696c64696e672064697374726962757465642073797374656d7320696e205275737420616e6420476f2e5c75303030615c7530303061457870657269656e63655c75303030614c656164204261636b656e6420456e67696e6565722c204578616d706c65204c6f676973746963732028323032312d303320746f2070726573656e74293a2064657369676e656420746865206576656e742d736f7572636564206f7264657220706970656c696e653b206d656e746f727320666f757220656e67696e656572732e5c7530303061536f66747761726520456e67696e6565722c2053616d706c6520416e616c79746963732028323031372d303920746f20323032312d3032293a206275696c742073747265616d696e6720696e67657374696f6e20696e20476f3b206d69677261746564206261746368206a6f627320746f204b61666b612e5c75303030615c7530303061456475636174696f6e5c75303030614d536320436f6d707574657220536369656e63652c2046696374696f6e616c20496e73746974757465206f6620546563686e6f6c6f67792c20323031372e5c75303030615c7530303061536b696c6c735c7530303061527573742c20476f2c204b61666b612c20506f737467726553514c2c204b756265726e657465732c2064697374726962757465642073797374656d732c206f62736572766162696c6974792e5c75303030615c75303030614c616e6775616765735c7530303061456e676c6973682028666c75656e74292c204672656e636820286e6174697665292e5c75303030615c753030306143657274696669636174696f6e735c7530303061436572746966696564204b756265726e657465732041646d696e6973747261746f722e5c7530303061227d", + "input_commitment": "fca07b64bacb2ce8ea9ceea432f50a6c565c26e28a2adae37f5c029896e878e6" +} diff --git a/test-vectors/inference/job-spec-negative-v1.json b/test-vectors/inference/job-spec-negative-v1.json new file mode 100644 index 0000000..4ef95ae --- /dev/null +++ b/test-vectors/inference/job-spec-negative-v1.json @@ -0,0 +1,575 @@ +{ + "$comment": "Job-spec documents and what InferenceJobSpecV1::from_json returns for each (contract sections 6, 8-10, 19-20). Floating-point parameters cannot be expressed in this file at all: the constrained model has no floats (section 7).", + "cases": [ + { + "name": "model_digest_mismatch", + "expected": "OK", + "note": "a valid job spec naming the same human model with a different artifact digest: a different execution identity, refused by check_model on a worker holding the pinned artifact", + "job_spec": { + "contract": "mbongo-infer:v1", + "generation": { + "max_tokens": 400, + "seed": 42, + "temperature_milli": 0, + "top_p_milli": 1000 + }, + "limits": { + "max_input_bytes": 10000, + "max_output_bytes": 8000 + }, + "model": { + "artifact_name": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "artifact_sha256": "0605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597", + "artifact_size_bytes": 2497281120, + "family": "Qwen3", + "format": "gguf", + "license": "Apache-2.0", + "name": "Qwen3-4B-Instruct-2507", + "quantization": "Q4_K_M", + "source": "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", + "source_revision": "a06e946bb6b655725eafa393f4a9745d460374c9" + }, + "profile": { + "id": "kaayu-cv-extract", + "input_contract": "kaayu-cv-extract-input:v1", + "output_contract": "kaayu-cv-profile:v1", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "version": 1 + }, + "runtime": { + "commit": "620d2225cc436ab0b001e3200e590e3b286ef9ab", + "family": "llama.cpp", + "license": "MIT", + "mode": "llama-server", + "revision": "v0.7.0" + } + } + }, + { + "name": "runtime_latest", + "expected": "JOB_SPEC_INVALID", + "job_spec": { + "contract": "mbongo-infer:v1", + "generation": { + "max_tokens": 400, + "seed": 42, + "temperature_milli": 0, + "top_p_milli": 1000 + }, + "limits": { + "max_input_bytes": 10000, + "max_output_bytes": 8000 + }, + "model": { + "artifact_name": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "artifact_sha256": "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597", + "artifact_size_bytes": 2497281120, + "family": "Qwen3", + "format": "gguf", + "license": "Apache-2.0", + "name": "Qwen3-4B-Instruct-2507", + "quantization": "Q4_K_M", + "source": "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", + "source_revision": "a06e946bb6b655725eafa393f4a9745d460374c9" + }, + "profile": { + "id": "kaayu-cv-extract", + "input_contract": "kaayu-cv-extract-input:v1", + "output_contract": "kaayu-cv-profile:v1", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "version": 1 + }, + "runtime": { + "commit": "620d2225cc436ab0b001e3200e590e3b286ef9ab", + "family": "llama.cpp", + "license": "MIT", + "mode": "llama-server", + "revision": "latest" + } + } + }, + { + "name": "runtime_build_number_not_release_tag", + "expected": "JOB_SPEC_INVALID", + "job_spec": { + "contract": "mbongo-infer:v1", + "generation": { + "max_tokens": 400, + "seed": 42, + "temperature_milli": 0, + "top_p_milli": 1000 + }, + "limits": { + "max_input_bytes": 10000, + "max_output_bytes": 8000 + }, + "model": { + "artifact_name": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "artifact_sha256": "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597", + "artifact_size_bytes": 2497281120, + "family": "Qwen3", + "format": "gguf", + "license": "Apache-2.0", + "name": "Qwen3-4B-Instruct-2507", + "quantization": "Q4_K_M", + "source": "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", + "source_revision": "a06e946bb6b655725eafa393f4a9745d460374c9" + }, + "profile": { + "id": "kaayu-cv-extract", + "input_contract": "kaayu-cv-extract-input:v1", + "output_contract": "kaayu-cv-profile:v1", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "version": 1 + }, + "runtime": { + "commit": "620d2225cc436ab0b001e3200e590e3b286ef9ab", + "family": "llama.cpp", + "license": "MIT", + "mode": "llama-server", + "revision": "b11713" + } + } + }, + { + "name": "vllm_runtime", + "expected": "UNSUPPORTED_RUNTIME", + "job_spec": { + "contract": "mbongo-infer:v1", + "generation": { + "max_tokens": 400, + "seed": 42, + "temperature_milli": 0, + "top_p_milli": 1000 + }, + "limits": { + "max_input_bytes": 10000, + "max_output_bytes": 8000 + }, + "model": { + "artifact_name": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "artifact_sha256": "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597", + "artifact_size_bytes": 2497281120, + "family": "Qwen3", + "format": "gguf", + "license": "Apache-2.0", + "name": "Qwen3-4B-Instruct-2507", + "quantization": "Q4_K_M", + "source": "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", + "source_revision": "a06e946bb6b655725eafa393f4a9745d460374c9" + }, + "profile": { + "id": "kaayu-cv-extract", + "input_contract": "kaayu-cv-extract-input:v1", + "output_contract": "kaayu-cv-profile:v1", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "version": 1 + }, + "runtime": { + "commit": "620d2225cc436ab0b001e3200e590e3b286ef9ab", + "family": "vllm", + "license": "MIT", + "mode": "llama-server", + "revision": "v0.7.0" + } + } + }, + { + "name": "contract_v2", + "expected": "UNSUPPORTED_INFERENCE_SPEC_VERSION", + "job_spec": { + "contract": "mbongo-infer:v2", + "generation": { + "max_tokens": 400, + "seed": 42, + "temperature_milli": 0, + "top_p_milli": 1000 + }, + "limits": { + "max_input_bytes": 10000, + "max_output_bytes": 8000 + }, + "model": { + "artifact_name": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "artifact_sha256": "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597", + "artifact_size_bytes": 2497281120, + "family": "Qwen3", + "format": "gguf", + "license": "Apache-2.0", + "name": "Qwen3-4B-Instruct-2507", + "quantization": "Q4_K_M", + "source": "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", + "source_revision": "a06e946bb6b655725eafa393f4a9745d460374c9" + }, + "profile": { + "id": "kaayu-cv-extract", + "input_contract": "kaayu-cv-extract-input:v1", + "output_contract": "kaayu-cv-profile:v1", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "version": 1 + }, + "runtime": { + "commit": "620d2225cc436ab0b001e3200e590e3b286ef9ab", + "family": "llama.cpp", + "license": "MIT", + "mode": "llama-server", + "revision": "v0.7.0" + } + } + }, + { + "name": "unknown_top_level_field", + "expected": "JOB_SPEC_INVALID", + "job_spec": { + "contract": "mbongo-infer:v1", + "generation": { + "max_tokens": 400, + "seed": 42, + "temperature_milli": 0, + "top_p_milli": 1000 + }, + "limits": { + "max_input_bytes": 10000, + "max_output_bytes": 8000 + }, + "model": { + "artifact_name": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "artifact_sha256": "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597", + "artifact_size_bytes": 2497281120, + "family": "Qwen3", + "format": "gguf", + "license": "Apache-2.0", + "name": "Qwen3-4B-Instruct-2507", + "quantization": "Q4_K_M", + "source": "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", + "source_revision": "a06e946bb6b655725eafa393f4a9745d460374c9" + }, + "profile": { + "id": "kaayu-cv-extract", + "input_contract": "kaayu-cv-extract-input:v1", + "output_contract": "kaayu-cv-profile:v1", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "version": 1 + }, + "runtime": { + "commit": "620d2225cc436ab0b001e3200e590e3b286ef9ab", + "family": "llama.cpp", + "license": "MIT", + "mode": "llama-server", + "revision": "v0.7.0" + }, + "price_micro_mbo": 1 + } + }, + { + "name": "missing_section", + "expected": "JOB_SPEC_INVALID", + "job_spec": { + "contract": "mbongo-infer:v1", + "generation": { + "max_tokens": 400, + "seed": 42, + "temperature_milli": 0, + "top_p_milli": 1000 + }, + "model": { + "artifact_name": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "artifact_sha256": "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597", + "artifact_size_bytes": 2497281120, + "family": "Qwen3", + "format": "gguf", + "license": "Apache-2.0", + "name": "Qwen3-4B-Instruct-2507", + "quantization": "Q4_K_M", + "source": "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", + "source_revision": "a06e946bb6b655725eafa393f4a9745d460374c9" + }, + "profile": { + "id": "kaayu-cv-extract", + "input_contract": "kaayu-cv-extract-input:v1", + "output_contract": "kaayu-cv-profile:v1", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "version": 1 + }, + "runtime": { + "commit": "620d2225cc436ab0b001e3200e590e3b286ef9ab", + "family": "llama.cpp", + "license": "MIT", + "mode": "llama-server", + "revision": "v0.7.0" + } + } + }, + { + "name": "temperature_too_high", + "expected": "INVALID_GENERATION_PARAMETERS", + "job_spec": { + "contract": "mbongo-infer:v1", + "generation": { + "max_tokens": 400, + "seed": 42, + "temperature_milli": 201, + "top_p_milli": 1000 + }, + "limits": { + "max_input_bytes": 10000, + "max_output_bytes": 8000 + }, + "model": { + "artifact_name": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "artifact_sha256": "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597", + "artifact_size_bytes": 2497281120, + "family": "Qwen3", + "format": "gguf", + "license": "Apache-2.0", + "name": "Qwen3-4B-Instruct-2507", + "quantization": "Q4_K_M", + "source": "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", + "source_revision": "a06e946bb6b655725eafa393f4a9745d460374c9" + }, + "profile": { + "id": "kaayu-cv-extract", + "input_contract": "kaayu-cv-extract-input:v1", + "output_contract": "kaayu-cv-profile:v1", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "version": 1 + }, + "runtime": { + "commit": "620d2225cc436ab0b001e3200e590e3b286ef9ab", + "family": "llama.cpp", + "license": "MIT", + "mode": "llama-server", + "revision": "v0.7.0" + } + } + }, + { + "name": "max_tokens_too_high", + "expected": "INVALID_GENERATION_PARAMETERS", + "job_spec": { + "contract": "mbongo-infer:v1", + "generation": { + "max_tokens": 401, + "seed": 42, + "temperature_milli": 0, + "top_p_milli": 1000 + }, + "limits": { + "max_input_bytes": 10000, + "max_output_bytes": 8000 + }, + "model": { + "artifact_name": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "artifact_sha256": "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597", + "artifact_size_bytes": 2497281120, + "family": "Qwen3", + "format": "gguf", + "license": "Apache-2.0", + "name": "Qwen3-4B-Instruct-2507", + "quantization": "Q4_K_M", + "source": "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", + "source_revision": "a06e946bb6b655725eafa393f4a9745d460374c9" + }, + "profile": { + "id": "kaayu-cv-extract", + "input_contract": "kaayu-cv-extract-input:v1", + "output_contract": "kaayu-cv-profile:v1", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "version": 1 + }, + "runtime": { + "commit": "620d2225cc436ab0b001e3200e590e3b286ef9ab", + "family": "llama.cpp", + "license": "MIT", + "mode": "llama-server", + "revision": "v0.7.0" + } + } + }, + { + "name": "uppercase_sha256", + "expected": "JOB_SPEC_INVALID", + "job_spec": { + "contract": "mbongo-infer:v1", + "generation": { + "max_tokens": 400, + "seed": 42, + "temperature_milli": 0, + "top_p_milli": 1000 + }, + "limits": { + "max_input_bytes": 10000, + "max_output_bytes": 8000 + }, + "model": { + "artifact_name": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "artifact_sha256": "3605803B982CB64AEAD44F6C1B2AE36E3ACDB41D8E46C8A94C6533BC4C67E597", + "artifact_size_bytes": 2497281120, + "family": "Qwen3", + "format": "gguf", + "license": "Apache-2.0", + "name": "Qwen3-4B-Instruct-2507", + "quantization": "Q4_K_M", + "source": "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", + "source_revision": "a06e946bb6b655725eafa393f4a9745d460374c9" + }, + "profile": { + "id": "kaayu-cv-extract", + "input_contract": "kaayu-cv-extract-input:v1", + "output_contract": "kaayu-cv-profile:v1", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "version": 1 + }, + "runtime": { + "commit": "620d2225cc436ab0b001e3200e590e3b286ef9ab", + "family": "llama.cpp", + "license": "MIT", + "mode": "llama-server", + "revision": "v0.7.0" + } + } + }, + { + "name": "format_not_gguf", + "expected": "JOB_SPEC_INVALID", + "job_spec": { + "contract": "mbongo-infer:v1", + "generation": { + "max_tokens": 400, + "seed": 42, + "temperature_milli": 0, + "top_p_milli": 1000 + }, + "limits": { + "max_input_bytes": 10000, + "max_output_bytes": 8000 + }, + "model": { + "artifact_name": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "artifact_sha256": "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597", + "artifact_size_bytes": 2497281120, + "family": "Qwen3", + "format": "safetensors", + "license": "Apache-2.0", + "name": "Qwen3-4B-Instruct-2507", + "quantization": "Q4_K_M", + "source": "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", + "source_revision": "a06e946bb6b655725eafa393f4a9745d460374c9" + }, + "profile": { + "id": "kaayu-cv-extract", + "input_contract": "kaayu-cv-extract-input:v1", + "output_contract": "kaayu-cv-profile:v1", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "version": 1 + }, + "runtime": { + "commit": "620d2225cc436ab0b001e3200e590e3b286ef9ab", + "family": "llama.cpp", + "license": "MIT", + "mode": "llama-server", + "revision": "v0.7.0" + } + } + }, + { + "name": "non_ascii_string", + "expected": "JOB_SPEC_INVALID", + "job_spec": { + "contract": "mbongo-infer:v1", + "generation": { + "max_tokens": 400, + "seed": 42, + "temperature_milli": 0, + "top_p_milli": 1000 + }, + "limits": { + "max_input_bytes": 10000, + "max_output_bytes": 8000 + }, + "model": { + "artifact_name": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "artifact_sha256": "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597", + "artifact_size_bytes": 2497281120, + "family": "Qwen3", + "format": "gguf", + "license": "Apache-2.0", + "name": "Qwen3-4B-Instruct-2507 é", + "quantization": "Q4_K_M", + "source": "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", + "source_revision": "a06e946bb6b655725eafa393f4a9745d460374c9" + }, + "profile": { + "id": "kaayu-cv-extract", + "input_contract": "kaayu-cv-extract-input:v1", + "output_contract": "kaayu-cv-profile:v1", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "version": 1 + }, + "runtime": { + "commit": "620d2225cc436ab0b001e3200e590e3b286ef9ab", + "family": "llama.cpp", + "license": "MIT", + "mode": "llama-server", + "revision": "v0.7.0" + } + } + }, + { + "name": "profile_version_zero", + "expected": "JOB_SPEC_INVALID", + "job_spec": { + "contract": "mbongo-infer:v1", + "generation": { + "max_tokens": 400, + "seed": 42, + "temperature_milli": 0, + "top_p_milli": 1000 + }, + "limits": { + "max_input_bytes": 10000, + "max_output_bytes": 8000 + }, + "model": { + "artifact_name": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "artifact_sha256": "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597", + "artifact_size_bytes": 2497281120, + "family": "Qwen3", + "format": "gguf", + "license": "Apache-2.0", + "name": "Qwen3-4B-Instruct-2507", + "quantization": "Q4_K_M", + "source": "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", + "source_revision": "a06e946bb6b655725eafa393f4a9745d460374c9" + }, + "profile": { + "id": "kaayu-cv-extract", + "input_contract": "kaayu-cv-extract-input:v1", + "output_contract": "kaayu-cv-profile:v1", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "version": 0 + }, + "runtime": { + "commit": "620d2225cc436ab0b001e3200e590e3b286ef9ab", + "family": "llama.cpp", + "license": "MIT", + "mode": "llama-server", + "revision": "v0.7.0" + } + } + } + ] +} diff --git a/test-vectors/inference/kaayu-cv-extract-v1.json b/test-vectors/inference/kaayu-cv-extract-v1.json new file mode 100644 index 0000000..ecec33a --- /dev/null +++ b/test-vectors/inference/kaayu-cv-extract-v1.json @@ -0,0 +1,57 @@ +{ + "$comment": "Golden vector for mbongo-infer:v1 / kaayu-cv-extract:v1 (docs/product/MVP_INFERENCE_CONTRACT_v1.md section 22). Byte-exact; Rust and any future TypeScript helper must reproduce every field.", + "contract": "mbongo-infer:v1", + "profile": "kaayu-cv-extract:v1", + "job_spec": { + "contract": "mbongo-infer:v1", + "generation": { + "max_tokens": 400, + "seed": 42, + "temperature_milli": 0, + "top_p_milli": 1000 + }, + "limits": { + "max_input_bytes": 10000, + "max_output_bytes": 8000 + }, + "model": { + "artifact_name": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "artifact_sha256": "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597", + "artifact_size_bytes": 2497281120, + "family": "Qwen3", + "format": "gguf", + "license": "Apache-2.0", + "name": "Qwen3-4B-Instruct-2507", + "quantization": "Q4_K_M", + "source": "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF", + "source_revision": "a06e946bb6b655725eafa393f4a9745d460374c9" + }, + "profile": { + "id": "kaayu-cv-extract", + "input_contract": "kaayu-cv-extract-input:v1", + "output_contract": "kaayu-cv-profile:v1", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "version": 1 + }, + "runtime": { + "commit": "620d2225cc436ab0b001e3200e590e3b286ef9ab", + "family": "llama.cpp", + "license": "MIT", + "mode": "llama-server", + "revision": "v0.7.0" + } + }, + "job_spec_canonical": "{\"contract\":\"mbongo-infer:v1\",\"generation\":{\"max_tokens\":400,\"seed\":42,\"temperature_milli\":0,\"top_p_milli\":1000},\"limits\":{\"max_input_bytes\":10000,\"max_output_bytes\":8000},\"model\":{\"artifact_name\":\"Qwen3-4B-Instruct-2507-Q4_K_M.gguf\",\"artifact_sha256\":\"3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597\",\"artifact_size_bytes\":2497281120,\"family\":\"Qwen3\",\"format\":\"gguf\",\"license\":\"Apache-2.0\",\"name\":\"Qwen3-4B-Instruct-2507\",\"quantization\":\"Q4_K_M\",\"source\":\"https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF\",\"source_revision\":\"a06e946bb6b655725eafa393f4a9745d460374c9\"},\"profile\":{\"id\":\"kaayu-cv-extract\",\"input_contract\":\"kaayu-cv-extract-input:v1\",\"output_contract\":\"kaayu-cv-profile:v1\",\"output_schema_sha256\":\"81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5\",\"prompt_template_sha256\":\"6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63\",\"version\":1},\"runtime\":{\"commit\":\"620d2225cc436ab0b001e3200e590e3b286ef9ab\",\"family\":\"llama.cpp\",\"license\":\"MIT\",\"mode\":\"llama-server\",\"revision\":\"v0.7.0\"}}", + "job_spec_canonical_hex": "7b22636f6e7472616374223a226d626f6e676f2d696e6665723a7631222c2267656e65726174696f6e223a7b226d61785f746f6b656e73223a3430302c2273656564223a34322c2274656d70657261747572655f6d696c6c69223a302c22746f705f705f6d696c6c69223a313030307d2c226c696d697473223a7b226d61785f696e7075745f6279746573223a31303030302c226d61785f6f75747075745f6279746573223a383030307d2c226d6f64656c223a7b2261727469666163745f6e616d65223a225177656e332d34422d496e7374727563742d323530372d51345f4b5f4d2e67677566222c2261727469666163745f736861323536223a2233363035383033623938326362363461656164343466366331623261653336653361636462343164386534366338613934633635333362633463363765353937222c2261727469666163745f73697a655f6279746573223a323439373238313132302c2266616d696c79223a225177656e33222c22666f726d6174223a2267677566222c226c6963656e7365223a224170616368652d322e30222c226e616d65223a225177656e332d34422d496e7374727563742d32353037222c227175616e74697a6174696f6e223a2251345f4b5f4d222c22736f75726365223a2268747470733a2f2f68756767696e67666163652e636f2f756e736c6f74682f5177656e332d34422d496e7374727563742d323530372d47475546222c22736f757263655f7265766973696f6e223a2261303665393436626236623635353732356561666133393366346139373435643436303337346339227d2c2270726f66696c65223a7b226964223a226b616179752d63762d65787472616374222c22696e7075745f636f6e7472616374223a226b616179752d63762d657874726163742d696e7075743a7631222c226f75747075745f636f6e7472616374223a226b616179752d63762d70726f66696c653a7631222c226f75747075745f736368656d615f736861323536223a2238313532313434376537623762343563363730666631373137643630386663323330343736356162353330353837616261323337306261306139313931616635222c2270726f6d70745f74656d706c6174655f736861323536223a2236633935306234383633626333333961663934643930346462623630396233646439303938323539373430663031613635333934303665363431323736643633222c2276657273696f6e223a317d2c2272756e74696d65223a7b22636f6d6d6974223a2236323064323232356363343336616230623030316533323030653539306533623238366566396162222c2266616d696c79223a226c6c616d612e637070222c226c6963656e7365223a224d4954222c226d6f6465223a226c6c616d612d736572766572222c227265766973696f6e223a2276302e372e30227d7d", + "job_spec_domain": "mbongo:infer-job-spec:v1", + "job_spec_digest": "be9ae960334c3e917ad600e2b5220440c27dd79c5e586e998e60cbc148affe0e", + "job_spec_digest_rule": "BLAKE3(job_spec_domain || job_spec_canonical)", + "execution_spec_hex": "6d626f6e676f2d696e6665723a7631be9ae960334c3e917ad600e2b5220440c27dd79c5e586e998e60cbc148affe0e", + "execution_spec_layout": "ASCII tag 'mbongo-infer:v1' (15 bytes) || job_spec_digest (32 bytes) = 47 bytes", + "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", + "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", + "prompt_template_path": "docs/product/inference/kaayu-cv-extract-v1/prompt-template.txt", + "output_schema_path": "docs/product/inference/kaayu-cv-extract-v1/output-schema.json", + "job_spec_path": "docs/product/inference/kaayu-cv-extract-v1/job-spec.json" +} diff --git a/test-vectors/inference/output-canonicalization-v1.json b/test-vectors/inference/output-canonicalization-v1.json new file mode 100644 index 0000000..346612b --- /dev/null +++ b/test-vectors/inference/output-canonicalization-v1.json @@ -0,0 +1,50 @@ +{ + "$comment": "A schema-valid raw model output in two non-canonical forms, the one canonical byte form both reduce to, its commitment, and outputs that must yield no result and no receipt (contract sections 13, 15, 16). output_commitment = BLAKE3('mbongo:compute-output:v1' || canonical_bytes).", + "contract": "kaayu-cv-profile:v1", + "raw_model_output": "{\n \"summary\": \"Senior backend engineer with nine years of experience in distributed systems, specialising in Rust and Go. Leads a small team and has built event-sourced and streaming pipelines.\",\n \"seniority\": \"senior\",\n \"location\": \"\",\n \"skills\": [\"Rust\", \"Go\", \"Kafka\", \"PostgreSQL\", \"Kubernetes\", \"distributed systems\", \"observability\"],\n \"experience\": [\n {\"title\": \"Lead Backend Engineer\", \"organization\": \"Example Logistics\", \"start\": \"2021-03\", \"end\": \"present\", \"description\": \"Designed the event-sourced order pipeline and mentors four engineers.\"},\n {\"title\": \"Software Engineer\", \"organization\": \"Sample Analytics\", \"start\": \"2017-09\", \"end\": \"2021-02\", \"description\": \"Built streaming ingestion in Go and migrated batch jobs to Kafka.\"}\n ],\n \"education\": [{\"degree\": \"MSc Computer Science\", \"institution\": \"Fictional Institute of Technology\", \"year\": \"2017\"}],\n \"languages\": [{\"language\": \"English\", \"level\": \"fluent\"}, {\"language\": \"French\", \"level\": \"native\"}],\n \"certifications\": [\"Certified Kubernetes Administrator\"]\n}", + "raw_model_output_reordered": "{\"certifications\":[\"Certified Kubernetes Administrator\"],\"languages\":[{\"level\":\"fluent\",\"language\":\"English\"},{\"level\":\"native\",\"language\":\"French\"}],\"education\":[{\"year\":\"2017\",\"institution\":\"Fictional Institute of Technology\",\"degree\":\"MSc Computer Science\"}],\"experience\":[{\"description\":\"Designed the event-sourced order pipeline and mentors four engineers.\",\"end\":\"present\",\"start\":\"2021-03\",\"organization\":\"Example Logistics\",\"title\":\"Lead Backend Engineer\"},{\"description\":\"Built streaming ingestion in Go and migrated batch jobs to Kafka.\",\"end\":\"2021-02\",\"start\":\"2017-09\",\"organization\":\"Sample Analytics\",\"title\":\"Software Engineer\"}],\"skills\":[\"Rust\",\"Go\",\"Kafka\",\"PostgreSQL\",\"Kubernetes\",\"distributed systems\",\"observability\"],\"location\":\"\",\"seniority\":\"senior\",\"summary\":\"Senior backend engineer with nine years of experience in distributed systems, specialising in Rust and Go. Leads a small team and has built event-sourced and streaming pipelines.\"}", + "canonical_bytes_hex": "7b2263657274696669636174696f6e73223a5b22436572746966696564204b756265726e657465732041646d696e6973747261746f72225d2c22656475636174696f6e223a5b7b22646567726565223a224d536320436f6d707574657220536369656e6365222c22696e737469747574696f6e223a2246696374696f6e616c20496e73746974757465206f6620546563686e6f6c6f6779222c2279656172223a2232303137227d5d2c22657870657269656e6365223a5b7b226465736372697074696f6e223a2244657369676e656420746865206576656e742d736f7572636564206f7264657220706970656c696e6520616e64206d656e746f727320666f757220656e67696e656572732e222c22656e64223a2270726573656e74222c226f7267616e697a6174696f6e223a224578616d706c65204c6f67697374696373222c227374617274223a22323032312d3033222c227469746c65223a224c656164204261636b656e6420456e67696e656572227d2c7b226465736372697074696f6e223a224275696c742073747265616d696e6720696e67657374696f6e20696e20476f20616e64206d69677261746564206261746368206a6f627320746f204b61666b612e222c22656e64223a22323032312d3032222c226f7267616e697a6174696f6e223a2253616d706c6520416e616c7974696373222c227374617274223a22323031372d3039222c227469746c65223a22536f66747761726520456e67696e656572227d5d2c226c616e677561676573223a5b7b226c616e6775616765223a22456e676c697368222c226c6576656c223a22666c75656e74227d2c7b226c616e6775616765223a224672656e6368222c226c6576656c223a226e6174697665227d5d2c226c6f636174696f6e223a22222c2273656e696f72697479223a2273656e696f72222c22736b696c6c73223a5b2252757374222c22476f222c224b61666b61222c22506f737467726553514c222c224b756265726e65746573222c2264697374726962757465642073797374656d73222c226f62736572766162696c697479225d2c2273756d6d617279223a2253656e696f72206261636b656e6420656e67696e6565722077697468206e696e65207965617273206f6620657870657269656e636520696e2064697374726962757465642073797374656d732c207370656369616c6973696e6720696e205275737420616e6420476f2e204c65616473206120736d616c6c207465616d20616e6420686173206275696c74206576656e742d736f757263656420616e642073747265616d696e6720706970656c696e65732e227d", + "output_commitment": "62f389aca12fcd6a7499ee0903def8ced7eb2b88403467af5a3563f72b6e382e", + "invalid": [ + { + "name": "prose_before_json", + "raw_model_output": "Here is the profile: {\"certifications\":[\"Certified Kubernetes Administrator\"],\"languages\":[{\"level\":\"fluent\",\"language\":\"English\"},{\"level\":\"native\",\"language\":\"French\"}],\"education\":[{\"year\":\"2017\",\"institution\":\"Fictional Institute of Technology\",\"degree\":\"MSc Computer Science\"}],\"experience\":[{\"description\":\"Designed the event-sourced order pipeline and mentors four engineers.\",\"end\":\"present\",\"start\":\"2021-03\",\"organization\":\"Example Logistics\",\"title\":\"Lead Backend Engineer\"},{\"description\":\"Built streaming ingestion in Go and migrated batch jobs to Kafka.\",\"end\":\"2021-02\",\"start\":\"2017-09\",\"organization\":\"Sample Analytics\",\"title\":\"Software Engineer\"}],\"skills\":[\"Rust\",\"Go\",\"Kafka\",\"PostgreSQL\",\"Kubernetes\",\"distributed systems\",\"observability\"],\"location\":\"\",\"seniority\":\"senior\",\"summary\":\"Senior backend engineer with nine years of experience in distributed systems, specialising in Rust and Go. Leads a small team and has built event-sourced and streaming pipelines.\"}", + "expected_error": "OUTPUT_NOT_JSON" + }, + { + "name": "truncated", + "raw_model_output": "{\"certifications\":[\"Certified Kubernetes Administrator\"],\"languages\":[{\"level\":\"fluent\",\"language\":\"English\"},{\"level\":\"", + "expected_error": "OUTPUT_NOT_JSON" + }, + { + "name": "null_field", + "raw_model_output": "{\"certifications\":[\"Certified Kubernetes Administrator\"],\"languages\":[{\"level\":\"fluent\",\"language\":\"English\"},{\"level\":\"native\",\"language\":\"French\"}],\"education\":[{\"year\":\"2017\",\"institution\":\"Fictional Institute of Technology\",\"degree\":\"MSc Computer Science\"}],\"experience\":[{\"description\":\"Designed the event-sourced order pipeline and mentors four engineers.\",\"end\":\"present\",\"start\":\"2021-03\",\"organization\":\"Example Logistics\",\"title\":\"Lead Backend Engineer\"},{\"description\":\"Built streaming ingestion in Go and migrated batch jobs to Kafka.\",\"end\":\"2021-02\",\"start\":\"2017-09\",\"organization\":\"Sample Analytics\",\"title\":\"Software Engineer\"}],\"skills\":[\"Rust\",\"Go\",\"Kafka\",\"PostgreSQL\",\"Kubernetes\",\"distributed systems\",\"observability\"],\"location\":null,\"seniority\":\"senior\",\"summary\":\"Senior backend engineer with nine years of experience in distributed systems, specialising in Rust and Go. Leads a small team and has built event-sourced and streaming pipelines.\"}", + "expected_error": "OUTPUT_NOT_JSON" + }, + { + "name": "missing_field", + "raw_model_output": "{\"certifications\":[\"Certified Kubernetes Administrator\"],\"languages\":[{\"level\":\"fluent\",\"language\":\"English\"},{\"level\":\"native\",\"language\":\"French\"}],\"education\":[{\"year\":\"2017\",\"institution\":\"Fictional Institute of Technology\",\"degree\":\"MSc Computer Science\"}],\"experience\":[{\"description\":\"Designed the event-sourced order pipeline and mentors four engineers.\",\"end\":\"present\",\"start\":\"2021-03\",\"organization\":\"Example Logistics\",\"title\":\"Lead Backend Engineer\"},{\"description\":\"Built streaming ingestion in Go and migrated batch jobs to Kafka.\",\"end\":\"2021-02\",\"start\":\"2017-09\",\"organization\":\"Sample Analytics\",\"title\":\"Software Engineer\"}],\"skills\":[\"Rust\",\"Go\",\"Kafka\",\"PostgreSQL\",\"Kubernetes\",\"distributed systems\",\"observability\"],\"seniority\":\"senior\",\"summary\":\"Senior backend engineer with nine years of experience in distributed systems, specialising in Rust and Go. Leads a small team and has built event-sourced and streaming pipelines.\"}", + "expected_error": "OUTPUT_SCHEMA_INVALID" + }, + { + "name": "identity_field_added", + "raw_model_output": "{\"certifications\":[\"Certified Kubernetes Administrator\"],\"languages\":[{\"level\":\"fluent\",\"language\":\"English\"},{\"level\":\"native\",\"language\":\"French\"}],\"education\":[{\"year\":\"2017\",\"institution\":\"Fictional Institute of Technology\",\"degree\":\"MSc Computer Science\"}],\"experience\":[{\"description\":\"Designed the event-sourced order pipeline and mentors four engineers.\",\"end\":\"present\",\"start\":\"2021-03\",\"organization\":\"Example Logistics\",\"title\":\"Lead Backend Engineer\"},{\"description\":\"Built streaming ingestion in Go and migrated batch jobs to Kafka.\",\"end\":\"2021-02\",\"start\":\"2017-09\",\"organization\":\"Sample Analytics\",\"title\":\"Software Engineer\"}],\"skills\":[\"Rust\",\"Go\",\"Kafka\",\"PostgreSQL\",\"Kubernetes\",\"distributed systems\",\"observability\"],\"location\":\"\",\"name\":\"Someone\",\"seniority\":\"senior\",\"summary\":\"Senior backend engineer with nine years of experience in distributed systems, specialising in Rust and Go. Leads a small team and has built event-sourced and streaming pipelines.\"}", + "expected_error": "OUTPUT_SCHEMA_INVALID" + }, + { + "name": "seniority_outside_enum", + "raw_model_output": "{\"certifications\":[\"Certified Kubernetes Administrator\"],\"languages\":[{\"level\":\"fluent\",\"language\":\"English\"},{\"level\":\"native\",\"language\":\"French\"}],\"education\":[{\"year\":\"2017\",\"institution\":\"Fictional Institute of Technology\",\"degree\":\"MSc Computer Science\"}],\"experience\":[{\"description\":\"Designed the event-sourced order pipeline and mentors four engineers.\",\"end\":\"present\",\"start\":\"2021-03\",\"organization\":\"Example Logistics\",\"title\":\"Lead Backend Engineer\"},{\"description\":\"Built streaming ingestion in Go and migrated batch jobs to Kafka.\",\"end\":\"2021-02\",\"start\":\"2017-09\",\"organization\":\"Sample Analytics\",\"title\":\"Software Engineer\"}],\"skills\":[\"Rust\",\"Go\",\"Kafka\",\"PostgreSQL\",\"Kubernetes\",\"distributed systems\",\"observability\"],\"location\":\"\",\"seniority\":\"principal\",\"summary\":\"Senior backend engineer with nine years of experience in distributed systems, specialising in Rust and Go. Leads a small team and has built event-sourced and streaming pipelines.\"}", + "expected_error": "OUTPUT_SCHEMA_INVALID" + }, + { + "name": "summary_too_long", + "raw_model_output": "{\"certifications\":[\"Certified Kubernetes Administrator\"],\"languages\":[{\"level\":\"fluent\",\"language\":\"English\"},{\"level\":\"native\",\"language\":\"French\"}],\"education\":[{\"year\":\"2017\",\"institution\":\"Fictional Institute of Technology\",\"degree\":\"MSc Computer Science\"}],\"experience\":[{\"description\":\"Designed the event-sourced order pipeline and mentors four engineers.\",\"end\":\"present\",\"start\":\"2021-03\",\"organization\":\"Example Logistics\",\"title\":\"Lead Backend Engineer\"},{\"description\":\"Built streaming ingestion in Go and migrated batch jobs to Kafka.\",\"end\":\"2021-02\",\"start\":\"2017-09\",\"organization\":\"Sample Analytics\",\"title\":\"Software Engineer\"}],\"skills\":[\"Rust\",\"Go\",\"Kafka\",\"PostgreSQL\",\"Kubernetes\",\"distributed systems\",\"observability\"],\"location\":\"\",\"seniority\":\"senior\",\"summary\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}", + "expected_error": "OUTPUT_SCHEMA_INVALID" + }, + { + "name": "skills_not_strings", + "raw_model_output": "{\"certifications\":[\"Certified Kubernetes Administrator\"],\"languages\":[{\"level\":\"fluent\",\"language\":\"English\"},{\"level\":\"native\",\"language\":\"French\"}],\"education\":[{\"year\":\"2017\",\"institution\":\"Fictional Institute of Technology\",\"degree\":\"MSc Computer Science\"}],\"experience\":[{\"description\":\"Designed the event-sourced order pipeline and mentors four engineers.\",\"end\":\"present\",\"start\":\"2021-03\",\"organization\":\"Example Logistics\",\"title\":\"Lead Backend Engineer\"},{\"description\":\"Built streaming ingestion in Go and migrated batch jobs to Kafka.\",\"end\":\"2021-02\",\"start\":\"2017-09\",\"organization\":\"Sample Analytics\",\"title\":\"Software Engineer\"}],\"skills\":[1,\"Go\",\"Kafka\",\"PostgreSQL\",\"Kubernetes\",\"distributed systems\",\"observability\"],\"location\":\"\",\"seniority\":\"senior\",\"summary\":\"Senior backend engineer with nine years of experience in distributed systems, specialising in Rust and Go. Leads a small team and has built event-sourced and streaming pipelines.\"}", + "expected_error": "OUTPUT_SCHEMA_INVALID" + } + ] +} From 6ec31ab310c6708c68c5dec674d7a1f7eb9a21e1 Mon Sep 17 00:00:00 2001 From: Gilbert Kalombo Date: Sat, 17 Oct 2026 12:46:12 -0400 Subject: [PATCH 2/2] fix(compute): keep the inference profile artifacts inside the crate The devnet image copies only Cargo manifests and crates/ (Dockerfile, .dockerignore), so include_str! of files under docs/ failed the Docker Devnet Bootstrap job. The prompt template, output schema and committed job-spec document move to crates/mbongo-compute/profiles/kaayu-cv-extract-v1/ unchanged (same bytes, same digests, golden vector unchanged); every reference follows. Refs #150 Co-Authored-By: Claude Fable 5.1 --- .gitattributes | 2 +- .../profiles}/kaayu-cv-extract-v1/job-spec.json | 0 .../profiles}/kaayu-cv-extract-v1/output-schema.json | 0 .../profiles}/kaayu-cv-extract-v1/prompt-template.txt | 0 crates/mbongo-compute/src/inference/kaayu.rs | 10 +++++----- crates/mbongo-compute/tests/inference_contract.rs | 2 +- docs/product/MVP_INFERENCE_CONTRACT_v1.md | 8 ++++---- test-vectors/inference/README.md | 2 +- test-vectors/inference/kaayu-cv-extract-v1.json | 6 +++--- 9 files changed, 15 insertions(+), 15 deletions(-) rename {docs/product/inference => crates/mbongo-compute/profiles}/kaayu-cv-extract-v1/job-spec.json (100%) rename {docs/product/inference => crates/mbongo-compute/profiles}/kaayu-cv-extract-v1/output-schema.json (100%) rename {docs/product/inference => crates/mbongo-compute/profiles}/kaayu-cv-extract-v1/prompt-template.txt (100%) diff --git a/.gitattributes b/.gitattributes index 37277b8..3223faf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,2 @@ -docs/product/inference/** -text +crates/mbongo-compute/profiles/** -text test-vectors/inference/** -text diff --git a/docs/product/inference/kaayu-cv-extract-v1/job-spec.json b/crates/mbongo-compute/profiles/kaayu-cv-extract-v1/job-spec.json similarity index 100% rename from docs/product/inference/kaayu-cv-extract-v1/job-spec.json rename to crates/mbongo-compute/profiles/kaayu-cv-extract-v1/job-spec.json diff --git a/docs/product/inference/kaayu-cv-extract-v1/output-schema.json b/crates/mbongo-compute/profiles/kaayu-cv-extract-v1/output-schema.json similarity index 100% rename from docs/product/inference/kaayu-cv-extract-v1/output-schema.json rename to crates/mbongo-compute/profiles/kaayu-cv-extract-v1/output-schema.json diff --git a/docs/product/inference/kaayu-cv-extract-v1/prompt-template.txt b/crates/mbongo-compute/profiles/kaayu-cv-extract-v1/prompt-template.txt similarity index 100% rename from docs/product/inference/kaayu-cv-extract-v1/prompt-template.txt rename to crates/mbongo-compute/profiles/kaayu-cv-extract-v1/prompt-template.txt diff --git a/crates/mbongo-compute/src/inference/kaayu.rs b/crates/mbongo-compute/src/inference/kaayu.rs index 0ac3600..9b5b4cc 100644 --- a/crates/mbongo-compute/src/inference/kaayu.rs +++ b/crates/mbongo-compute/src/inference/kaayu.rs @@ -7,9 +7,9 @@ //! //! | Artifact | Where | Pinned by | //! |---|---|---| -//! | prompt template (the system message, verbatim) | `docs/product/inference/kaayu-cv-extract-v1/prompt-template.txt` | `profile.prompt_template_sha256` | -//! | output JSON Schema (`kaayu-cv-profile:v1`) | `docs/product/inference/kaayu-cv-extract-v1/output-schema.json` | `profile.output_schema_sha256` | -//! | job spec (canonical bytes) | `docs/product/inference/kaayu-cv-extract-v1/job-spec.json` | the digest in `execution_spec` | +//! | prompt template (the system message, verbatim) | `crates/mbongo-compute/profiles/kaayu-cv-extract-v1/prompt-template.txt` | `profile.prompt_template_sha256` | +//! | output JSON Schema (`kaayu-cv-profile:v1`) | `crates/mbongo-compute/profiles/kaayu-cv-extract-v1/output-schema.json` | `profile.output_schema_sha256` | +//! | job spec (canonical bytes) | `crates/mbongo-compute/profiles/kaayu-cv-extract-v1/job-spec.json` | the digest in `execution_spec` | //! //! The typed output validator below is the executable form of the schema //! file; a test asserts the two agree on their field set, bounds and @@ -25,11 +25,11 @@ use super::ContractError; /// The prompt template: used verbatim as the chat **system** message. pub const PROMPT_TEMPLATE: &str = - include_str!("../../../../docs/product/inference/kaayu-cv-extract-v1/prompt-template.txt"); + include_str!("../../profiles/kaayu-cv-extract-v1/prompt-template.txt"); /// The output JSON Schema (`kaayu-cv-profile:v1`), passed to llama-server /// as `response_format` and mirrored by [`CandidateProfileV1::validate`]. pub const OUTPUT_SCHEMA: &str = - include_str!("../../../../docs/product/inference/kaayu-cv-extract-v1/output-schema.json"); + include_str!("../../profiles/kaayu-cv-extract-v1/output-schema.json"); /// Profile id. pub const PROFILE_ID: &str = "kaayu-cv-extract"; diff --git a/crates/mbongo-compute/tests/inference_contract.rs b/crates/mbongo-compute/tests/inference_contract.rs index 80cc54a..2348c6f 100644 --- a/crates/mbongo-compute/tests/inference_contract.rs +++ b/crates/mbongo-compute/tests/inference_contract.rs @@ -583,7 +583,7 @@ fn a20_golden_vector_exact() { // The committed job-spec document in docs/ is byte-identical to the canonical bytes. let committed = std::fs::read(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../docs/product/inference/kaayu-cv-extract-v1/job-spec.json" + "/profiles/kaayu-cv-extract-v1/job-spec.json" )) .unwrap(); assert_eq!(committed, spec.canonical_bytes()); diff --git a/docs/product/MVP_INFERENCE_CONTRACT_v1.md b/docs/product/MVP_INFERENCE_CONTRACT_v1.md index 316d583..e8eba7f 100644 --- a/docs/product/MVP_INFERENCE_CONTRACT_v1.md +++ b/docs/product/MVP_INFERENCE_CONTRACT_v1.md @@ -146,13 +146,13 @@ Rules: exactly those two fields; `cv_text` non-empty after trimming; the only co ## 12. Prompt and profile identity -Prompt construction is **worker-owned and pinned**: the chat request is exactly two messages — `system` = the prompt template bytes verbatim (`docs/product/inference/kaayu-cv-extract-v1/prompt-template.txt`, SHA-256 `6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63`), `user` = `cv_text`. No substitution, no additional message, no tool, no retrieval (A23). The model's own chat template is embedded in the GGUF and therefore covered by the artifact digest. +Prompt construction is **worker-owned and pinned**: the chat request is exactly two messages — `system` = the prompt template bytes verbatim (`crates/mbongo-compute/profiles/kaayu-cv-extract-v1/prompt-template.txt`, SHA-256 `6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63`), `user` = `cv_text`. No substitution, no additional message, no tool, no retrieval (A23). The model's own chat template is embedded in the GGUF and therefore covered by the artifact digest. `profile = { id: "kaayu-cv-extract", version: 1, input_contract: "kaayu-cv-extract-input:v1", output_contract: "kaayu-cv-profile:v1", prompt_template_sha256, output_schema_sha256 }`. A worker cannot change the instructions silently: the template digest is inside the job spec, so a changed template is a changed digest, a changed `execution_spec` and a different task. ## 13. Output schema (`kaayu-cv-profile:v1`) -`docs/product/inference/kaayu-cv-extract-v1/output-schema.json` (JSON Schema 2020-12, SHA-256 `81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5`). One object, `additionalProperties: false`, all fields required, **no numbers, booleans or nulls anywhere** — only strings, arrays and objects, so the canonical form of §7 is total over valid outputs: +`crates/mbongo-compute/profiles/kaayu-cv-extract-v1/output-schema.json` (JSON Schema 2020-12, SHA-256 `81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5`). One object, `additionalProperties: false`, all fields required, **no numbers, booleans or nulls anywhere** — only strings, arrays and objects, so the canonical form of §7 is total over valid outputs: | Field | Type | Bound | |---|---|---| @@ -247,7 +247,7 @@ The receipt proves: executor attribution (signed by the key of the executor the ## 22. Golden vector -`test-vectors/inference/kaayu-cv-extract-v1.json`, reproduced byte-exactly by test A20 and by the committed document `docs/product/inference/kaayu-cv-extract-v1/job-spec.json`: +`test-vectors/inference/kaayu-cv-extract-v1.json`, reproduced byte-exactly by test A20 and by the committed document `crates/mbongo-compute/profiles/kaayu-cv-extract-v1/job-spec.json`: ``` job_spec_canonical = {"contract":"mbongo-infer:v1","generation":{"max_tokens":400,"seed":42,"temperature_milli":0,"top_p_milli":1000},"limits":{"max_input_bytes":10000,"max_output_bytes":8000},"model":{"artifact_name":"Qwen3-4B-Instruct-2507-Q4_K_M.gguf","artifact_sha256":"3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597","artifact_size_bytes":2497281120,"family":"Qwen3","format":"gguf","license":"Apache-2.0","name":"Qwen3-4B-Instruct-2507","quantization":"Q4_K_M","source":"https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF","source_revision":"a06e946bb6b655725eafa393f4a9745d460374c9"},"profile":{"id":"kaayu-cv-extract","input_contract":"kaayu-cv-extract-input:v1","output_contract":"kaayu-cv-profile:v1","output_schema_sha256":"81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5","prompt_template_sha256":"6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63","version":1},"runtime":{"commit":"620d2225cc436ab0b001e3200e590e3b286ef9ab","family":"llama.cpp","license":"MIT","mode":"llama-server","revision":"v0.7.0"}} @@ -306,7 +306,7 @@ MVP-B implements an `ExecutionProfile` whose `spec_tag` is the 15-byte tag prefi |---|---| | `execution_spec` parser/encoder | `inference::execution_spec` | | job-spec schema, canonical form, digest, validation | `inference::job_spec`, `inference::canonical` | -| the frozen profile, prompt, schema, parameters, limits | `inference::kaayu` and `docs/product/inference/kaayu-cv-extract-v1/` | +| the frozen profile, prompt, schema, parameters, limits | `inference::kaayu` and `crates/mbongo-compute/profiles/kaayu-cv-extract-v1/` | | golden vector and negative cases | `test-vectors/inference/` | | runtime revision policy | §9; `check_runtime` | | model digest policy and acquisition | §10; `check_model`; download → SHA-256 → refuse on mismatch | diff --git a/test-vectors/inference/README.md b/test-vectors/inference/README.md index 6ed3215..6439cfe 100644 --- a/test-vectors/inference/README.md +++ b/test-vectors/inference/README.md @@ -16,7 +16,7 @@ RFC 0005 and never interprets them. The protocol vectors remain | `output-canonicalization-v1.json` | one schema-valid model output in two non-canonical forms, the single canonical byte form, its `output_commitment`, and outputs that yield no result and no receipt (A10, A13) | The committed job-spec document itself is -`docs/product/inference/kaayu-cv-extract-v1/job-spec.json`: exactly the +`crates/mbongo-compute/profiles/kaayu-cv-extract-v1/job-spec.json`: exactly the canonical bytes, no trailing newline. The three files under that directory are marked `-text` in `.gitattributes` so no checkout can alter the bytes their digests pin. diff --git a/test-vectors/inference/kaayu-cv-extract-v1.json b/test-vectors/inference/kaayu-cv-extract-v1.json index ecec33a..325572f 100644 --- a/test-vectors/inference/kaayu-cv-extract-v1.json +++ b/test-vectors/inference/kaayu-cv-extract-v1.json @@ -51,7 +51,7 @@ "execution_spec_layout": "ASCII tag 'mbongo-infer:v1' (15 bytes) || job_spec_digest (32 bytes) = 47 bytes", "prompt_template_sha256": "6c950b4863bc339af94d904dbb609b3dd9098259740f01a6539406e641276d63", "output_schema_sha256": "81521447e7b7b45c670ff1717d608fc2304765ab530587aba2370ba0a9191af5", - "prompt_template_path": "docs/product/inference/kaayu-cv-extract-v1/prompt-template.txt", - "output_schema_path": "docs/product/inference/kaayu-cv-extract-v1/output-schema.json", - "job_spec_path": "docs/product/inference/kaayu-cv-extract-v1/job-spec.json" + "prompt_template_path": "crates/mbongo-compute/profiles/kaayu-cv-extract-v1/prompt-template.txt", + "output_schema_path": "crates/mbongo-compute/profiles/kaayu-cv-extract-v1/output-schema.json", + "job_spec_path": "crates/mbongo-compute/profiles/kaayu-cv-extract-v1/job-spec.json" }