Validate structured model stages before acceptance

This commit is contained in:
Meta Vens 2026-08-16 21:24:24 +02:00
commit 3f7c3f1d6a
4 changed files with 399 additions and 2 deletions

331
Cargo.lock generated
View file

@ -37,6 +37,20 @@ dependencies = [
"subtle",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@ -46,6 +60,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.6"
@ -154,6 +174,21 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "2.13.1"
@ -169,12 +204,24 @@ dependencies = [
"generic-array",
]
[[package]]
name = "borrow-or-share"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytecount"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]]
name = "bytes"
version = "1.12.1"
@ -372,6 +419,12 @@ dependencies = [
"uuid",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "diff"
version = "0.1.13"
@ -406,6 +459,21 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
dependencies = [
"serde",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
@ -416,6 +484,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fancy-regex"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "476de73bddf2ef8490aa4ee8f1cf40b430bf1d56c48c22080e5186952cd580e6"
dependencies = [
"bit-set",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@ -428,6 +507,23 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "fluent-uri"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
dependencies = [
"borrow-or-share",
"ref-cast",
"serde",
]
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@ -437,6 +533,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fraction"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872"
dependencies = [
"lazy_static",
"num",
]
[[package]]
name = "fs2"
version = "0.4.3"
@ -537,9 +643,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@ -578,6 +686,7 @@ dependencies = [
"cultcache-rs 0.1.0 (git+https://github.com/GameCult/cultcache-rs.git?rev=0ba87453115e3d44e8fcb7419866cd7dd70fb096)",
"cultmesh-rs",
"cultnet-rs",
"jsonschema",
"rand 0.9.5",
"reqwest",
"rmp-serde",
@ -596,6 +705,23 @@ dependencies = [
"zeroize",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hmac"
version = "0.12.1"
@ -876,6 +1002,58 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonschema"
version = "0.49.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ec8a241beed129f06114aa68007e905ca350e7baeb6e17a7631bb7978d91b2"
dependencies = [
"ahash",
"bytecount",
"data-encoding",
"email_address",
"fancy-regex",
"fraction",
"getrandom 0.3.4",
"idna",
"itoa",
"jsonschema-regex",
"jsonschema-value",
"num-cmp",
"num-traits",
"percent-encoding",
"referencing",
"regex",
"serde",
"serde_json",
"strum",
"unicode-general-category",
"uuid-simd",
]
[[package]]
name = "jsonschema-regex"
version = "0.49.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91994f45017ed5e66aa8e59b8415f4cb033a6380d7200387b7cf117595fbdf85"
dependencies = [
"regex-syntax",
]
[[package]]
name = "jsonschema-value"
version = "0.49.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ec7637f83e510868ae6ed625f7ebfbbde4554ee8ce49854caa5126a8b9b9ecb"
dependencies = [
"ahash",
"bytecount",
"fraction",
"num-cmp",
"num-traits",
"serde_json",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@ -942,6 +1120,12 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "micromap"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
[[package]]
name = "mime"
version = "0.3.17"
@ -978,6 +1162,75 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-cmp"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@ -999,6 +1252,12 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "parking_lot"
version = "0.12.5"
@ -1262,6 +1521,35 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "referencing"
version = "0.49.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6efa2154ea6f5ce0fdecdd2a8d18f2fa1a39a8fbba91564f555a592e4dce8278"
dependencies = [
"ahash",
"fluent-uri",
"getrandom 0.3.4",
"hashbrown",
"itoa",
"micromap",
"parking_lot",
"percent-encoding",
"serde_json",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
@ -1624,6 +1912,27 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "subtle"
version = "2.6.1"
@ -1926,6 +2235,12 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@ -1978,6 +2293,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "uuid-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
dependencies = [
"outref",
"vsimd",
]
[[package]]
name = "valuable"
version = "0.1.1"
@ -1990,6 +2315,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "want"
version = "0.3.1"

View file

@ -22,6 +22,7 @@ cultcache-legacy = { package = "cultcache-rs", git = "https://github.com/GameCul
cultcache-rs = { path = "vendor/cultcache-rs" }
cultnet-rs = { path = "vendor/cultnet-rs" }
cultmesh-rs = { path = "vendor/cultmesh-rs" }
jsonschema = { version = "0.49", default-features = false }
rand = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
rmp-serde = "1.3"

View file

@ -13,6 +13,7 @@ cultcache-legacy.workspace = true
cultcache-rs.workspace = true
cultnet-rs.workspace = true
cultmesh-rs.workspace = true
jsonschema.workspace = true
rand.workspace = true
reqwest.workspace = true
rmp-serde.workspace = true

View file

@ -48,6 +48,12 @@ pub async fn run_validated_stage(
request: &ModelStageRequest,
) -> Result<ModelStageOutput> {
let request_bytes = serde_json::to_vec(request)?;
let validator = request
.output_schema
.as_ref()
.map(jsonschema::validator_for)
.transpose()
.map_err(|error| anyhow!("invalid local output schema: {error}"))?;
for attempt in 0..2 {
let started = Instant::now();
let output = port.run(request).await?;
@ -57,9 +63,18 @@ pub async fn run_validated_stage(
}
return Err(anyhow!("model returned empty output twice"));
}
let structured = match &request.output_schema {
let structured = match &validator {
Some(_) => match serde_json::from_str(&output) {
Ok(value) => Some(value),
Ok(value) => {
let validation = validator.as_ref().expect("structured validator");
if let Err(error) = validation.validate(&value) {
if attempt == 0 {
continue;
}
return Err(anyhow!("model returned schema-invalid JSON twice: {error}"));
}
Some(value)
}
Err(_) if attempt == 0 => continue,
Err(error) => return Err(anyhow!("model returned malformed JSON twice: {error}")),
},
@ -85,6 +100,55 @@ pub async fn run_validated_stage(
unreachable!()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
struct InvalidThenValid {
calls: AtomicUsize,
}
#[async_trait]
impl ModelPort for InvalidThenValid {
async fn run(&self, _: &ModelStageRequest) -> Result<String> {
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
r#"{"answer":7}"#.into()
} else {
r#"{"answer":"ready"}"#.into()
})
}
fn provider(&self) -> &'static str {
"fixture"
}
}
#[tokio::test]
async fn schema_invalid_json_gets_one_same_snapshot_retry() {
let port = InvalidThenValid {
calls: AtomicUsize::new(0),
};
let request = ModelStageRequest {
stage: "typed-stage".into(),
model: "fixture".into(),
snapshot_binding: "campaign:one:revision:4".into(),
lived_stream: "fixture".into(),
output_schema: Some(serde_json::json!({
"$schema":"https://json-schema.org/draft/2020-12/schema",
"type":"object",
"required":["answer"],
"properties":{"answer":{"type":"string"}}
})),
source_receipt_ids: vec![],
};
let output = run_validated_stage(&port, &request).await.unwrap();
assert_eq!(port.calls.load(Ordering::SeqCst), 2);
assert_eq!(output.structured.unwrap()["answer"], "ready");
assert_eq!(output.receipt.snapshot_binding, request.snapshot_binding);
}
}
#[derive(Clone)]
pub struct FixtureModel;
#[async_trait]