From 265d4ee149673a0fdb63f845c3df46fd27efbf3d Mon Sep 17 00:00:00 2001 From: nachiketb Date: Tue, 8 Sep 2026 14:17:54 -0700 Subject: [PATCH 1/4] feat(libsy): add optional decision metadata Signed-off-by: nachiketb --- crates/libsy/src/core.rs | 1 + crates/libsy/src/core/algorithm.rs | 5 +++++ crates/libsy/src/core/decision.rs | 13 +++++++++++++ crates/libsy/src/lib.rs | 1 + crates/switchyard-py/src/libsy_bindings.rs | 1 + 5 files changed, 21 insertions(+) create mode 100644 crates/libsy/src/core/decision.rs diff --git a/crates/libsy/src/core.rs b/crates/libsy/src/core.rs index b22ccc2ee..76f6b1374 100644 --- a/crates/libsy/src/core.rs +++ b/crates/libsy/src/core.rs @@ -10,5 +10,6 @@ pub(crate) mod testing; pub mod algorithm; pub mod classifier; +pub mod decision; pub mod processor; pub mod state; diff --git a/crates/libsy/src/core/algorithm.rs b/crates/libsy/src/core/algorithm.rs index 9c89a4553..81fffa1f4 100644 --- a/crates/libsy/src/core/algorithm.rs +++ b/crates/libsy/src/core/algorithm.rs @@ -68,6 +68,8 @@ pub struct RoutingOutcome { pub request: Request, /// A response produced while routing, or `None` when the client must make the answer call. pub response: Option, + /// Optional algorithm-owned explanation of the routing decision. + pub decision: Option, } impl RoutingOutcome { @@ -93,6 +95,7 @@ impl RoutingOutcome { selected_model_ids, request, response: None, + decision: None, } } @@ -104,6 +107,7 @@ impl RoutingOutcome { selected_model_ids: vec![selected_model_id], request, response: Some(response), + decision: None, } } } @@ -483,6 +487,7 @@ mod tests { ); assert_eq!(outcome.request.model_id().as_deref(), Some("selected")); assert!(outcome.response.is_none()); + assert!(outcome.decision.is_none()); let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request()); assert_eq!(outcome.selected_model_ids, target_set(&["only"])); diff --git a/crates/libsy/src/core/decision.rs b/crates/libsy/src/core/decision.rs new file mode 100644 index 000000000..754cb492e --- /dev/null +++ b/crates/libsy/src/core/decision.rs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Optional metadata explaining a routing decision. + +/// Algorithm-owned metadata attached to a successful routing outcome. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DecisionMetadata { + /// Stable name of the algorithm that made the decision. + pub algorithm: String, + /// Optional bounded, machine-readable evidence produced by the algorithm. + pub evidence: Option, +} diff --git a/crates/libsy/src/lib.rs b/crates/libsy/src/lib.rs index 3907feb3c..c67b5c2e1 100644 --- a/crates/libsy/src/lib.rs +++ b/crates/libsy/src/lib.rs @@ -7,6 +7,7 @@ mod core; pub use core::algorithm::{Algorithm, CallModel, Driver, RoutingOutcome, Step, StepStream, drive}; pub use core::classifier::{Classification, Classifier, Score}; +pub use core::decision::DecisionMetadata; pub use core::processor::{Event, Processor}; pub use core::state::{State, StateValue}; diff --git a/crates/switchyard-py/src/libsy_bindings.rs b/crates/switchyard-py/src/libsy_bindings.rs index 0571a3e12..c1a930c02 100644 --- a/crates/switchyard-py/src/libsy_bindings.rs +++ b/crates/switchyard-py/src/libsy_bindings.rs @@ -675,6 +675,7 @@ fn step_to_python(step: RustStep) -> PyResult { selected_model_ids, request, response, + decision: _, } = *outcome; Python::attach(|py| { Ok(PyStep::Done { From 04eb238cf25ea4de7abd5102398f0411faabd763 Mon Sep 17 00:00:00 2001 From: nachiketb Date: Tue, 8 Sep 2026 14:32:07 -0700 Subject: [PATCH 2/4] feat(libsy): assign routing decision IDs Signed-off-by: nachiketb --- Cargo.lock | 1 + Cargo.toml | 1 + crates/libsy/Cargo.toml | 1 + crates/libsy/src/core/algorithm.rs | 31 ++++++++++++++++++++++++++++-- crates/libsy/src/core/decision.rs | 5 ++++- crates/libsy/src/observability.rs | 3 ++- 6 files changed, 38 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4adb2d8dc..407fd40ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2342,6 +2342,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7a8fc60ef..e3e26e114 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,3 +54,4 @@ tokio = { version = "1", features = ["full"] } tracing = { version = "0.1", default-features = false, features = ["attributes", "std"] } tracing-opentelemetry = "0.33" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v7"] } diff --git a/crates/libsy/Cargo.toml b/crates/libsy/Cargo.toml index 369cb6160..fa92e3094 100644 --- a/crates/libsy/Cargo.toml +++ b/crates/libsy/Cargo.toml @@ -37,6 +37,7 @@ tokio.workspace = true tokio-stream = "0.1" tracing.workspace = true tracing-opentelemetry.workspace = true +uuid.workspace = true [dev-dependencies] # SDK + in-memory exporter to assert what the observability layer records. diff --git a/crates/libsy/src/core/algorithm.rs b/crates/libsy/src/core/algorithm.rs index 81fffa1f4..4018b8fb2 100644 --- a/crates/libsy/src/core/algorithm.rs +++ b/crates/libsy/src/core/algorithm.rs @@ -68,7 +68,10 @@ pub struct RoutingOutcome { pub request: Request, /// A response produced while routing, or `None` when the client must make the answer call. pub response: Option, - /// Optional algorithm-owned explanation of the routing decision. + /// Decision identity and optional algorithm evidence. + /// + /// Constructors leave this empty; [`Algorithm::run_stream`] fills it before publishing a + /// successful outcome. pub decision: Option, } @@ -116,6 +119,8 @@ impl RoutingOutcome { #[derive(Clone)] pub struct Driver { step_tx: mpsc::Sender>, + /// Identifier shared by this run's outcome and telemetry. + decision_id: String, /// The owning algorithm's telemetry label, stamped onto every call this driver publishes. algorithm: String, } @@ -132,6 +137,7 @@ impl Driver { ( Self { step_tx, + decision_id: uuid::Uuid::now_v7().to_string(), algorithm: algorithm.to_string(), }, step_rx, @@ -200,6 +206,15 @@ impl Driver { /// item on failure. Internal: called once by [`run_stream`](Algorithm::run_stream) /// when the algorithm finishes. pub(crate) async fn finish(&self, result: Result) -> Result<()> { + let result = result.map(|mut outcome| { + let evidence = outcome.decision.and_then(|decision| decision.evidence); + outcome.decision = Some(crate::DecisionMetadata { + decision_id: self.decision_id.clone(), + algorithm: self.algorithm.clone(), + evidence, + }); + outcome + }); let selected_model = result .as_ref() .ok() @@ -380,7 +395,7 @@ pub trait Algorithm: Send + Sync + 'static { /// Every invocation owns a separate [`Driver`]. fn run_stream(self: Arc, request: Request) -> StepStream { let (driver, step_rx) = Driver::new(self.name()); - let span = observability::run_span(self.name(), &request); + let span = observability::run_span(self.name(), &driver.decision_id, &request); let handle = tokio::spawn( async move { let algorithm = self.name().to_string(); @@ -722,6 +737,18 @@ mod tests { }))?; } Step::Done(outcome) => { + let decision = outcome + .decision + .as_ref() + .expect("run_stream should attach decision metadata"); + assert_eq!(decision.algorithm, "test"); + assert_eq!( + uuid::Uuid::parse_str(&decision.decision_id) + .expect("decision id should be a UUID") + .get_version_num(), + 7 + ); + assert!(decision.evidence.is_none()); let response = outcome .response .ok_or_else(|| test_error("expected an answered outcome"))?; diff --git a/crates/libsy/src/core/decision.rs b/crates/libsy/src/core/decision.rs index 754cb492e..eab27b9a4 100644 --- a/crates/libsy/src/core/decision.rs +++ b/crates/libsy/src/core/decision.rs @@ -3,9 +3,12 @@ //! Optional metadata explaining a routing decision. -/// Algorithm-owned metadata attached to a successful routing outcome. +/// Metadata attached by libsy to a successful routing outcome. #[derive(Clone, Debug, PartialEq, Eq)] pub struct DecisionMetadata { + /// Unique identifier for this invocation of + /// [`Algorithm::run_stream`](crate::Algorithm::run_stream). + pub decision_id: String, /// Stable name of the algorithm that made the decision. pub algorithm: String, /// Optional bounded, machine-readable evidence produced by the algorithm. diff --git a/crates/libsy/src/observability.rs b/crates/libsy/src/observability.rs index ab6c4c89d..264d2edc0 100644 --- a/crates/libsy/src/observability.rs +++ b/crates/libsy/src/observability.rs @@ -65,11 +65,12 @@ pub(crate) fn outcome_value(result: &Result) -> &'static str { /// arbitrary host labels ride in via [`switchyard_protocol::Metadata::extra_metadata`], recorded /// whole into the `extra_metadata` field. `outcome` and `error` are filled in /// by [`record_run`] when the run ends. -pub(crate) fn run_span(algorithm: &str, request: &Request) -> Span { +pub(crate) fn run_span(algorithm: &str, decision_id: &str, request: &Request) -> Span { let span = tracing::info_span!( target: TRACING_TARGET, "libsy.run", algorithm, + decision_id, switchyard.algorithm = algorithm, openinference.span.kind = "CHAIN", switchyard.route = tracing::field::Empty, From 1f30eda604f8b922577b687fe5bda8421130d3e1 Mon Sep 17 00:00:00 2001 From: nachiketb Date: Tue, 8 Sep 2026 14:49:39 -0700 Subject: [PATCH 3/4] refactor(libsy): rename decision metadata Signed-off-by: nachiketb --- crates/libsy/src/core.rs | 2 +- crates/libsy/src/core/algorithm.rs | 39 ++++++++--------- crates/libsy/src/core/decision.rs | 16 ------- crates/libsy/src/core/outcome_metadata.rs | 51 ++++++++++++++++++++++ crates/libsy/src/lib.rs | 2 +- crates/libsy/src/observability.rs | 4 +- crates/switchyard-py/src/libsy_bindings.rs | 2 +- 7 files changed, 73 insertions(+), 43 deletions(-) delete mode 100644 crates/libsy/src/core/decision.rs create mode 100644 crates/libsy/src/core/outcome_metadata.rs diff --git a/crates/libsy/src/core.rs b/crates/libsy/src/core.rs index 76f6b1374..998b8e902 100644 --- a/crates/libsy/src/core.rs +++ b/crates/libsy/src/core.rs @@ -10,6 +10,6 @@ pub(crate) mod testing; pub mod algorithm; pub mod classifier; -pub mod decision; +pub mod outcome_metadata; pub mod processor; pub mod state; diff --git a/crates/libsy/src/core/algorithm.rs b/crates/libsy/src/core/algorithm.rs index 4018b8fb2..56e56f5fd 100644 --- a/crates/libsy/src/core/algorithm.rs +++ b/crates/libsy/src/core/algorithm.rs @@ -68,11 +68,11 @@ pub struct RoutingOutcome { pub request: Request, /// A response produced while routing, or `None` when the client must make the answer call. pub response: Option, - /// Decision identity and optional algorithm evidence. + /// Outcome identity and optional algorithm evidence. /// /// Constructors leave this empty; [`Algorithm::run_stream`] fills it before publishing a /// successful outcome. - pub decision: Option, + pub metadata: Option, } impl RoutingOutcome { @@ -98,7 +98,7 @@ impl RoutingOutcome { selected_model_ids, request, response: None, - decision: None, + metadata: None, } } @@ -110,7 +110,7 @@ impl RoutingOutcome { selected_model_ids: vec![selected_model_id], request, response: Some(response), - decision: None, + metadata: None, } } } @@ -119,8 +119,6 @@ impl RoutingOutcome { #[derive(Clone)] pub struct Driver { step_tx: mpsc::Sender>, - /// Identifier shared by this run's outcome and telemetry. - decision_id: String, /// The owning algorithm's telemetry label, stamped onto every call this driver publishes. algorithm: String, } @@ -137,7 +135,6 @@ impl Driver { ( Self { step_tx, - decision_id: uuid::Uuid::now_v7().to_string(), algorithm: algorithm.to_string(), }, step_rx, @@ -207,12 +204,10 @@ impl Driver { /// when the algorithm finishes. pub(crate) async fn finish(&self, result: Result) -> Result<()> { let result = result.map(|mut outcome| { - let evidence = outcome.decision.and_then(|decision| decision.evidence); - outcome.decision = Some(crate::DecisionMetadata { - decision_id: self.decision_id.clone(), - algorithm: self.algorithm.clone(), - evidence, - }); + let metadata = outcome + .metadata + .get_or_insert_with(|| crate::OutcomeMetadata::new(self.algorithm.clone(), None)); + tracing::Span::current().record("outcome_id", metadata.outcome_id()); outcome }); let selected_model = result @@ -395,7 +390,7 @@ pub trait Algorithm: Send + Sync + 'static { /// Every invocation owns a separate [`Driver`]. fn run_stream(self: Arc, request: Request) -> StepStream { let (driver, step_rx) = Driver::new(self.name()); - let span = observability::run_span(self.name(), &driver.decision_id, &request); + let span = observability::run_span(self.name(), &request); let handle = tokio::spawn( async move { let algorithm = self.name().to_string(); @@ -502,7 +497,7 @@ mod tests { ); assert_eq!(outcome.request.model_id().as_deref(), Some("selected")); assert!(outcome.response.is_none()); - assert!(outcome.decision.is_none()); + assert!(outcome.metadata.is_none()); let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request()); assert_eq!(outcome.selected_model_ids, target_set(&["only"])); @@ -737,18 +732,18 @@ mod tests { }))?; } Step::Done(outcome) => { - let decision = outcome - .decision + let metadata = outcome + .metadata .as_ref() - .expect("run_stream should attach decision metadata"); - assert_eq!(decision.algorithm, "test"); + .expect("run_stream should attach outcome metadata"); + assert_eq!(metadata.algorithm, "test"); assert_eq!( - uuid::Uuid::parse_str(&decision.decision_id) - .expect("decision id should be a UUID") + uuid::Uuid::parse_str(metadata.outcome_id()) + .expect("outcome id should be a UUID") .get_version_num(), 7 ); - assert!(decision.evidence.is_none()); + assert!(metadata.evidence.is_none()); let response = outcome .response .ok_or_else(|| test_error("expected an answered outcome"))?; diff --git a/crates/libsy/src/core/decision.rs b/crates/libsy/src/core/decision.rs deleted file mode 100644 index eab27b9a4..000000000 --- a/crates/libsy/src/core/decision.rs +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Optional metadata explaining a routing decision. - -/// Metadata attached by libsy to a successful routing outcome. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct DecisionMetadata { - /// Unique identifier for this invocation of - /// [`Algorithm::run_stream`](crate::Algorithm::run_stream). - pub decision_id: String, - /// Stable name of the algorithm that made the decision. - pub algorithm: String, - /// Optional bounded, machine-readable evidence produced by the algorithm. - pub evidence: Option, -} diff --git a/crates/libsy/src/core/outcome_metadata.rs b/crates/libsy/src/core/outcome_metadata.rs new file mode 100644 index 000000000..5bf325a0b --- /dev/null +++ b/crates/libsy/src/core/outcome_metadata.rs @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Metadata describing a routing outcome. + +/// Identity and optional algorithm evidence attached to a successful routing outcome. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OutcomeMetadata { + outcome_id: String, + /// Stable name of the algorithm that produced the outcome. + pub algorithm: String, + /// Optional bounded, machine-readable evidence produced by the algorithm. + pub evidence: Option, +} + +impl OutcomeMetadata { + /// Creates outcome metadata with a new UUIDv7 identifier. + pub fn new(algorithm: String, evidence: Option) -> Self { + Self { + outcome_id: uuid::Uuid::now_v7().to_string(), + algorithm, + evidence, + } + } + + /// Returns the unique identifier for this outcome. + pub fn outcome_id(&self) -> &str { + &self.outcome_id + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_assigns_unique_uuidv7_and_preserves_inputs() { + let metadata = OutcomeMetadata::new("test".to_string(), Some("matched".to_string())); + let another = OutcomeMetadata::new("test".to_string(), None); + + assert_eq!(metadata.algorithm, "test"); + assert_eq!(metadata.evidence.as_deref(), Some("matched")); + assert_eq!( + uuid::Uuid::parse_str(metadata.outcome_id()) + .expect("outcome id should be a UUID") + .get_version_num(), + 7 + ); + assert_ne!(metadata.outcome_id(), another.outcome_id()); + } +} diff --git a/crates/libsy/src/lib.rs b/crates/libsy/src/lib.rs index c67b5c2e1..ad70b8da2 100644 --- a/crates/libsy/src/lib.rs +++ b/crates/libsy/src/lib.rs @@ -7,7 +7,7 @@ mod core; pub use core::algorithm::{Algorithm, CallModel, Driver, RoutingOutcome, Step, StepStream, drive}; pub use core::classifier::{Classification, Classifier, Score}; -pub use core::decision::DecisionMetadata; +pub use core::outcome_metadata::OutcomeMetadata; pub use core::processor::{Event, Processor}; pub use core::state::{State, StateValue}; diff --git a/crates/libsy/src/observability.rs b/crates/libsy/src/observability.rs index 264d2edc0..763d31153 100644 --- a/crates/libsy/src/observability.rs +++ b/crates/libsy/src/observability.rs @@ -65,12 +65,12 @@ pub(crate) fn outcome_value(result: &Result) -> &'static str { /// arbitrary host labels ride in via [`switchyard_protocol::Metadata::extra_metadata`], recorded /// whole into the `extra_metadata` field. `outcome` and `error` are filled in /// by [`record_run`] when the run ends. -pub(crate) fn run_span(algorithm: &str, decision_id: &str, request: &Request) -> Span { +pub(crate) fn run_span(algorithm: &str, request: &Request) -> Span { let span = tracing::info_span!( target: TRACING_TARGET, "libsy.run", algorithm, - decision_id, + outcome_id = tracing::field::Empty, switchyard.algorithm = algorithm, openinference.span.kind = "CHAIN", switchyard.route = tracing::field::Empty, diff --git a/crates/switchyard-py/src/libsy_bindings.rs b/crates/switchyard-py/src/libsy_bindings.rs index c1a930c02..756dbc8bf 100644 --- a/crates/switchyard-py/src/libsy_bindings.rs +++ b/crates/switchyard-py/src/libsy_bindings.rs @@ -675,7 +675,7 @@ fn step_to_python(step: RustStep) -> PyResult { selected_model_ids, request, response, - decision: _, + metadata: _, } = *outcome; Python::attach(|py| { Ok(PyStep::Done { From 46005c766d309f5bc05d2c3559807f4861c4c7bd Mon Sep 17 00:00:00 2001 From: nachiketb Date: Tue, 8 Sep 2026 15:40:44 -0700 Subject: [PATCH 4/4] test: remove dumb test Signed-off-by: nachiketb --- crates/libsy/src/core/outcome_metadata.rs | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/crates/libsy/src/core/outcome_metadata.rs b/crates/libsy/src/core/outcome_metadata.rs index 5bf325a0b..595e09961 100644 --- a/crates/libsy/src/core/outcome_metadata.rs +++ b/crates/libsy/src/core/outcome_metadata.rs @@ -28,24 +28,3 @@ impl OutcomeMetadata { &self.outcome_id } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn new_assigns_unique_uuidv7_and_preserves_inputs() { - let metadata = OutcomeMetadata::new("test".to_string(), Some("matched".to_string())); - let another = OutcomeMetadata::new("test".to_string(), None); - - assert_eq!(metadata.algorithm, "test"); - assert_eq!(metadata.evidence.as_deref(), Some("matched")); - assert_eq!( - uuid::Uuid::parse_str(metadata.outcome_id()) - .expect("outcome id should be a UUID") - .get_version_num(), - 7 - ); - assert_ne!(metadata.outcome_id(), another.outcome_id()); - } -}