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..546a6dc 100644 --- a/rust/src/bidder/evaluator.rs +++ b/rust/src/bidder/evaluator.rs @@ -6,14 +6,33 @@ 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 // 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(); + + let applicable_intents = req.applicable_intents.clone(); + + let mut response = match auction_id.as_str() { "auction-123" => RtbResponse { id: req.id, mutations: vec![ @@ -49,7 +68,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"]), @@ -62,5 +81,135 @@ 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)] +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 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 { + 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..508bcc9 --- /dev/null +++ b/rust/src/sample_tests.rs @@ -0,0 +1,96 @@ +//! 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() { + // 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", 1), + ("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 } } }