diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs
new file mode 100644
index 00000000000..f4227f9b199
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs
@@ -0,0 +1,404 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable SA1402 // File may only contain a single type — decision answer types are co-located as one immutable model.
+#pragma warning disable SA1649 // File name should match first type name — decision answer types are co-located as one immutable model.
+#pragma warning disable CA1032 // Implement standard exception constructors — protocol exceptions expose the protocol-specific constructors.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Text.Json.Serialization;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+/// Identifies the precision represented by provider-reported probabilities.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public enum DecisionPrecision
+{
+ /// The provider reports values without a declared decimal rounding.
+ HighPrecision,
+
+ /// The provider reports values rounded to four decimal places.
+ FourDecimalPlaces,
+
+ /// The provider reports values rounded to two decimal places.
+ TwoDecimalPlaces,
+}
+
+/// Represents one named probability observation.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class DecisionProbability
+{
+ /// Initializes a new instance of the class.
+ /// The exact opaque candidate or level ID.
+ /// The probability value.
+ [JsonConstructor]
+ public DecisionProbability(string id, double value)
+ {
+ Id = Throw.IfNullOrWhitespace(id);
+ Value = value;
+ }
+
+ /// Gets the exact opaque candidate or level ID.
+ public string Id { get; }
+
+ /// Gets the native double probability value.
+ public double Value { get; }
+}
+
+/// Represents a provider result for one decision question.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
+[JsonDerivedType(typeof(BinaryDecisionAnswer), "binary")]
+[JsonDerivedType(typeof(ChoiceDecisionAnswer), "choice")]
+[JsonDerivedType(typeof(ScoreDecisionAnswer), "score")]
+public abstract class DecisionAnswer
+{
+ internal const double DistributionTolerance = 1e-6;
+
+ private protected DecisionAnswer(
+ string questionId,
+ DecisionKind kind,
+ DecisionPrecision precision,
+ object? rawRepresentation,
+ IReadOnlyDictionary? additionalProperties)
+ {
+ QuestionId = Throw.IfNullOrWhitespace(questionId);
+ if (!Enum.IsDefined(typeof(DecisionPrecision), precision))
+ {
+ Throw.ArgumentOutOfRangeException(nameof(precision));
+ }
+
+ Kind = kind;
+ Precision = precision;
+ RawRepresentation = rawRepresentation;
+ AdditionalProperties = DecisionSnapshots.Properties(additionalProperties);
+ }
+
+ /// Gets the exact opaque question ID this answer belongs to.
+ public string QuestionId { get; }
+
+ /// Gets the semantic kind of this answer.
+ public DecisionKind Kind { get; }
+
+ /// Gets the precision declared for the numeric observations.
+ public DecisionPrecision Precision { get; }
+
+ ///
+ /// Gets the provider-owned raw representation, if available.
+ /// This value is not cloned or used for semantic data and must be treated as diagnostic-only.
+ ///
+ [JsonIgnore]
+ public object? RawRepresentation { get; }
+
+ ///
+ /// Gets an owned read-only snapshot of provider extension data.
+ /// Values are shallow-copied references and must be independently safe to share.
+ ///
+ public IReadOnlyDictionary? AdditionalProperties { get; }
+
+ internal static void ValidateProbability(double value)
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value) || value < 0 || value > 1)
+ {
+ throw new DecisionProtocolException("Probability must be finite and in the range [0, 1].");
+ }
+ }
+
+ internal static void ValidateDistribution(
+ IReadOnlyList probabilities,
+ DecisionPrecision precision)
+ {
+ double sum = 0;
+ HashSet ids = new(StringComparer.Ordinal);
+
+ foreach (DecisionProbability probability in probabilities)
+ {
+ if (probability is null)
+ {
+ throw new DecisionProtocolException("A probability distribution cannot contain null entries.");
+ }
+
+ if (!ids.Add(probability.Id))
+ {
+ throw new DecisionProtocolException("A probability distribution contains a duplicate ID.");
+ }
+
+ ValidateProbability(probability.Value);
+ sum += probability.Value;
+ }
+
+ double tolerance = GetDistributionTolerance(precision, probabilities.Count);
+ if (Math.Abs(sum - 1) > tolerance)
+ {
+ throw new DecisionProtocolException(
+ "A probability distribution must sum to 1 within the bound allowed by its declared rounding.");
+ }
+
+ double roundingHalfUnit = GetRoundingHalfUnit(precision);
+ double lowerBound = 0;
+ double upperBound = 0;
+ foreach (DecisionProbability probability in probabilities)
+ {
+ lowerBound += Math.Max(0, probability.Value - roundingHalfUnit);
+ upperBound += Math.Min(1, probability.Value + roundingHalfUnit);
+ }
+
+ if (lowerBound > 1 + DistributionTolerance || upperBound < 1 - DistributionTolerance)
+ {
+ throw new DecisionProtocolException(
+ "A probability distribution has no normalized latent values consistent with its declared rounding.");
+ }
+ }
+
+ internal static double GetDistributionTolerance(DecisionPrecision precision, int count)
+ {
+ return precision switch
+ {
+ DecisionPrecision.HighPrecision => DistributionTolerance,
+ DecisionPrecision.FourDecimalPlaces => (count * 0.00005) + DistributionTolerance,
+ DecisionPrecision.TwoDecimalPlaces => (count * 0.005) + DistributionTolerance,
+ _ => throw new ArgumentOutOfRangeException(nameof(precision)),
+ };
+ }
+
+ internal static (double Minimum, double Maximum) GetFeasibleExpectedScoreBounds(
+ IReadOnlyList probabilities,
+ DecisionPrecision precision)
+ {
+ double roundingHalfUnit = GetRoundingHalfUnit(precision);
+ double lowerMass = 0;
+ double upperMass = 0;
+ for (int i = 0; i < probabilities.Count; i++)
+ {
+ double probability = probabilities[i].Value;
+ lowerMass += Math.Max(0, probability - roundingHalfUnit);
+ upperMass += Math.Min(1, probability + roundingHalfUnit);
+ }
+
+ if (lowerMass > 1 + DistributionTolerance || upperMass < 1 - DistributionTolerance)
+ {
+ throw new DecisionProtocolException(
+ "A probability distribution has no normalized latent values consistent with its declared rounding.");
+ }
+
+ double remaining = Math.Max(0, 1 - lowerMass);
+ double minimum = 0;
+ for (int i = 0; i < probabilities.Count; i++)
+ {
+ double lower = Math.Max(0, probabilities[i].Value - roundingHalfUnit);
+ double capacity = Math.Min(1, probabilities[i].Value + roundingHalfUnit) - lower;
+ double allocated = Math.Min(remaining, capacity);
+ minimum += (lower + allocated) * i;
+ remaining -= allocated;
+ }
+
+ remaining = Math.Max(0, 1 - lowerMass);
+ double maximum = 0;
+ double[] lowerValues = new double[probabilities.Count];
+ for (int i = 0; i < probabilities.Count; i++)
+ {
+ lowerValues[i] = Math.Max(0, probabilities[i].Value - roundingHalfUnit);
+ maximum += lowerValues[i] * i;
+ }
+
+ for (int i = probabilities.Count - 1; i >= 0; i--)
+ {
+ double capacity = Math.Min(1, probabilities[i].Value + roundingHalfUnit) - lowerValues[i];
+ double allocated = Math.Min(remaining, capacity);
+ maximum += allocated * i;
+ remaining -= allocated;
+ }
+
+ if (remaining > DistributionTolerance)
+ {
+ throw new DecisionProtocolException(
+ "A probability distribution has no normalized latent values consistent with its declared rounding.");
+ }
+
+ return (minimum, maximum);
+ }
+
+ internal static double GetRoundingHalfUnit(DecisionPrecision precision) =>
+ precision switch
+ {
+ DecisionPrecision.HighPrecision => 0,
+ DecisionPrecision.FourDecimalPlaces => 0.00005,
+ DecisionPrecision.TwoDecimalPlaces => 0.005,
+ _ => throw new ArgumentOutOfRangeException(nameof(precision)),
+ };
+}
+
+/// Represents the probability that a binary proposition is true.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class BinaryDecisionAnswer : DecisionAnswer
+{
+ /// Initializes a new instance of the class.
+ [JsonConstructor]
+ public BinaryDecisionAnswer(
+ string questionId,
+ double trueProbability,
+ DecisionPrecision precision = DecisionPrecision.HighPrecision,
+ object? rawRepresentation = null,
+ IReadOnlyDictionary? additionalProperties = null)
+ : base(questionId, DecisionKind.Binary, precision, rawRepresentation, additionalProperties)
+ {
+ ValidateProbability(trueProbability);
+ TrueProbability = trueProbability;
+ }
+
+ /// Gets the probability that the proposition is true.
+ public double TrueProbability { get; }
+}
+
+/// Represents a selected candidate and its complete probability distribution.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class ChoiceDecisionAnswer : DecisionAnswer
+{
+ /// Initializes a new instance of the class.
+ [JsonConstructor]
+ public ChoiceDecisionAnswer(
+ string questionId,
+ string selectedCandidateId,
+ IReadOnlyList probabilities,
+ DecisionPrecision precision = DecisionPrecision.HighPrecision,
+ object? rawRepresentation = null,
+ IReadOnlyDictionary? additionalProperties = null)
+ : base(questionId, DecisionKind.Choice, precision, rawRepresentation, additionalProperties)
+ {
+ SelectedCandidateId = Throw.IfNullOrWhitespace(selectedCandidateId);
+ _ = Throw.IfNull(probabilities);
+
+ DecisionProbability[] copy = probabilities.ToArray();
+ if (copy.Length == 0)
+ {
+ throw new DecisionProtocolException("A choice answer requires a complete distribution.");
+ }
+
+ ValidateDistribution(copy, precision);
+ if (!Array.Exists(copy, probability => string.Equals(probability.Id, SelectedCandidateId, StringComparison.Ordinal)))
+ {
+ throw new DecisionProtocolException("The selected candidate is not present in the distribution.");
+ }
+
+ Probabilities = Array.AsReadOnly(copy);
+ }
+
+ /// Gets the exact selected candidate ID.
+ public string SelectedCandidateId { get; }
+
+ /// Gets the complete ordered candidate distribution.
+ public IReadOnlyList Probabilities { get; }
+}
+
+///
+/// Represents an ordinal score and its complete level distribution.
+/// A reported score is checked against normalized latent values within the declared clipped rounding intervals; observed probabilities and the derived expectation are never repaired.
+///
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class ScoreDecisionAnswer : DecisionAnswer
+{
+ /// Initializes a new instance of the class.
+ [JsonConstructor]
+ public ScoreDecisionAnswer(
+ string questionId,
+ double? score,
+ IReadOnlyList probabilities,
+ DecisionPrecision precision = DecisionPrecision.HighPrecision,
+ object? rawRepresentation = null,
+ IReadOnlyDictionary? additionalProperties = null)
+ : base(questionId, DecisionKind.Score, precision, rawRepresentation, additionalProperties)
+ {
+ _ = Throw.IfNull(probabilities);
+
+ DecisionProbability[] copy = probabilities.ToArray();
+ if (copy.Length < 2)
+ {
+ throw new DecisionProtocolException("A score answer requires at least two levels.");
+ }
+
+ ValidateDistribution(copy, precision);
+ if (score is double reportedScore &&
+ (double.IsNaN(reportedScore) || double.IsInfinity(reportedScore) || reportedScore < 0 || reportedScore > copy.Length - 1))
+ {
+ throw new DecisionProtocolException("A score must be finite and within the ordinal level range.");
+ }
+
+ double expectedScore = copy.Select((probability, index) => probability.Value * index).Sum();
+ if (score is double nativeScore)
+ {
+ (double minimum, double maximum) = GetFeasibleExpectedScoreBounds(copy, precision);
+ double scalarHalfUnit = GetRoundingHalfUnit(precision);
+ if (nativeScore + scalarHalfUnit + DistributionTolerance < minimum ||
+ nativeScore - scalarHalfUnit - DistributionTolerance > maximum)
+ {
+ throw new DecisionProtocolException("The score does not agree with its probability distribution.");
+ }
+ }
+
+ Score = score;
+ ExpectedScore = expectedScore;
+ Probabilities = Array.AsReadOnly(copy);
+ }
+
+ /// Gets the provider-reported score, retaining any declared rounding, or when it was not reported.
+ public double? Score { get; }
+
+ /// Gets the score derived from the observed distribution without repairing it, which may fall outside the ordinal level bounds after rounding.
+ public double ExpectedScore { get; }
+
+ /// Gets the complete ordered level distribution.
+ public IReadOnlyList Probabilities { get; }
+}
+
+/// Represents a protocol-invalid provider result.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class DecisionProtocolException : Exception
+{
+ /// Initializes a new instance of the class.
+ public DecisionProtocolException()
+ {
+ }
+
+ /// Initializes a new instance of the class with a message.
+ public DecisionProtocolException(string message)
+ : base(message)
+ {
+ }
+
+ /// Initializes a new instance of the class with a message and inner exception.
+ public DecisionProtocolException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
+
+internal static class DecisionSnapshots
+{
+ internal static IReadOnlyDictionary? Properties(
+ IReadOnlyDictionary? entries)
+ {
+ if (entries is null)
+ {
+ return null;
+ }
+
+ Dictionary copy = new(StringComparer.Ordinal);
+ foreach (KeyValuePair property in entries)
+ {
+ if (copy.ContainsKey(property.Key))
+ {
+ Throw.ArgumentException(nameof(entries), "Extension property names must be unique.");
+ }
+
+ copy.Add(property.Key, property.Value);
+ }
+
+ return new ReadOnlyDictionary(copy);
+ }
+}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionBinding.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionBinding.cs
new file mode 100644
index 00000000000..8c40cd2244d
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionBinding.cs
@@ -0,0 +1,191 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable SA1402 // File may only contain a single type — binding proof types are co-located as one API surface.
+#pragma warning disable SA1649 // File name should match first type name — binding proof types are co-located as one API surface.
+#pragma warning disable SA1204 // Static extension type is intentionally grouped with the binding types.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Text.Json.Serialization.Metadata;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+/// Provides an explicit mapping from a choice candidate ID to an enum value.
+/// The application-owned enum type.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class DecisionEnumDefinition
+ where TEnum : struct, Enum
+{
+ private readonly ReadOnlyDictionary _values;
+
+ /// Initializes a new instance of the class.
+ public DecisionEnumDefinition(IEnumerable> values)
+ {
+ _ = Throw.IfNull(values);
+
+ DecisionEnumValue[] copy = values.ToArray();
+ if (copy.Length == 0)
+ {
+ Throw.ArgumentException(nameof(values), "An enum definition requires at least one value.");
+ }
+
+ Dictionary byId = new(StringComparer.Ordinal);
+ HashSet enumValues = new();
+ DecisionCandidate[] candidates = new DecisionCandidate[copy.Length];
+
+ for (int i = 0; i < copy.Length; i++)
+ {
+ DecisionEnumValue value = copy[i];
+ if (!enumValues.Add(value.Value))
+ {
+ Throw.ArgumentException(nameof(values), "Enum aliases or duplicate enum values are not allowed.");
+ }
+
+ if (byId.ContainsKey(value.Id))
+ {
+ Throw.ArgumentException(
+ nameof(values),
+ "Enum candidate IDs must be unique using ordinal comparison.");
+ }
+
+ byId.Add(value.Id, value.Value);
+ candidates[i] = new DecisionCandidate(value.Id, value.Description);
+ }
+
+ _values = new ReadOnlyDictionary(byId);
+ Candidates = Array.AsReadOnly(candidates);
+ }
+
+ /// Gets the ordered candidate snapshot for a choice question.
+ public IReadOnlyList Candidates { get; }
+
+ /// Creates a choice question using this exact enum mapping.
+ /// The choice question with the exact mapped candidate IDs.
+ public ChoiceDecisionQuestion CreateQuestion(string id, string? instructions) =>
+ new(id, instructions, Candidates);
+
+ /// Maps an exact selected candidate ID to its enum value.
+ /// The mapped enum value.
+ public TEnum Parse(string candidateId)
+ {
+ _ = Throw.IfNullOrWhitespace(candidateId);
+ if (_values.TryGetValue(candidateId, out TEnum value))
+ {
+ return value;
+ }
+
+ throw new DecisionProtocolException($"Unknown candidate ID '{candidateId}'.");
+ }
+}
+
+/// Describes one enum mapping entry.
+/// The application-owned enum type.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class DecisionEnumValue
+ where TEnum : struct, Enum
+{
+ /// Initializes a new instance of the class.
+ public DecisionEnumValue(TEnum value, string id, string description)
+ {
+ Value = value;
+ Id = Throw.IfNullOrWhitespace(id);
+ Description = Throw.IfNullOrWhitespace(description);
+ }
+
+ /// Gets the application enum value.
+ public TEnum Value { get; }
+
+ /// Gets the exact candidate ID.
+ public string Id { get; }
+
+ /// Gets the candidate description.
+ public string Description { get; }
+}
+
+/// Provides typed access to a complete decision response.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class DecisionAnswerSet
+{
+ internal DecisionAnswerSet(DecisionResponse response)
+ {
+ Response = response;
+ }
+
+ /// Gets the complete unmodified response, including probability evidence.
+ public DecisionResponse Response { get; }
+
+ /// Gets a binary answer by exact question ID.
+ /// The complete binary answer.
+ public BinaryDecisionAnswer GetBinary(string questionId) =>
+ Response.GetAnswer(questionId) as BinaryDecisionAnswer ??
+ throw new DecisionProtocolException($"Question '{questionId}' is not binary.");
+
+ /// Gets a choice answer by exact question ID.
+ /// The complete choice answer.
+ public ChoiceDecisionAnswer GetChoice(string questionId) =>
+ Response.GetAnswer(questionId) as ChoiceDecisionAnswer ??
+ throw new DecisionProtocolException($"Question '{questionId}' is not a choice.");
+
+ /// Gets a choice answer and maps its exact selected ID to an enum.
+ /// The application-owned enum type.
+ /// The mapped enum value.
+ public TEnum GetChoice(string questionId, DecisionEnumDefinition definition)
+ where TEnum : struct, Enum
+ {
+ _ = Throw.IfNull(definition);
+ return definition.Parse(GetChoice(questionId).SelectedCandidateId);
+ }
+
+ /// Gets a score answer by exact question ID.
+ /// The complete score answer.
+ public ScoreDecisionAnswer GetScore(string questionId) =>
+ Response.GetAnswer(questionId) as ScoreDecisionAnswer ??
+ throw new DecisionProtocolException($"Question '{questionId}' is not a score.");
+}
+
+/// Maps one complete response to an application-owned result without inference or reflection.
+/// The application-owned result type.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class DecisionResultBinding
+{
+ private readonly Func _map;
+
+ /// Initializes a new instance of the class.
+ /// The source-generated JSON contract for the application result.
+ /// The application-owned mapping delegate.
+ public DecisionResultBinding(
+ JsonTypeInfo resultTypeInfo,
+ Func map)
+ {
+ ResultTypeInfo = Throw.IfNull(resultTypeInfo);
+ _map = Throw.IfNull(map);
+ }
+
+ /// Gets the explicit source-generated result contract.
+ public JsonTypeInfo ResultTypeInfo { get; }
+
+ internal TResult Map(DecisionResponse response) => _map(new DecisionAnswerSet(response));
+}
+
+/// Provides response binding extensions.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public static class DecisionBindingExtensions
+{
+ /// Maps a complete response locally using the supplied explicit binding.
+ /// The application-owned result type.
+ /// The application-owned result.
+ public static TResult Bind(
+ this DecisionResponse response,
+ DecisionResultBinding binding)
+ {
+ _ = Throw.IfNull(response);
+ _ = Throw.IfNull(binding);
+ return binding.Map(response);
+ }
+}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs
new file mode 100644
index 00000000000..7777cb682f7
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs
@@ -0,0 +1,546 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable SA1402 // File may only contain a single type — decision client contracts are co-located as one API surface.
+#pragma warning disable SA1649 // File name should match first type name — decision client contracts are co-located as one API surface.
+#pragma warning disable SA1204 // Static extension types are intentionally grouped with the client contracts.
+#pragma warning disable S1128 // Unused "using" should be removed — ExperimentalAttribute is supplied by the shared API contract.
+#pragma warning disable S2302 // Protocol diagnostics intentionally mention the opaque question concept.
+#pragma warning disable CA1032 // Implement standard exception constructors — client exceptions expose the client-specific constructors.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+/// Provides provider-neutral decision inference over a heterogeneous question batch.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public interface IDecisionClient : IDisposable
+{
+ /// Evaluates all questions against the same request state.
+ /// The owned decision request.
+ /// Optional per-request options.
+ /// The cancellation token to monitor.
+ /// A complete, correlated decision response or an exception.
+ Task GetResponseAsync(
+ DecisionRequest request,
+ DecisionOptions? options = null,
+ CancellationToken cancellationToken = default);
+
+ /// Retrieves a provider service for the exact requested type and key.
+ /// The requested provider service, or when unavailable.
+ object? GetService(Type serviceType, object? serviceKey = null);
+}
+
+/// Provides per-request decision options.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public class DecisionOptions
+{
+ /// Initializes a new instance of the class.
+ public DecisionOptions()
+ {
+ }
+
+ /// Initializes a new instance of the class by copying another instance.
+ protected DecisionOptions(DecisionOptions? other)
+ {
+ if (other is null)
+ {
+ return;
+ }
+
+ ModelId = other.ModelId;
+ AdditionalProperties = other.AdditionalProperties?.Clone();
+ RawRepresentationFactory = other.RawRepresentationFactory;
+ }
+
+ /// Gets or sets the requested model ID.
+ public string? ModelId { get; set; }
+
+ /// Gets or sets provider-specific extension properties.
+ public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
+
+ ///
+ /// Gets or sets a callback that creates provider-specific raw options.
+ /// The callback should return a new instance for each invocation; the provider may mutate it.
+ ///
+ [JsonIgnore]
+ public Func? RawRepresentationFactory { get; set; }
+
+ /// Creates a deliberate shallow clone of these options.
+ /// A cloned options instance.
+ public virtual DecisionOptions Clone() => new(this);
+}
+
+/// Provides metadata about an .
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class DecisionClientMetadata
+{
+ /// Initializes a new instance of the class.
+ public DecisionClientMetadata(
+ string? providerName = null,
+ Uri? providerUri = null,
+ string? defaultModelId = null,
+ IReadOnlyList? supportedKinds = null)
+ {
+ ProviderName = providerName;
+ ProviderUri = providerUri;
+ DefaultModelId = defaultModelId;
+
+ if (supportedKinds is not null)
+ {
+ DecisionKind[] copy = supportedKinds.Distinct().ToArray();
+ foreach (DecisionKind kind in copy)
+ {
+ if (!Enum.IsDefined(typeof(DecisionKind), kind))
+ {
+ Throw.ArgumentException(nameof(supportedKinds), "Supported kinds must be defined decision kinds.");
+ }
+ }
+
+ SupportedKinds = Array.AsReadOnly(copy);
+ }
+ }
+
+ /// Gets the provider name, when known.
+ public string? ProviderName { get; }
+
+ /// Gets the provider URI, when known.
+ public Uri? ProviderUri { get; }
+
+ /// Gets the default model ID, when known.
+ public string? DefaultModelId { get; }
+
+ /// Gets the owned supported-kind snapshot, or when unknown.
+ public IReadOnlyList? SupportedKinds { get; }
+}
+
+/// Classifies a provider failure without prescribing retry behavior.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class DecisionClientException : Exception
+{
+ /// Initializes a new instance of the class.
+ public DecisionClientException()
+ : this(string.Empty, isTransient: false)
+ {
+ }
+
+ /// Initializes a new instance of the class with a message.
+ public DecisionClientException(string message)
+ : this(message, isTransient: false)
+ {
+ }
+
+ /// Initializes a new instance of the class with provider classification.
+ public DecisionClientException(string message, bool isTransient, Exception? innerException = null)
+ : base(message, innerException)
+ {
+ IsTransient = isTransient;
+ }
+
+ /// Initializes a new instance of the class with a message and inner exception.
+ public DecisionClientException(string message, Exception innerException)
+ : this(message, isTransient: false, innerException)
+ {
+ }
+
+ /// Gets a value indicating whether the provider classified the failure as transient.
+ public bool IsTransient { get; }
+}
+
+/// Provides extensions for decision clients.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public static class DecisionClientExtensions
+{
+ /// Gets a typed provider service, or when it is unavailable.
+ /// The provider service type.
+ /// The requested provider service, or when unavailable.
+ public static TService? GetService(this IDecisionClient client, object? serviceKey = null)
+ {
+ _ = Throw.IfNull(client);
+ return client.GetService(typeof(TService), serviceKey) is TService service ? service : default;
+ }
+
+ /// Evaluates typed state using an explicit source-generated JSON contract.
+ /// The application state type.
+ /// A complete, correlated decision response.
+ public static async Task GetResponseAsync(
+ this IDecisionClient client,
+ TState state,
+ JsonTypeInfo stateTypeInfo,
+ IEnumerable questions,
+ DecisionOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ _ = Throw.IfNull(client);
+ _ = Throw.IfNull(stateTypeInfo);
+
+ DecisionRequest request = DecisionRequest.Create(state, stateTypeInfo, questions);
+ DecisionResponse response = await client.GetResponseAsync(request, options, cancellationToken).ConfigureAwait(false);
+ response.ValidateAgainst(request);
+ return response;
+ }
+
+ /// Evaluates a JSON state using a reusable typed decision definition.
+ /// The application-owned result type.
+ /// The decision client.
+ /// The explicit JSON state evaluated by every declared question.
+ /// The immutable typed decision definition.
+ /// Optional per-request options.
+ /// The cancellation token to monitor.
+ /// The typed result and complete neutral evidence.
+ public static async Task> GetResponseAsync(
+ this IDecisionClient client,
+ JsonElement state,
+ DecisionDefinition definition,
+ DecisionOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ _ = Throw.IfNull(client);
+ _ = Throw.IfNull(definition);
+
+ DecisionRequest request = new(state, definition.Questions);
+ DecisionResponse response = await client.GetResponseAsync(
+ request,
+ options,
+ cancellationToken).ConfigureAwait(false);
+ return definition.Bind(response, request);
+ }
+
+ /// Evaluates typed application state using a reusable typed decision definition.
+ /// The application state type.
+ /// The application-owned result type.
+ /// The decision client.
+ /// The application-owned state.
+ /// The immutable typed decision definition.
+ /// Optional per-request options.
+ /// The cancellation token to monitor.
+ /// The typed result and complete neutral evidence.
+ public static Task> GetResponseAsync(
+ this IDecisionClient client,
+ TState state,
+ DecisionDefinition definition,
+ DecisionOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ _ = Throw.IfNull(definition);
+ return GetResponseAsync(
+ client,
+ state,
+ definition,
+ definition.SerializerOptions,
+ options,
+ cancellationToken);
+ }
+
+ /// Evaluates typed application state using explicit source-generated state metadata.
+ /// The application state type.
+ /// The application-owned result type.
+ /// The decision client.
+ /// The application-owned state.
+ /// The JSON contract for .
+ /// The immutable typed decision definition.
+ /// Optional per-request options.
+ /// The cancellation token to monitor.
+ /// The typed result and complete neutral evidence.
+ public static Task> GetResponseAsync(
+ this IDecisionClient client,
+ TState state,
+ JsonTypeInfo stateTypeInfo,
+ DecisionDefinition definition,
+ DecisionOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ _ = Throw.IfNull(stateTypeInfo);
+ return GetResponseAsync(
+ client,
+ state,
+ definition,
+ stateTypeInfo,
+ options,
+ cancellationToken);
+ }
+
+ private static async Task> GetResponseAsync(
+ IDecisionClient client,
+ TState state,
+ DecisionDefinition definition,
+ JsonSerializerOptions serializerOptions,
+ DecisionOptions? options,
+ CancellationToken cancellationToken)
+ {
+ _ = Throw.IfNull(client);
+ _ = Throw.IfNull(definition);
+
+ JsonTypeInfo stateTypeInfo = serializerOptions.GetTypeInfo(typeof(TState));
+ JsonElement stateJson = JsonSerializer.SerializeToElement(state, stateTypeInfo);
+ return await GetResponseAsync(client, stateJson, definition, options, cancellationToken).ConfigureAwait(false);
+ }
+
+ private static async Task> GetResponseAsync(
+ IDecisionClient client,
+ TState state,
+ DecisionDefinition definition,
+ JsonTypeInfo stateTypeInfo,
+ DecisionOptions? options,
+ CancellationToken cancellationToken)
+ {
+ _ = Throw.IfNull(client);
+ _ = Throw.IfNull(definition);
+
+ JsonElement stateJson = JsonSerializer.SerializeToElement(state, stateTypeInfo);
+ return await GetResponseAsync(client, stateJson, definition, options, cancellationToken).ConfigureAwait(false);
+ }
+}
+
+/// Identifies provider/model provenance without inventing missing identity.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class DecisionProvenance
+{
+ /// Initializes a new instance of the class.
+ public DecisionProvenance(
+ string? providerName = null,
+ Uri? providerUri = null,
+ string? modelId = null,
+ string? responseId = null,
+ DecisionPrecision? precision = null)
+ {
+ ProviderName = providerName;
+ ProviderUri = providerUri;
+ ModelId = modelId;
+ ResponseId = responseId;
+ Precision = precision;
+ }
+
+ /// Gets the provider name, when reported.
+ public string? ProviderName { get; }
+
+ /// Gets the provider URI, when reported.
+ public Uri? ProviderUri { get; }
+
+ /// Gets the model ID, when reported.
+ public string? ModelId { get; }
+
+ /// Gets the provider response ID, when reported.
+ public string? ResponseId { get; }
+
+ /// Gets the reported probability precision, when available.
+ public DecisionPrecision? Precision { get; }
+}
+
+/// Represents a complete response correlated to one request.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class DecisionResponse
+{
+ /// Initializes a new instance of the class and validates it.
+ [JsonConstructor]
+ public DecisionResponse(
+ DecisionRequest request,
+ IReadOnlyList answers,
+ DecisionProvenance? provenance = null,
+ UsageDetails? usage = null,
+ object? rawRepresentation = null,
+ IReadOnlyDictionary? additionalProperties = null)
+ {
+ Request = Throw.IfNull(request);
+ _ = Throw.IfNull(answers);
+
+ DecisionAnswer[] copy = answers.ToArray();
+ if (copy.Length != request.Questions.Count)
+ {
+ throw new DecisionProtocolException("A response must contain one answer for every question.");
+ }
+
+ for (int i = 0; i < copy.Length; i++)
+ {
+ ValidateAnswer(request.Questions[i], copy[i]);
+ }
+
+ Answers = Array.AsReadOnly(copy);
+ Provenance = provenance;
+ Usage = usage;
+ RawRepresentation = rawRepresentation;
+ AdditionalProperties = DecisionSnapshots.Properties(additionalProperties);
+ }
+
+ /// Gets the request snapshot evaluated by the provider.
+ public DecisionRequest Request { get; }
+
+ /// Gets complete answers in the exact request order.
+ public IReadOnlyList Answers { get; }
+
+ /// Gets provider/model provenance, when reported.
+ public DecisionProvenance? Provenance { get; }
+
+ /// Gets usage details reported by the provider.
+ public UsageDetails? Usage { get; }
+
+ ///
+ /// Gets the provider-owned raw response, if available.
+ /// This value is not cloned or used for semantic data and must be treated as diagnostic-only.
+ ///
+ [JsonIgnore]
+ public object? RawRepresentation { get; }
+
+ ///
+ /// Gets an owned read-only snapshot of provider extension data.
+ /// Values are shallow-copied references and must be independently safe to share.
+ ///
+ public IReadOnlyDictionary? AdditionalProperties { get; }
+
+ /// Gets an answer by exact ordinal question ID.
+ /// The answer associated with the exact question ID.
+ public DecisionAnswer GetAnswer(string questionId)
+ {
+ _ = Throw.IfNullOrWhitespace(questionId);
+ foreach (DecisionAnswer answer in Answers)
+ {
+ if (string.Equals(answer.QuestionId, questionId, StringComparison.Ordinal))
+ {
+ return answer;
+ }
+ }
+
+ throw new KeyNotFoundException($"No answer exists for question ID '{questionId}'.");
+ }
+
+ internal void ValidateAgainst(DecisionRequest expectedRequest)
+ {
+ _ = Throw.IfNull(expectedRequest);
+
+ if (!JsonElement.DeepEquals(Request.State, expectedRequest.State) ||
+ Request.Questions.Count != expectedRequest.Questions.Count)
+ {
+ throw new DecisionProtocolException("The response does not correspond to the request sent by the client.");
+ }
+
+ for (int i = 0; i < Request.Questions.Count; i++)
+ {
+ if (!QuestionsAreEquivalent(Request.Questions[i], expectedRequest.Questions[i]))
+ {
+ throw new DecisionProtocolException("The response does not correspond to the request sent by the client.");
+ }
+ }
+ }
+
+ private static bool QuestionsAreEquivalent(DecisionQuestion actual, DecisionQuestion expected)
+ {
+ if (actual.Kind != expected.Kind ||
+ !string.Equals(actual.Id, expected.Id, StringComparison.Ordinal) ||
+ !string.Equals(actual.Instructions, expected.Instructions, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return (actual, expected) switch
+ {
+ (BinaryDecisionQuestion actualBinary, BinaryDecisionQuestion expectedBinary) =>
+ string.Equals(actualBinary.TrueDescription, expectedBinary.TrueDescription, StringComparison.Ordinal) &&
+ string.Equals(actualBinary.FalseDescription, expectedBinary.FalseDescription, StringComparison.Ordinal),
+
+ (ChoiceDecisionQuestion actualChoice, ChoiceDecisionQuestion expectedChoice) =>
+ CandidatesAreEquivalent(actualChoice.Candidates, expectedChoice.Candidates),
+
+ (ScoreDecisionQuestion actualScore, ScoreDecisionQuestion expectedScore) =>
+ LevelsAreEquivalent(actualScore.Levels, expectedScore.Levels),
+
+ _ => false,
+ };
+ }
+
+ private static bool CandidatesAreEquivalent(
+ IReadOnlyList actual,
+ IReadOnlyList expected)
+ {
+ if (actual.Count != expected.Count)
+ {
+ return false;
+ }
+
+ for (int i = 0; i < actual.Count; i++)
+ {
+ if (!string.Equals(actual[i].Id, expected[i].Id, StringComparison.Ordinal) ||
+ !string.Equals(actual[i].Description, expected[i].Description, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool LevelsAreEquivalent(
+ IReadOnlyList actual,
+ IReadOnlyList expected)
+ {
+ if (actual.Count != expected.Count)
+ {
+ return false;
+ }
+
+ for (int i = 0; i < actual.Count; i++)
+ {
+ if (!string.Equals(actual[i].Id, expected[i].Id, StringComparison.Ordinal) ||
+ !string.Equals(actual[i].Description, expected[i].Description, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static void ValidateAnswer(DecisionQuestion question, DecisionAnswer answer)
+ {
+ if (answer is null ||
+ !string.Equals(question.Id, answer.QuestionId, StringComparison.Ordinal) ||
+ question.Kind != answer.Kind)
+ {
+ throw new DecisionProtocolException("An answer does not exactly match its question.");
+ }
+
+ switch (question)
+ {
+ case BinaryDecisionQuestion when answer is BinaryDecisionAnswer:
+ return;
+
+ case ChoiceDecisionQuestion choice when answer is ChoiceDecisionAnswer choiceAnswer:
+ if (choiceAnswer.Probabilities.Count != choice.Candidates.Count ||
+ !choice.Candidates.Select(static candidate => candidate.Id)
+ .SequenceEqual(
+ choiceAnswer.Probabilities.Select(static probability => probability.Id),
+ StringComparer.Ordinal))
+ {
+ throw new DecisionProtocolException(
+ "Choice probabilities must contain exactly the requested candidate IDs in order.");
+ }
+
+ return;
+
+ case ScoreDecisionQuestion score when answer is ScoreDecisionAnswer scoreAnswer:
+ if (scoreAnswer.Probabilities.Count != score.Levels.Count ||
+ !score.Levels.Select(static level => level.Id)
+ .SequenceEqual(
+ scoreAnswer.Probabilities.Select(static probability => probability.Id),
+ StringComparer.Ordinal))
+ {
+ throw new DecisionProtocolException(
+ "Score probabilities must contain exactly the requested level IDs in order.");
+ }
+
+ return;
+
+ default:
+ throw new DecisionProtocolException("An answer kind does not match its question.");
+ }
+ }
+}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs
new file mode 100644
index 00000000000..926f0fa8812
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs
@@ -0,0 +1,645 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable SA1402 // Definition and nested builder are one immutable API surface.
+#pragma warning disable SA1649 // File name identifies the primary definition type.
+#pragma warning disable CA1000 // The generic type is the natural home for the typed factory methods.
+#pragma warning disable CA1032 // Decision materialization exposes protocol-specific failures.
+#pragma warning disable S2302 // Protocol exception messages intentionally describe the response and configured options.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+///
+/// Declares the decision questions and application-owned result properties for a typed decision flow.
+///
+/// The application-owned result type.
+[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class DecisionDefinition
+{
+ private readonly ReadOnlyCollection> _questions;
+
+ private DecisionDefinition(
+ JsonTypeInfo resultTypeInfo,
+ JsonSerializerOptions serializerOptions,
+ IReadOnlyList> questions)
+ {
+ ResultTypeInfo = resultTypeInfo;
+ SerializerOptions = serializerOptions;
+ _questions = new ReadOnlyCollection>(questions.ToArray());
+ Questions = new ReadOnlyCollection(_questions.Select(static question => question.Question).ToArray());
+ }
+
+ /// Gets the explicit JSON contract used to materialize the application result.
+ public JsonTypeInfo ResultTypeInfo { get; }
+
+ /// Gets the serializer options associated with this definition.
+ [JsonIgnore]
+ public JsonSerializerOptions SerializerOptions { get; }
+
+ /// Gets the immutable ordered questions declared by this definition.
+ public IReadOnlyList Questions { get; }
+
+ ///
+ /// Creates a typed definition using the configured default JSON contract for .
+ ///
+ /// The explicit question declaration callback.
+ ///
+ /// Optional JSON options used for property and enum metadata. A missing resolver is supplied from the MEAI defaults;
+ /// explicit caller resolvers and converters are preserved without mutating the caller's options.
+ ///
+ /// An immutable decision definition.
+ public static DecisionDefinition Create(
+ Action configure,
+ JsonSerializerOptions? serializerOptions = null)
+ {
+ _ = Throw.IfNull(configure);
+ serializerOptions = new JsonSerializerOptions(serializerOptions ?? AIJsonUtilities.DefaultOptions);
+ serializerOptions.TypeInfoResolver ??= AIJsonUtilities.DefaultOptions.TypeInfoResolver;
+ serializerOptions.MakeReadOnly();
+
+ JsonTypeInfo resultTypeInfo = GetResultTypeInfo(serializerOptions);
+ return Create(resultTypeInfo, configure);
+ }
+
+ /// Creates a typed definition using an application-supplied JSON contract.
+ /// The JSON contract for .
+ /// The explicit question declaration callback.
+ /// An immutable decision definition.
+ public static DecisionDefinition Create(
+ JsonTypeInfo resultTypeInfo,
+ Action configure)
+ {
+ _ = Throw.IfNull(resultTypeInfo);
+ _ = Throw.IfNull(configure);
+
+ Builder builder = new(resultTypeInfo);
+ configure(builder);
+ return builder.Build();
+ }
+
+ internal DecisionResponse Bind(DecisionResponse response, DecisionRequest expectedRequest)
+ {
+ _ = Throw.IfNull(response);
+ response.ValidateAgainst(expectedRequest);
+
+ using MemoryStream stream = new();
+ using (Utf8JsonWriter writer = new(stream))
+ {
+ writer.WriteStartObject();
+
+ foreach (DecisionDefinitionQuestion question in _questions)
+ {
+ DecisionAnswer answer = response.GetAnswer(question.Question.Id);
+ switch (question.Kind)
+ {
+ case DecisionKind.Binary:
+ writer.WriteNumber(question.PropertyName, ((BinaryDecisionAnswer)answer).TrueProbability);
+ break;
+
+ case DecisionKind.Choice:
+ ChoiceDecisionAnswer choice = (ChoiceDecisionAnswer)answer;
+ writer.WriteString(question.PropertyName, choice.SelectedCandidateId);
+ break;
+
+ case DecisionKind.Score:
+ ScoreDecisionAnswer score = (ScoreDecisionAnswer)answer;
+ if (question.UsesExpectedScore)
+ {
+ writer.WriteNumber(question.PropertyName, score.ExpectedScore);
+ }
+ else if (score.Score is double reportedScore)
+ {
+ writer.WriteNumber(question.PropertyName, reportedScore);
+ }
+ else
+ {
+ throw new DecisionProtocolException(
+ $"Question '{question.Question.Id}' does not contain a provider-reported score.");
+ }
+ break;
+
+ default:
+ throw new DecisionProtocolException($"Unsupported decision kind '{question.Kind}'.");
+ }
+ }
+
+ writer.WriteEndObject();
+ }
+
+ byte[] resultJson = stream.ToArray();
+ TResult result;
+ try
+ {
+ result = JsonSerializer.Deserialize(resultJson, ResultTypeInfo)!;
+ }
+ catch (Exception exception) when (exception is JsonException or NotSupportedException)
+ {
+ throw new DecisionProtocolException(
+ "The decision response could not be materialized as the declared result type.",
+ exception);
+ }
+
+ if (result is null)
+ {
+ throw new DecisionProtocolException("The decision response produced a null result.");
+ }
+
+ foreach (DecisionDefinitionQuestion question in _questions)
+ {
+ DecisionAnswer answer = response.GetAnswer(question.Question.Id);
+ object? actualValue = question.ValueAccessor(result);
+ bool preserved = question.Kind switch
+ {
+ DecisionKind.Binary => actualValue is double actualBinary &&
+ actualBinary.Equals(((BinaryDecisionAnswer)answer).TrueProbability),
+ DecisionKind.Choice => question.ChoiceValueMatches is not null &&
+ question.ChoiceValueMatches(actualValue, ((ChoiceDecisionAnswer)answer).SelectedCandidateId),
+ DecisionKind.Score => actualValue is double actualScore &&
+ actualScore.Equals(question.UsesExpectedScore
+ ? ((ScoreDecisionAnswer)answer).ExpectedScore
+ : ((ScoreDecisionAnswer)answer).Score ?? double.NaN),
+ _ => false,
+ };
+
+ if (!preserved)
+ {
+ throw new DecisionProtocolException(
+ $"The decision response value for result property '{question.Property.Name}' was not preserved by the configured JSON contract.");
+ }
+ }
+
+ return new DecisionResponse(result, response, this);
+ }
+
+ internal IReadOnlyDictionary GetDistribution(
+ DecisionResponse response,
+ Expression> selector)
+ where TEnum : struct, Enum
+ {
+ DecisionDefinitionQuestion question = FindQuestion(selector);
+ if (question.Kind != DecisionKind.Choice ||
+ question.EnumDefinition is not DecisionEnumDefinition definition)
+ {
+ throw new DecisionProtocolException("The selected result property is not a choice enum in this definition.");
+ }
+
+ ChoiceDecisionAnswer answer = (ChoiceDecisionAnswer)response.Evidence.GetAnswer(question.Question.Id);
+ Dictionary values = new();
+ foreach (DecisionProbability probability in answer.Probabilities)
+ {
+ values.Add(definition.Parse(probability.Id), probability.Value);
+ }
+
+ return new ReadOnlyDictionary(values);
+ }
+
+ private static JsonTypeInfo GetResultTypeInfo(JsonSerializerOptions options)
+ {
+ if (options.GetTypeInfo(typeof(TResult)) is not JsonTypeInfo resultTypeInfo)
+ {
+ throw new NotSupportedException(
+ $"The configured JSON options do not contain a contract for result type '{typeof(TResult)}'.");
+ }
+
+ return resultTypeInfo;
+ }
+
+ private static PropertyInfo GetDirectProperty(Expression> selector)
+ {
+ _ = Throw.IfNull(selector);
+
+ Expression body = selector.Body;
+ if (body is UnaryExpression { NodeType: ExpressionType.Convert } conversion)
+ {
+ body = conversion.Operand;
+ }
+
+ if (body is not MemberExpression { Member: PropertyInfo property, Expression: ParameterExpression parameter } ||
+ selector.Parameters.Count != 1 ||
+ selector.Parameters[0] != parameter)
+ {
+ Throw.ArgumentException(
+ nameof(selector),
+ "The selector must access one direct instance property of the result type.");
+ return null!;
+ }
+
+ return property;
+ }
+
+ private DecisionDefinitionQuestion FindQuestion(Expression> selector)
+ {
+ PropertyInfo property = GetDirectProperty(selector);
+ return _questions.FirstOrDefault(question => question.Property == property) ??
+ throw new DecisionProtocolException($"The result property '{property.Name}' is not declared in this definition.");
+ }
+
+ /// Builds a through explicit property declarations.
+ [Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)]
+ public sealed class Builder
+ {
+ private readonly JsonTypeInfo _resultTypeInfo;
+ private readonly List> _questions = [];
+
+ private static DecisionEnumDefinition CreateEnumDefinition<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] TEnum>(
+ PropertyInfo property,
+ JsonTypeInfo resultTypeInfo,
+ JsonPropertyInfo jsonProperty)
+ where TEnum : struct, Enum
+ {
+ if (property.PropertyType.IsDefined(typeof(FlagsAttribute), inherit: false))
+ {
+ Throw.ArgumentException("selector", "Flags enums are not supported as closed decision choices.");
+ }
+
+ if (jsonProperty.CustomConverter is not null)
+ {
+ throw new NotSupportedException(
+ $"Property-specific JSON converters are not supported for decision enum property '{property.Name}'. " +
+ "Configure the enum converter at the JSON type/options level so candidate IDs and result materialization share one contract.");
+ }
+
+ JsonTypeInfo enumTypeInfo;
+ try
+ {
+ enumTypeInfo = resultTypeInfo.Options.GetTypeInfo(typeof(TEnum));
+ }
+ catch (Exception exception) when (exception is NotSupportedException or InvalidOperationException)
+ {
+ throw new NotSupportedException(
+ $"The configured JSON options do not contain a contract for enum type '{typeof(TEnum)}'.",
+ exception);
+ }
+
+#if NET5_0_OR_GREATER
+ TEnum[] enumValues = Enum.GetValues();
+#else
+ TEnum[] enumValues = Enum.GetValues(typeof(TEnum)).Cast().ToArray();
+#endif
+ List> values = new(enumValues.Length);
+ foreach (TEnum value in enumValues)
+ {
+ JsonElement serialized = JsonSerializer.SerializeToElement(value, enumTypeInfo);
+ if (serialized.ValueKind != JsonValueKind.String || serialized.GetString() is not string id)
+ {
+ throw new NotSupportedException(
+ $"Enum type '{typeof(TEnum)}' must use string JSON serialization for decision choices.");
+ }
+
+ string enumName = Enum.GetName(typeof(TEnum), value)!;
+ string description = typeof(TEnum).GetField(enumName)?.GetCustomAttribute()?.Description ??
+ enumName;
+
+ values.Add(new DecisionEnumValue(value, id, description));
+ }
+
+ return new DecisionEnumDefinition(values);
+ }
+
+ private static JsonPropertyInfo GetJsonProperty(JsonTypeInfo resultTypeInfo, PropertyInfo property)
+ {
+ JsonPropertyInfo? jsonProperty = resultTypeInfo.Properties.FirstOrDefault(
+ candidate => candidate.AttributeProvider is PropertyInfo propertyInfo && propertyInfo == property);
+
+ if (jsonProperty is null)
+ {
+ string expectedName = property.GetCustomAttribute()?.Name ??
+ resultTypeInfo.Options.PropertyNamingPolicy?.ConvertName(property.Name) ??
+ property.Name;
+ jsonProperty = resultTypeInfo.Properties.FirstOrDefault(
+ candidate => string.Equals(candidate.Name, expectedName, StringComparison.Ordinal));
+ }
+
+ if (jsonProperty is null)
+ {
+ Throw.ArgumentException(
+ nameof(property),
+ $"The selected property '{property.Name}' is not present in the configured JSON contract.");
+ }
+
+ return jsonProperty;
+ }
+
+ private static string GetInstructions(JsonPropertyInfo jsonProperty, PropertyInfo property, string? explicitInstructions)
+ {
+ if (explicitInstructions is not null)
+ {
+ return Throw.IfNullOrWhitespace(explicitInstructions);
+ }
+
+ string? description = (jsonProperty.AttributeProvider as PropertyInfo)?.GetCustomAttribute(inherit: true)?.Description;
+ if (description is not null)
+ {
+ return description;
+ }
+
+#if NET9_0_OR_GREATER
+ description = jsonProperty.AssociatedParameter?.AttributeProvider?.GetCustomAttributes(typeof(DescriptionAttribute), inherit: true)
+ .OfType()
+ .FirstOrDefault()
+ ?.Description;
+#else
+ description = GetConstructorDescription(property);
+#endif
+ if (description is not null)
+ {
+ return description;
+ }
+
+ return property.PropertyType.GetCustomAttribute(inherit: true)?.Description ??
+ property.Name;
+ }
+
+ private static void EnsurePropertyCanBeDeclared(PropertyInfo property, Type expectedType)
+ {
+ if (property.GetMethod is null ||
+ property.GetMethod.IsStatic ||
+ property.PropertyType != expectedType)
+ {
+ Throw.ArgumentException("selector", $"The selected property must be a readable instance property of type '{expectedType}'.");
+ }
+ }
+
+#if !NET9_0_OR_GREATER
+ [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "The reflection convenience path checks preserved constructor binding metadata; source-generated metadata is used on newer TFMs.")]
+#endif
+ private static void EnsurePropertyCanBeMaterialized(
+ PropertyInfo property,
+ JsonPropertyInfo jsonProperty)
+ {
+ if (jsonProperty.Set is not null)
+ {
+ return;
+ }
+
+#if NET9_0_OR_GREATER
+ if (jsonProperty.AssociatedParameter is not null)
+ {
+ return;
+ }
+#else
+ ConstructorInfo[] constructors = property.DeclaringType?.GetConstructors() ?? [];
+ if (Array.Exists(
+ constructors,
+ candidate => Array.Exists(
+ candidate.GetParameters(),
+ parameter => string.Equals(parameter.Name, property.Name, StringComparison.OrdinalIgnoreCase))))
+ {
+ return;
+ }
+#endif
+
+ Throw.ArgumentException(
+ "selector",
+ $"The selected property '{property.Name}' is not writable or bound to a JSON constructor parameter.");
+ }
+
+#if !NET9_0_OR_GREATER
+ [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "The reflection convenience path intentionally reads preserved constructor parameter metadata; source-generated metadata is used on newer TFMs.")]
+ private static string? GetConstructorDescription(PropertyInfo property)
+ {
+ ConstructorInfo[] constructors = property.DeclaringType?.GetConstructors() ?? [];
+ ConstructorInfo? constructor = Array.Find(
+ constructors,
+ candidate => Array.Exists(candidate.GetParameters(), parameter =>
+ string.Equals(parameter.Name, property.Name, StringComparison.OrdinalIgnoreCase)));
+ return Array.Find(
+ constructor?.GetParameters() ?? [],
+ parameter => string.Equals(parameter.Name, property.Name, StringComparison.OrdinalIgnoreCase))
+ ?.GetCustomAttribute(inherit: true)
+ ?.Description;
+ }
+#endif
+
+ private static Func CreateValueAccessor(Expression> selector)
+ {
+#if NETFRAMEWORK
+ Func accessor = selector.Compile();
+#else
+ Func accessor = selector.Compile(true);
+#endif
+ return result => accessor(result);
+ }
+
+ private static Func