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.rs b/crates/libsy/src/core.rs index b22ccc2ee..998b8e902 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 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 9c89a4553..56e56f5fd 100644 --- a/crates/libsy/src/core/algorithm.rs +++ b/crates/libsy/src/core/algorithm.rs @@ -68,6 +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, + /// Outcome identity and optional algorithm evidence. + /// + /// Constructors leave this empty; [`Algorithm::run_stream`] fills it before publishing a + /// successful outcome. + pub metadata: Option, } impl RoutingOutcome { @@ -93,6 +98,7 @@ impl RoutingOutcome { selected_model_ids, request, response: None, + metadata: None, } } @@ -104,6 +110,7 @@ impl RoutingOutcome { selected_model_ids: vec![selected_model_id], request, response: Some(response), + metadata: None, } } } @@ -196,6 +203,13 @@ 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 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 .as_ref() .ok() @@ -483,6 +497,7 @@ mod tests { ); assert_eq!(outcome.request.model_id().as_deref(), Some("selected")); assert!(outcome.response.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"])); @@ -717,6 +732,18 @@ mod tests { }))?; } Step::Done(outcome) => { + let metadata = outcome + .metadata + .as_ref() + .expect("run_stream should attach outcome metadata"); + assert_eq!(metadata.algorithm, "test"); + assert_eq!( + uuid::Uuid::parse_str(metadata.outcome_id()) + .expect("outcome id should be a UUID") + .get_version_num(), + 7 + ); + 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/outcome_metadata.rs b/crates/libsy/src/core/outcome_metadata.rs new file mode 100644 index 000000000..595e09961 --- /dev/null +++ b/crates/libsy/src/core/outcome_metadata.rs @@ -0,0 +1,30 @@ +// 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 + } +} diff --git a/crates/libsy/src/lib.rs b/crates/libsy/src/lib.rs index 3907feb3c..ad70b8da2 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::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 ab6c4c89d..763d31153 100644 --- a/crates/libsy/src/observability.rs +++ b/crates/libsy/src/observability.rs @@ -70,6 +70,7 @@ pub(crate) fn run_span(algorithm: &str, request: &Request) -> Span { target: TRACING_TARGET, "libsy.run", algorithm, + 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 0571a3e12..756dbc8bf 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, + metadata: _, } = *outcome; Python::attach(|py| { Ok(PyStep::Done {