From cf9832d04dd834ac5674a72ea439a28f5ca869c4 Mon Sep 17 00:00:00 2001 From: Aleksander Sekowski Date: Sun, 5 Jul 2026 18:38:08 -0700 Subject: [PATCH 1/2] Add Rust test suite seeded from bundled samples; fix demo evaluator routing The new tests decode every samples/*.json through the proto schema (via a descriptor set emitted at build time) and run them through the demo evaluator. Writing them surfaced two existing bugs, fixed here: - The evaluator matched auction ids (auction-123, auction-456, ...) against the top-level RTBRequest envelope id, so no bundled sample ever produced mutations. It now routes on bid_request.id and echoes the envelope id in the response, matching the Go implementation's use of the enclosed BidRequest. The dead app-123 arm now matches native-ad.json's auction-native-123. - multi-impression.json and native-ad.json encoded proto bool fields (device.js, regs.coppa, regs.gdpr, source.fd) as 0/1, which the proto JSON mapping rejects. This is bug 4 of #11 (expecting boolean, instead got 1); both samples now decode cleanly. Test coverage added: - mutation/builder.rs: payload shapes for segments, deals, bid shade - bidder/evaluator.rs: routing table, envelope id echo, fallbacks - sample_tests.rs: schema-validates all five samples and asserts each drives its documented demo mutations end to end cargo test: 13 passed. No changes to the generated proto code. --- rust/Cargo.lock | 50 +++++++++++++++- rust/build.rs | 7 ++- rust/cargo.toml | 3 + rust/src/bidder/evaluator.rs | 109 +++++++++++++++++++++++++++++++++- rust/src/main.rs | 2 + rust/src/mutation/builder.rs | 48 +++++++++++++++ rust/src/sample_tests.rs | 94 +++++++++++++++++++++++++++++ samples/multi-impression.json | 8 +-- samples/native-ad.json | 4 +- 9 files changed, 315 insertions(+), 10 deletions(-) create mode 100644 rust/src/sample_tests.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7a0e0af..f5466cc 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "agentic-rtb-framework-service" @@ -11,6 +11,7 @@ dependencies = [ "futures-util", "hyper 0.14.32", "prost", + "prost-reflect", "rustc-hash", "serde_json", "tokio", @@ -74,6 +75,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.8.8" @@ -567,12 +574,30 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -685,6 +710,19 @@ dependencies = [ "syn", ] +[[package]] +name = "prost-reflect" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590aa145fee8f7a26b5a6055365e7c5e89a5c1caae9869de76ec0ee73181a2f9" +dependencies = [ + "base64", + "prost", + "prost-types", + "serde", + "serde-value", +] + [[package]] name = "prost-types" version = "0.14.3" @@ -792,6 +830,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" diff --git a/rust/build.rs b/rust/build.rs index c181492..426bd24 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -1,8 +1,13 @@ +use std::env; +use std::path::PathBuf; + fn main() -> Result<(), Box> { + let descriptor_path = PathBuf::from(env::var("OUT_DIR")?).join("artf_descriptor.bin"); tonic_prost_build::configure() .build_server(true) .build_client(false) .out_dir("./src") + .file_descriptor_set_path(descriptor_path) .compile_protos(&["./proto/agenticrtbframeworkservices.proto"], &["proto"])?; Ok(()) -} \ No newline at end of file +} diff --git a/rust/cargo.toml b/rust/cargo.toml index 31bd579..5b0fa7f 100644 --- a/rust/cargo.toml +++ b/rust/cargo.toml @@ -30,3 +30,6 @@ strip = true # Automatically strip symbols from the binary. opt-level = "z" # Optimize for size. lto = true # Enable link-time optimization. codegen-units = 1 # Use a single codegen unit to reduce binary size. + +[dev-dependencies] +prost-reflect = { version = "0.16.4", features = ["serde"] } diff --git a/rust/src/bidder/evaluator.rs b/rust/src/bidder/evaluator.rs index 0eaeb5b..85b0975 100644 --- a/rust/src/bidder/evaluator.rs +++ b/rust/src/bidder/evaluator.rs @@ -13,7 +13,16 @@ pub async fn evaluate(req: RtbRequest) -> RtbResponse { // and determine the appropriate mutations based on the request data. let metadata = build_metadata(); - match req.id.as_str() { + // Route on the auction id carried by the enclosed bid request; the + // top-level `req.id` is the extension point envelope id assigned by the + // exchange and is only echoed back in the response. + let auction_id = req + .bid_request + .as_ref() + .and_then(|bid_request| bid_request.id.clone()) + .unwrap_or_default(); + + match auction_id.as_str() { "auction-123" => RtbResponse { id: req.id, mutations: vec![ @@ -49,7 +58,7 @@ pub async fn evaluate(req: RtbRequest) -> RtbResponse { ], metadata: Some(metadata), }, - "app-123" => RtbResponse { + "auction-native-123" => RtbResponse { id: req.id, mutations: vec![ activate_segments(&["demo-18-24"]), @@ -64,3 +73,99 @@ pub async fn evaluate(req: RtbRequest) -> RtbResponse { }, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mutation::types::{PATH_IMP_1, PATH_SEATBID_BID_ABC, PATH_USER_SEGMENT}; + use crate::proto::com::iabtechlab::bidstream::mutation::v1::{ + mutation::Value, Intent, Operation, + }; + use crate::proto::com::iabtechlab::openrtb::v2::BidRequest; + + fn request(envelope_id: &str, auction_id: &str) -> RtbRequest { + RtbRequest { + id: envelope_id.to_string(), + bid_request: Some(BidRequest { + id: Some(auction_id.to_string()), + ..Default::default() + }), + ..Default::default() + } + } + + #[tokio::test] + async fn known_auctions_produce_expected_mutation_counts() { + let cases = [ + ("auction-123", 2), + ("auction-456", 2), + ("auction-789", 3), + ("auction-multi-123", 4), + ("auction-native-123", 2), + ]; + for (auction_id, expected) in cases { + let response = evaluate(request("req-1", auction_id)).await; + assert_eq!(response.mutations.len(), expected, "auction {auction_id}"); + assert!(response.metadata.is_some(), "auction {auction_id}"); + } + } + + #[tokio::test] + async fn response_echoes_envelope_id_not_auction_id() { + let response = evaluate(request("envelope-42", "auction-123")).await; + assert_eq!(response.id, "envelope-42"); + } + + #[tokio::test] + async fn segment_and_deal_mutations_carry_expected_fields() { + let response = evaluate(request("req-1", "auction-123")).await; + + let segments = &response.mutations[0]; + assert_eq!(segments.intent, Intent::ActivateSegments as i32); + assert_eq!(segments.op, Operation::Add as i32); + assert_eq!(segments.path, PATH_USER_SEGMENT); + match segments.value.as_ref() { + Some(Value::Ids(ids)) => { + assert_eq!(ids.id, vec!["seg-sports", "demo-25-35", "gender-male"]) + } + other => panic!("expected IDs payload, got {other:?}"), + } + + let deals = &response.mutations[1]; + assert_eq!(deals.intent, Intent::ActivateDeals as i32); + assert_eq!(deals.op, Operation::Add as i32); + assert_eq!(deals.path, PATH_IMP_1); + } + + #[tokio::test] + async fn bid_shade_mutation_replaces_price_at_bid_path() { + let response = evaluate(request("req-1", "auction-789")).await; + + let shade = response.mutations.last().expect("bid shade mutation"); + assert_eq!(shade.intent, Intent::BidShade as i32); + assert_eq!(shade.op, Operation::Replace as i32); + assert_eq!(shade.path, PATH_SEATBID_BID_ABC); + match shade.value.as_ref() { + Some(Value::AdjustBid(adjust)) => assert_eq!(adjust.price, 4.675), + other => panic!("expected AdjustBid payload, got {other:?}"), + } + } + + #[tokio::test] + async fn unknown_auction_returns_no_mutations() { + let response = evaluate(request("req-1", "auction-unknown")).await; + assert!(response.mutations.is_empty()); + assert!(response.metadata.is_some()); + } + + #[tokio::test] + async fn missing_bid_request_returns_no_mutations() { + let request = RtbRequest { + id: "req-1".to_string(), + ..Default::default() + }; + let response = evaluate(request).await; + assert_eq!(response.id, "req-1"); + assert!(response.mutations.is_empty()); + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs index c182fe9..7b6e5dc 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -10,6 +10,8 @@ mod bidder; mod config; mod mutation; mod proto; +#[cfg(test)] +mod sample_tests; mod service; use crate::config::{Config, API_VERSION, MODEL_VERSION}; diff --git a/rust/src/mutation/builder.rs b/rust/src/mutation/builder.rs index 1763cf2..4237468 100644 --- a/rust/src/mutation/builder.rs +++ b/rust/src/mutation/builder.rs @@ -49,3 +49,51 @@ fn ids_payload(ids: &[&str]) -> IDsPayload { id: ids.iter().map(|id| id.to_string()).collect(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metadata_uses_configured_versions() { + let metadata = build_metadata(); + assert_eq!(metadata.api_version, API_VERSION); + assert_eq!(metadata.model_version, MODEL_VERSION); + } + + #[test] + fn activate_segments_adds_ids_at_user_segment_path() { + let mutation = activate_segments(&["seg-1", "seg-2"]); + assert_eq!(mutation.intent, Intent::ActivateSegments as i32); + assert_eq!(mutation.op, Operation::Add as i32); + assert_eq!(mutation.path, PATH_USER_SEGMENT); + match mutation.value { + Some(Value::Ids(ids)) => assert_eq!(ids.id, vec!["seg-1", "seg-2"]), + other => panic!("expected IDs payload, got {other:?}"), + } + } + + #[test] + fn activate_deals_adds_ids_at_given_path() { + let mutation = activate_deals("/imp/imp-1", &["deal-1"]); + assert_eq!(mutation.intent, Intent::ActivateDeals as i32); + assert_eq!(mutation.op, Operation::Add as i32); + assert_eq!(mutation.path, "/imp/imp-1"); + match mutation.value { + Some(Value::Ids(ids)) => assert_eq!(ids.id, vec!["deal-1"]), + other => panic!("expected IDs payload, got {other:?}"), + } + } + + #[test] + fn bid_shade_replaces_price_at_given_path() { + let mutation = bid_shade("/seatbid/dsp-001/bid/bid-abc", 4.675); + assert_eq!(mutation.intent, Intent::BidShade as i32); + assert_eq!(mutation.op, Operation::Replace as i32); + assert_eq!(mutation.path, "/seatbid/dsp-001/bid/bid-abc"); + match mutation.value { + Some(Value::AdjustBid(adjust)) => assert_eq!(adjust.price, 4.675), + other => panic!("expected AdjustBid payload, got {other:?}"), + } + } +} diff --git a/rust/src/sample_tests.rs b/rust/src/sample_tests.rs new file mode 100644 index 0000000..f28b746 --- /dev/null +++ b/rust/src/sample_tests.rs @@ -0,0 +1,94 @@ +//! Tests that exercise the bundled sample payloads in `samples/`. +//! +//! Each sample must decode as a schema-valid `RtbRequest` (proto3 JSON +//! mapping, unknown fields rejected) and drive the demo evaluator to the +//! mutations its scenario describes. This keeps the shared sample payloads +//! and the reference implementation from drifting apart. + +use prost_reflect::{DescriptorPool, DynamicMessage}; + +use crate::bidder::evaluate; +use crate::proto::com::iabtechlab::bidstream::mutation::v1::{mutation::Value, Intent, RtbRequest}; + +const DESCRIPTOR_BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/artf_descriptor.bin")); + +const SAMPLES: [&str; 5] = [ + "banner-basic.json", + "video-deals.json", + "bid-shading.json", + "multi-impression.json", + "native-ad.json", +]; + +fn load_sample(name: &str) -> RtbRequest { + let path = format!("{}/../samples/{}", env!("CARGO_MANIFEST_DIR"), name); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {path}: {error}")); + + let pool = DescriptorPool::decode(DESCRIPTOR_BYTES).expect("descriptor set"); + let descriptor = pool + .get_message_by_name("com.iabtechlab.bidstream.mutation.v1.RTBRequest") + .expect("RTBRequest descriptor"); + + let mut deserializer = serde_json::Deserializer::from_str(&json); + let message = DynamicMessage::deserialize(descriptor, &mut deserializer) + .unwrap_or_else(|error| panic!("{name} is not a valid RtbRequest payload: {error}")); + deserializer.end().expect("trailing JSON content"); + + message.transcode_to().expect("transcode to RtbRequest") +} + +#[test] +fn all_samples_decode_as_rtb_requests() { + for name in SAMPLES { + let request = load_sample(name); + assert!(!request.id.is_empty(), "{name} carries an envelope id"); + assert!( + request.bid_request.is_some(), + "{name} carries a bid_request" + ); + assert!( + !request.applicable_intents.is_empty(), + "{name} declares applicable intents" + ); + } +} + +#[tokio::test] +async fn samples_drive_demo_evaluator_mutations() { + let cases = [ + ("banner-basic.json", 2), + ("video-deals.json", 2), + ("bid-shading.json", 3), + ("multi-impression.json", 4), + ("native-ad.json", 2), + ]; + for (name, expected_mutations) in cases { + let request = load_sample(name); + let envelope_id = request.id.clone(); + + let response = evaluate(request).await; + + assert_eq!(response.id, envelope_id, "{name} echoes the envelope id"); + assert_eq!( + response.mutations.len(), + expected_mutations, + "{name} produces the demo mutations for its scenario" + ); + assert!(response.metadata.is_some(), "{name} carries metadata"); + } +} + +#[tokio::test] +async fn bid_shading_sample_produces_bid_shade_mutation() { + let response = evaluate(load_sample("bid-shading.json")).await; + let shade = response + .mutations + .iter() + .find(|mutation| mutation.intent == Intent::BidShade as i32) + .expect("bid shade mutation"); + match shade.value.as_ref() { + Some(Value::AdjustBid(adjust)) => assert!(adjust.price > 0.0), + other => panic!("expected AdjustBid payload, got {other:?}"), + } +} diff --git a/samples/multi-impression.json b/samples/multi-impression.json index e6681a0..e9157b5 100644 --- a/samples/multi-impression.json +++ b/samples/multi-impression.json @@ -111,7 +111,7 @@ "devicetype": 2, "ip": "203.0.113.50", "language": "en", - "js": 1, + "js": true, "geo": { "country": "USA", "region": "TX", @@ -119,12 +119,12 @@ } }, "regs": { - "coppa": 0, - "gdpr": 0, + "coppa": false, + "gdpr": false, "us_privacy": "1YNN" }, "source": { - "fd": 1, + "fd": true, "tid": "transaction-abc-123" }, "at": 1, diff --git a/samples/native-ad.json b/samples/native-ad.json index f06420d..9fa4040 100644 --- a/samples/native-ad.json +++ b/samples/native-ad.json @@ -60,7 +60,7 @@ "w": 390, "h": 844, "pxratio": 3.0, - "js": 1, + "js": true, "language": "en", "connectiontype": 6, "ifa": "8A2E0A2B-3C4D-5E6F-7A8B-9C0D1E2F3A4B", @@ -75,7 +75,7 @@ } }, "regs": { - "coppa": 0 + "coppa": false } } } From 4cd6540704eec88667b22197cfbb17cea7f82784 Mon Sep 17 00:00:00 2001 From: Aleksander Sekowski Date: Sun, 5 Jul 2026 19:11:18 -0700 Subject: [PATCH 2/2] Honor applicable_intents in the Rust demo evaluator The evaluator returned every mutation its auction arm defines, ignoring the request's applicable_intents. The Go implementation gates every mutation through IsIntentApplicable, and the field is documented as the list of intents the server is eligible to send back. Mutations are now filtered with the same semantics: an empty list means all intents are applicable. samples/bid-shading.json declares only BID_SHADE, so its response drops from 3 mutations to 1; verified over a live gRPC call. All other samples declare the intents their mutations use and are unchanged. cargo test: 16 passed. --- rust/src/bidder/evaluator.rs | 50 +++++++++++++++++++++++++++++++++--- rust/src/sample_tests.rs | 4 ++- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/rust/src/bidder/evaluator.rs b/rust/src/bidder/evaluator.rs index 85b0975..546a6dc 100644 --- a/rust/src/bidder/evaluator.rs +++ b/rust/src/bidder/evaluator.rs @@ -6,7 +6,15 @@ use crate::mutation::types::{ }; use crate::proto::com::iabtechlab::bidstream::mutation::v1::{RtbRequest, RtbResponse}; -/// Evaluate the `RtbRequest` and return an `RtbResponse`. +/// True when `intent` may be returned given the request's applicable intents. +/// An empty list means all intents are applicable, matching the Go +/// implementation's `IsIntentApplicable` semantics. +fn is_intent_applicable(intent: i32, applicable_intents: &[i32]) -> bool { + applicable_intents.is_empty() || applicable_intents.contains(&intent) +} + +/// Evaluate the `RtbRequest` and return an `RtbResponse`. Mutations are +/// limited to the intents the request declared applicable. pub async fn evaluate(req: RtbRequest) -> RtbResponse { // For demonstration purposes, we will create a static response // In a real-world scenario, you would implement logic to evaluate the request @@ -22,7 +30,9 @@ pub async fn evaluate(req: RtbRequest) -> RtbResponse { .and_then(|bid_request| bid_request.id.clone()) .unwrap_or_default(); - match auction_id.as_str() { + let applicable_intents = req.applicable_intents.clone(); + + let mut response = match auction_id.as_str() { "auction-123" => RtbResponse { id: req.id, mutations: vec![ @@ -71,7 +81,13 @@ pub async fn evaluate(req: RtbRequest) -> RtbResponse { mutations: vec![], metadata: Some(metadata), }, - } + }; + + response + .mutations + .retain(|mutation| is_intent_applicable(mutation.intent, &applicable_intents)); + + response } #[cfg(test)] @@ -158,6 +174,34 @@ mod tests { assert!(response.metadata.is_some()); } + #[tokio::test] + async fn mutations_are_limited_to_applicable_intents() { + let mut req = request("req-1", "auction-789"); + req.applicable_intents = vec![Intent::BidShade as i32]; + + let response = evaluate(req).await; + + assert_eq!(response.mutations.len(), 1); + assert_eq!(response.mutations[0].intent, Intent::BidShade as i32); + } + + #[tokio::test] + async fn empty_applicable_intents_allows_all_mutations() { + let response = evaluate(request("req-1", "auction-789")).await; + assert_eq!(response.mutations.len(), 3); + } + + #[tokio::test] + async fn no_matching_applicable_intent_returns_no_mutations() { + let mut req = request("req-1", "auction-123"); + req.applicable_intents = vec![Intent::AdjustDealFloor as i32]; + + let response = evaluate(req).await; + + assert!(response.mutations.is_empty()); + assert!(response.metadata.is_some()); + } + #[tokio::test] async fn missing_bid_request_returns_no_mutations() { let request = RtbRequest { diff --git a/rust/src/sample_tests.rs b/rust/src/sample_tests.rs index f28b746..508bcc9 100644 --- a/rust/src/sample_tests.rs +++ b/rust/src/sample_tests.rs @@ -56,10 +56,12 @@ fn all_samples_decode_as_rtb_requests() { #[tokio::test] async fn samples_drive_demo_evaluator_mutations() { + // bid-shading.json declares only BID_SHADE applicable, so the demo + // evaluator's segment and deal mutations for that auction are filtered out. let cases = [ ("banner-basic.json", 2), ("video-deals.json", 2), - ("bid-shading.json", 3), + ("bid-shading.json", 1), ("multi-impression.json", 4), ("native-ad.json", 2), ];