From 252989a1ec0255c6d041b17ef036a7dcd3d3fbf8 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Wed, 30 Sep 2026 17:27:19 -0400 Subject: [PATCH 01/11] Add experimental decision abstractions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b735e048-cd3e-4bfb-ad05-3f68ad46082e --- .../Decisions/DecisionAnswers.cs | 295 +++++++ .../Decisions/DecisionBinding.cs | 191 +++++ .../Decisions/DecisionClient.cs | 345 ++++++++ .../Decisions/DecisionFeatures.cs | 236 ++++++ .../Decisions/DecisionQuestions.cs | 281 +++++++ .../Decisions/README.md | 7 + .../Microsoft.Extensions.AI.Abstractions.json | 739 ++++++++++++++++++ .../Utilities/AIJsonUtilities.Defaults.cs | 16 + src/Shared/DiagnosticIds/DiagnosticIds.cs | 1 + .../Decisions/DecisionTests.cs | 313 ++++++++ .../TestJsonSerializerContext.cs | 17 + 11 files changed, 2441 insertions(+) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionBinding.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFeatures.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionQuestions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs 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..e02ec882246 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs @@ -0,0 +1,295 @@ +// 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, + IEnumerable>? 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) + { + double sum = 0; + HashSet ids = new(StringComparer.Ordinal); + + foreach (DecisionProbability probability in probabilities) + { + if (!ids.Add(probability.Id)) + { + throw new DecisionProtocolException("A probability distribution contains a duplicate ID."); + } + + ValidateProbability(probability.Value); + sum += probability.Value; + } + + if (Math.Abs(sum - 1) > DistributionTolerance) + { + throw new DecisionProtocolException( + "A probability distribution must sum to 1 within an absolute tolerance of 1e-6."); + } + } +} + +/// 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, + IEnumerable>? 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, + IEnumerable>? 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); + 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. +[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, + IEnumerable>? 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); + if (double.IsNaN(score) || double.IsInfinity(score) || score < 0 || score > 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 (precision == DecisionPrecision.HighPrecision && + Math.Abs(score - expectedScore) > DistributionTolerance * (copy.Length - 1)) + { + 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. + public double Score { get; } + + /// Gets the score derived from the observed distribution without repairing it. + 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( + IEnumerable>? 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..b2eabb76630 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs @@ -0,0 +1,345 @@ +// 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 Task GetResponseAsync( + this IDecisionClient client, + TState state, + JsonTypeInfo stateTypeInfo, + IEnumerable questions, + DecisionOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(client); + _ = Throw.IfNull(stateTypeInfo); + return client.GetResponseAsync(DecisionRequest.Create(state, stateTypeInfo, questions), options, cancellationToken); + } +} + +/// 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, + IEnumerable>? 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}'."); + } + + 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/DecisionFeatures.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFeatures.cs new file mode 100644 index 00000000000..978ebcc0fbb --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFeatures.cs @@ -0,0 +1,236 @@ +// 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 — feature projection types are co-located as one API surface. +#pragma warning disable SA1649 // File name should match first type name — feature projection types are co-located as one API surface. +#pragma warning disable SA1204 // Static projection type is intentionally grouped with the feature model. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Identifies the semantic value projected by a feature coordinate. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public enum DecisionFeatureValueKind +{ + /// The probability that a binary proposition is true. + BinaryTrueProbability, + + /// The probability that a binary proposition is false. + BinaryFalseProbability, + + /// The probability of one choice candidate. + ChoiceProbability, + + /// The probability of one score level. + ScoreProbability, + + /// The provider-reported ordinal score. + Score, +} + +/// Names one stable semantic coordinate in a feature schema. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class DecisionFeatureCoordinate +{ + /// Initializes a new instance of the class. + public DecisionFeatureCoordinate( + string name, + string questionId, + DecisionFeatureValueKind valueKind, + string? memberId = null) + { + Name = Throw.IfNullOrWhitespace(name); + QuestionId = Throw.IfNullOrWhitespace(questionId); + if (!Enum.IsDefined(typeof(DecisionFeatureValueKind), valueKind)) + { + Throw.ArgumentOutOfRangeException(nameof(valueKind)); + } + + ValueKind = valueKind; + + bool requiresMemberId = valueKind is DecisionFeatureValueKind.ChoiceProbability or DecisionFeatureValueKind.ScoreProbability; + if (requiresMemberId != (memberId is not null)) + { + Throw.ArgumentException( + nameof(memberId), + requiresMemberId ? "This coordinate kind requires a member ID." : "This coordinate kind does not accept a member ID."); + } + + if (memberId is not null) + { + MemberId = Throw.IfNullOrWhitespace(memberId); + } + } + + /// Gets the stable coordinate name. + public string Name { get; } + + /// Gets the exact question ID supplying this coordinate. + public string QuestionId { get; } + + /// Gets the coordinate semantic kind. + public DecisionFeatureValueKind ValueKind { get; } + + /// Gets the exact candidate or level ID, when applicable. + public string? MemberId { get; } +} + +/// Identifies a named feature schema and its version. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class DecisionFeatureSchema +{ + /// Initializes a new instance of the class. + public DecisionFeatureSchema(string id, int version, IEnumerable coordinates) + { + Id = Throw.IfNullOrWhitespace(id); + if (version < 1) + { + Throw.ArgumentOutOfRangeException(nameof(version)); + } + + _ = Throw.IfNull(coordinates); + DecisionFeatureCoordinate[] copy = coordinates.ToArray(); + if (copy.Length == 0 || Array.Exists(copy, static coordinate => coordinate is null)) + { + Throw.ArgumentException(nameof(coordinates), "Coordinates must be nonempty."); + } + + HashSet names = new(StringComparer.Ordinal); + foreach (DecisionFeatureCoordinate coordinate in copy) + { + if (!names.Add(coordinate.Name)) + { + Throw.ArgumentException(nameof(coordinates), "Feature names must be unique using ordinal comparison."); + } + } + + Version = version; + Coordinates = Array.AsReadOnly(copy); + } + + /// Gets the stable schema identity. + public string Id { get; } + + /// Gets the schema version. + public int Version { get; } + + /// Gets the ordered coordinate snapshot. + public IReadOnlyList Coordinates { get; } +} + +/// Represents one named native-double feature value. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class DecisionFeatureValue +{ + /// Initializes a new instance of the class. + public DecisionFeatureValue(string name, double value) + { + Name = Throw.IfNullOrWhitespace(name); + if (double.IsNaN(value) || double.IsInfinity(value)) + { + Throw.ArgumentOutOfRangeException(nameof(value)); + } + + Value = value; + } + + /// Gets the stable coordinate name. + public string Name { get; } + + /// Gets the native double value. + public double Value { get; } +} + +/// Represents a named feature projection with schema and provenance. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class DecisionFeatureVector +{ + internal DecisionFeatureVector( + DecisionFeatureSchema schema, + IEnumerable values, + DecisionProvenance? provenance) + { + Schema = schema; + Values = Array.AsReadOnly(values.ToArray()); + Provenance = provenance; + } + + /// Gets the explicit feature schema. + public DecisionFeatureSchema Schema { get; } + + /// Gets named values in schema order. + public IReadOnlyList Values { get; } + + /// Gets provider/model provenance, when reported by the response. + public DecisionProvenance? Provenance { get; } +} + +/// Projects complete decision observations into named native-double coordinates. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public static class DecisionFeatureProjection +{ + /// Projects a response without an inference call or probability repair. + /// The named feature vector projected from the complete response. + public static DecisionFeatureVector Project( + DecisionResponse response, + DecisionFeatureSchema schema) + { + _ = Throw.IfNull(response); + _ = Throw.IfNull(schema); + + List values = new(schema.Coordinates.Count); + foreach (DecisionFeatureCoordinate coordinate in schema.Coordinates) + { + DecisionAnswer answer = response.GetAnswer(coordinate.QuestionId); + double value = coordinate.ValueKind switch + { + DecisionFeatureValueKind.BinaryTrueProbability => + GetBinary(answer, coordinate).TrueProbability, + DecisionFeatureValueKind.BinaryFalseProbability => + 1 - GetBinary(answer, coordinate).TrueProbability, + DecisionFeatureValueKind.ChoiceProbability => + GetProbability(GetChoice(answer, coordinate).Probabilities, coordinate.MemberId!), + DecisionFeatureValueKind.ScoreProbability => + GetProbability(GetScore(answer, coordinate).Probabilities, coordinate.MemberId!), + DecisionFeatureValueKind.Score => + GetScore(answer, coordinate).Score, + _ => throw new ArgumentOutOfRangeException(nameof(schema)), + }; + + values.Add(new DecisionFeatureValue(coordinate.Name, value)); + } + + return new DecisionFeatureVector(schema, values, response.Provenance); + } + + private static BinaryDecisionAnswer GetBinary(DecisionAnswer answer, DecisionFeatureCoordinate coordinate) => + answer as BinaryDecisionAnswer ?? + throw new ArgumentException($"Question '{coordinate.QuestionId}' is not binary.", nameof(coordinate)); + + private static ChoiceDecisionAnswer GetChoice(DecisionAnswer answer, DecisionFeatureCoordinate coordinate) => + answer as ChoiceDecisionAnswer ?? + throw new ArgumentException($"Question '{coordinate.QuestionId}' is not a choice.", nameof(coordinate)); + + private static ScoreDecisionAnswer GetScore(DecisionAnswer answer, DecisionFeatureCoordinate coordinate) => + answer as ScoreDecisionAnswer ?? + throw new ArgumentException($"Question '{coordinate.QuestionId}' is not a score.", nameof(coordinate)); + + private static double GetProbability(IReadOnlyList probabilities, string memberId) + { + foreach (DecisionProbability probability in probabilities) + { + if (string.Equals(probability.Id, memberId, StringComparison.Ordinal)) + { + return probability.Value; + } + } + + throw new DecisionProtocolException($"Unknown feature member ID '{memberId}'."); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionQuestions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionQuestions.cs new file mode 100644 index 00000000000..cc37031765c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionQuestions.cs @@ -0,0 +1,281 @@ +// 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 question types are co-located as one immutable model. +#pragma warning disable SA1649 // File name should match first type name — decision question types are co-located as one immutable model. + +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 Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Identifies the semantic kind of a decision question or answer. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public enum DecisionKind +{ + /// A two-outcome proposition. + Binary, + + /// A choice from a caller-defined unordered set. + Choice, + + /// A position on a caller-defined ordered scale. + Score, +} + +/// Describes a decision question. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(BinaryDecisionQuestion), "binary")] +[JsonDerivedType(typeof(ChoiceDecisionQuestion), "choice")] +[JsonDerivedType(typeof(ScoreDecisionQuestion), "score")] +public abstract class DecisionQuestion +{ + private protected DecisionQuestion(string id, DecisionKind kind, string? instructions) + { + Id = Throw.IfNullOrWhitespace(id); + Kind = kind; + Instructions = instructions ?? string.Empty; + } + + /// Gets the opaque, caller-owned correlation ID for this question. + public string Id { get; } + + /// Gets the semantic kind of this question. + public DecisionKind Kind { get; } + + /// Gets the explicit instructions that define the question. + public string Instructions { get; } +} + +/// Describes a binary decision question. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class BinaryDecisionQuestion : DecisionQuestion +{ + /// Initializes a new instance of the class. + /// The opaque, caller-owned question ID. + /// The instructions defining the proposition. + /// Optional criteria describing a true result. + /// Optional criteria describing a false result. + [JsonConstructor] + public BinaryDecisionQuestion( + string id, + string? instructions = null, + string? trueDescription = null, + string? falseDescription = null) + : base(id, DecisionKind.Binary, instructions) + { + if (string.IsNullOrWhiteSpace(instructions) && + (string.IsNullOrWhiteSpace(trueDescription) || string.IsNullOrWhiteSpace(falseDescription))) + { + Throw.ArgumentException( + nameof(instructions), + "Binary questions require instructions or both true and false descriptions."); + } + + if (trueDescription is not null) + { + _ = Throw.IfNullOrWhitespace(trueDescription); + } + + if (falseDescription is not null) + { + _ = Throw.IfNullOrWhitespace(falseDescription); + } + + TrueDescription = trueDescription; + FalseDescription = falseDescription; + } + + /// Gets the optional criteria describing a true result. + public string? TrueDescription { get; } + + /// Gets the optional criteria describing a false result. + public string? FalseDescription { get; } +} + +/// Describes one caller-defined choice candidate. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class DecisionCandidate +{ + /// Initializes a new instance of the class. + /// The exact opaque candidate ID to round-trip in results. + /// The human-readable candidate criteria. + [JsonConstructor] + public DecisionCandidate(string id, string description) + { + Id = Throw.IfNullOrWhitespace(id); + Description = Throw.IfNullOrWhitespace(description); + } + + /// Gets the exact opaque, caller-owned candidate ID. + public string Id { get; } + + /// Gets the human-readable candidate criteria. + public string Description { get; } +} + +/// Describes a choice decision question. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class ChoiceDecisionQuestion : DecisionQuestion +{ + /// Initializes a new instance of the class. + /// The opaque, caller-owned question ID. + /// The instructions defining the choice. + /// The ordered, caller-defined candidates. + [JsonConstructor] + public ChoiceDecisionQuestion(string id, string? instructions, IReadOnlyList candidates) + : base(id, DecisionKind.Choice, instructions) + { + _ = Throw.IfNull(candidates); + + DecisionCandidate[] copy = candidates.ToArray(); + if (copy.Length == 0 || Array.Exists(copy, static candidate => candidate is null)) + { + Throw.ArgumentException(nameof(candidates), "Candidates must be nonempty."); + } + + HashSet ids = new(StringComparer.Ordinal); + foreach (DecisionCandidate candidate in copy) + { + if (!ids.Add(candidate.Id)) + { + Throw.ArgumentException( + nameof(candidates), + "Candidate IDs must be unique using ordinal comparison."); + } + } + + Candidates = Array.AsReadOnly(copy); + } + + /// Gets the ordered, owned candidate snapshot. + public IReadOnlyList Candidates { get; } +} + +/// Describes one level in an ordered score rubric. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class DecisionScoreLevel +{ + /// Initializes a new instance of the class. + /// The exact opaque, caller-owned level ID. + /// The criteria for this level. + [JsonConstructor] + public DecisionScoreLevel(string id, string description) + { + Id = Throw.IfNullOrWhitespace(id); + Description = Throw.IfNullOrWhitespace(description); + } + + /// Gets the exact opaque level ID. + public string Id { get; } + + /// Gets the criteria for this level. + public string Description { get; } +} + +/// Describes a score decision question. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class ScoreDecisionQuestion : DecisionQuestion +{ + /// Initializes a new instance of the class. + /// The opaque, caller-owned question ID. + /// The instructions defining the score. + /// The ordered, caller-defined score levels. + [JsonConstructor] + public ScoreDecisionQuestion(string id, string? instructions, IReadOnlyList levels) + : base(id, DecisionKind.Score, instructions) + { + _ = Throw.IfNull(levels); + + DecisionScoreLevel[] copy = levels.ToArray(); + if (copy.Length < 2 || Array.Exists(copy, static level => level is null)) + { + Throw.ArgumentException(nameof(levels), "A score question requires at least two levels."); + } + + HashSet ids = new(StringComparer.Ordinal); + foreach (DecisionScoreLevel level in copy) + { + if (!ids.Add(level.Id)) + { + Throw.ArgumentException( + nameof(levels), + "Score level IDs must be unique using ordinal comparison."); + } + } + + Levels = Array.AsReadOnly(copy); + } + + /// Gets the ordered, owned score-level snapshot. + public IReadOnlyList Levels { get; } +} + +/// Represents a state and a nonempty, ordered batch of questions. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class DecisionRequest +{ + /// Initializes a new instance of the class and snapshots its state and questions. + /// The explicit JSON state evaluated by every question. + /// The ordered heterogeneous question batch. + [JsonConstructor] + public DecisionRequest(JsonElement state, IReadOnlyList questions) + { + if (state.ValueKind == JsonValueKind.Undefined) + { + Throw.ArgumentException(nameof(state), "State must be explicit JSON."); + } + + _ = Throw.IfNull(questions); + + DecisionQuestion[] copy = questions.ToArray(); + if (copy.Length == 0 || Array.Exists(copy, static question => question is null)) + { + Throw.ArgumentException(nameof(questions), "Questions must be nonempty."); + } + + HashSet ids = new(StringComparer.Ordinal); + foreach (DecisionQuestion question in copy) + { + if (!ids.Add(question.Id)) + { + Throw.ArgumentException( + nameof(questions), + "Question IDs must be unique using ordinal comparison."); + } + } + + State = state.Clone(); + Questions = Array.AsReadOnly(copy); + } + + /// Gets the owned JSON state snapshot. + public JsonElement State { get; } + + /// Gets the ordered, owned question snapshot. + public IReadOnlyList Questions { get; } + + /// Creates a request from an application-owned state using an explicit source-generated contract. + /// The application state type. + /// The application-owned state. + /// The source-generated JSON contract for . + /// The ordered heterogeneous question batch. + /// An owned decision request. + public static DecisionRequest Create( + TState state, + JsonTypeInfo stateTypeInfo, + IEnumerable questions) + { + _ = Throw.IfNull(stateTypeInfo); + _ = Throw.IfNull(questions); + return new(JsonSerializer.SerializeToElement(state, stateTypeInfo), questions.ToArray()); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md new file mode 100644 index 00000000000..6a6dd49a1c4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -0,0 +1,7 @@ +# Experimental decision abstractions + +The `Microsoft.Extensions.AI.Abstractions` decision contracts provide a small provider-neutral Layer 1 capability for heterogeneous binary, choice, and ordinal-score questions. Requests preserve caller-owned question and candidate IDs and order; responses preserve complete probability observations, score precision, provenance, usage, and provider extensions without applying thresholds, calibration, or probability repair. + +Typed application results use an explicit `JsonTypeInfo` and mapper supplied by the application. Feature projection is likewise explicit: callers define a versioned schema of named `double` coordinates over the complete observations. The contracts snapshot collections and JSON state; `DecisionOptions.Clone` deliberately snapshots provider extension data before a request is shared. + +This layer intentionally does not define tools, routing composition, MEDI processors, provider adapters, ML.NET or Arrow integrations, or automatic POCO/union inference. Those concerns require a later layer and are not implied by these abstractions. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index 8b5f337da10..bd3719c2c9e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -4939,6 +4939,745 @@ "Stage": "Stable" } ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.BinaryDecisionAnswer : Microsoft.Extensions.AI.DecisionAnswer", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.BinaryDecisionAnswer.BinaryDecisionAnswer(string questionId, double trueProbability, Microsoft.Extensions.AI.DecisionPrecision precision = Microsoft.Extensions.AI.DecisionPrecision.HighPrecision, object? rawRepresentation = null, System.Collections.Generic.IEnumerable>? additionalProperties = null);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "double Microsoft.Extensions.AI.BinaryDecisionAnswer.TrueProbability { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.BinaryDecisionQuestion : Microsoft.Extensions.AI.DecisionQuestion", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.BinaryDecisionQuestion.BinaryDecisionQuestion(string id, string? instructions = null, string? trueDescription = null, string? falseDescription = null);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "string? Microsoft.Extensions.AI.BinaryDecisionQuestion.FalseDescription { get; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.BinaryDecisionQuestion.TrueDescription { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.ChoiceDecisionAnswer : Microsoft.Extensions.AI.DecisionAnswer", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChoiceDecisionAnswer.ChoiceDecisionAnswer(string questionId, string selectedCandidateId, System.Collections.Generic.IReadOnlyList probabilities, Microsoft.Extensions.AI.DecisionPrecision precision = Microsoft.Extensions.AI.DecisionPrecision.HighPrecision, object? rawRepresentation = null, System.Collections.Generic.IEnumerable>? additionalProperties = null);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.ChoiceDecisionAnswer.Probabilities { get; }", + "Stage": "Experimental" + }, + { + "Member": "string Microsoft.Extensions.AI.ChoiceDecisionAnswer.SelectedCandidateId { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.ChoiceDecisionQuestion : Microsoft.Extensions.AI.DecisionQuestion", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChoiceDecisionQuestion.ChoiceDecisionQuestion(string id, string? instructions, System.Collections.Generic.IReadOnlyList candidates);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.ChoiceDecisionQuestion.Candidates { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "abstract class Microsoft.Extensions.AI.DecisionAnswer", + "Stage": "Experimental", + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyDictionary? Microsoft.Extensions.AI.DecisionAnswer.AdditionalProperties { get; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionKind Microsoft.Extensions.AI.DecisionAnswer.Kind { get; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionPrecision Microsoft.Extensions.AI.DecisionAnswer.Precision { get; }", + "Stage": "Experimental" + }, + { + "Member": "string Microsoft.Extensions.AI.DecisionAnswer.QuestionId { get; }", + "Stage": "Experimental" + }, + { + "Member": "object? Microsoft.Extensions.AI.DecisionAnswer.RawRepresentation { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionAnswerSet", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.BinaryDecisionAnswer Microsoft.Extensions.AI.DecisionAnswerSet.GetBinary(string questionId);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.ChoiceDecisionAnswer Microsoft.Extensions.AI.DecisionAnswerSet.GetChoice(string questionId);", + "Stage": "Experimental" + }, + { + "Member": "TEnum Microsoft.Extensions.AI.DecisionAnswerSet.GetChoice(string questionId, Microsoft.Extensions.AI.DecisionEnumDefinition definition);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.ScoreDecisionAnswer Microsoft.Extensions.AI.DecisionAnswerSet.GetScore(string questionId);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.DecisionResponse Microsoft.Extensions.AI.DecisionAnswerSet.Response { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.DecisionBindingExtensions", + "Stage": "Experimental", + "Methods": [ + { + "Member": "static TResult Microsoft.Extensions.AI.DecisionBindingExtensions.Bind(this Microsoft.Extensions.AI.DecisionResponse response, Microsoft.Extensions.AI.DecisionResultBinding binding);", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionCandidate", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionCandidate.DecisionCandidate(string id, string description);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.DecisionCandidate.Description { get; }", + "Stage": "Experimental" + }, + { + "Member": "string Microsoft.Extensions.AI.DecisionCandidate.Id { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionClientException : System.Exception", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionClientException.DecisionClientException();", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionClientException.DecisionClientException(string message);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionClientException.DecisionClientException(string message, bool isTransient, System.Exception? innerException = null);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionClientException.DecisionClientException(string message, System.Exception innerException);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "bool Microsoft.Extensions.AI.DecisionClientException.IsTransient { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.DecisionClientExtensions", + "Stage": "Experimental", + "Methods": [ + { + "Member": "static System.Threading.Tasks.Task Microsoft.Extensions.AI.DecisionClientExtensions.GetResponseAsync(this Microsoft.Extensions.AI.IDecisionClient client, TState state, System.Text.Json.Serialization.Metadata.JsonTypeInfo stateTypeInfo, System.Collections.Generic.IEnumerable questions, Microsoft.Extensions.AI.DecisionOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "static TService? Microsoft.Extensions.AI.DecisionClientExtensions.GetService(this Microsoft.Extensions.AI.IDecisionClient client, object? serviceKey = null);", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionClientMetadata", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionClientMetadata.DecisionClientMetadata(string? providerName = null, System.Uri? providerUri = null, string? defaultModelId = null, System.Collections.Generic.IReadOnlyList? supportedKinds = null);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "string? Microsoft.Extensions.AI.DecisionClientMetadata.DefaultModelId { get; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.DecisionClientMetadata.ProviderName { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Uri? Microsoft.Extensions.AI.DecisionClientMetadata.ProviderUri { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Collections.Generic.IReadOnlyList? Microsoft.Extensions.AI.DecisionClientMetadata.SupportedKinds { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionEnumDefinition where TEnum : struct, System.Enum", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionEnumDefinition.DecisionEnumDefinition(System.Collections.Generic.IEnumerable> values);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.ChoiceDecisionQuestion Microsoft.Extensions.AI.DecisionEnumDefinition.CreateQuestion(string id, string? instructions);", + "Stage": "Experimental" + }, + { + "Member": "TEnum Microsoft.Extensions.AI.DecisionEnumDefinition.Parse(string candidateId);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.DecisionEnumDefinition.Candidates { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionEnumValue where TEnum : struct, System.Enum", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionEnumValue.DecisionEnumValue(TEnum value, string id, string description);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.DecisionEnumValue.Description { get; }", + "Stage": "Experimental" + }, + { + "Member": "string Microsoft.Extensions.AI.DecisionEnumValue.Id { get; }", + "Stage": "Experimental" + }, + { + "Member": "TEnum Microsoft.Extensions.AI.DecisionEnumValue.Value { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionFeatureCoordinate", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionFeatureCoordinate.DecisionFeatureCoordinate(string name, string questionId, Microsoft.Extensions.AI.DecisionFeatureValueKind valueKind, string? memberId = null);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "string? Microsoft.Extensions.AI.DecisionFeatureCoordinate.MemberId { get; }", + "Stage": "Experimental" + }, + { + "Member": "string Microsoft.Extensions.AI.DecisionFeatureCoordinate.Name { get; }", + "Stage": "Experimental" + }, + { + "Member": "string Microsoft.Extensions.AI.DecisionFeatureCoordinate.QuestionId { get; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionFeatureValueKind Microsoft.Extensions.AI.DecisionFeatureCoordinate.ValueKind { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.DecisionFeatureProjection", + "Stage": "Experimental", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.DecisionFeatureVector Microsoft.Extensions.AI.DecisionFeatureProjection.Project(Microsoft.Extensions.AI.DecisionResponse response, Microsoft.Extensions.AI.DecisionFeatureSchema schema);", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionFeatureSchema", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionFeatureSchema.DecisionFeatureSchema(string id, int version, System.Collections.Generic.IEnumerable coordinates);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.DecisionFeatureSchema.Coordinates { get; }", + "Stage": "Experimental" + }, + { + "Member": "string Microsoft.Extensions.AI.DecisionFeatureSchema.Id { get; }", + "Stage": "Experimental" + }, + { + "Member": "int Microsoft.Extensions.AI.DecisionFeatureSchema.Version { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionFeatureValue", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionFeatureValue.DecisionFeatureValue(string name, double value);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.DecisionFeatureValue.Name { get; }", + "Stage": "Experimental" + }, + { + "Member": "double Microsoft.Extensions.AI.DecisionFeatureValue.Value { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "enum Microsoft.Extensions.AI.DecisionFeatureValueKind", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionFeatureValueKind.DecisionFeatureValueKind();", + "Stage": "Experimental" + } + ], + "Fields": [ + { + "Member": "const Microsoft.Extensions.AI.DecisionFeatureValueKind Microsoft.Extensions.AI.DecisionFeatureValueKind.BinaryFalseProbability", + "Stage": "Experimental", + "Value": "1" + }, + { + "Member": "const Microsoft.Extensions.AI.DecisionFeatureValueKind Microsoft.Extensions.AI.DecisionFeatureValueKind.BinaryTrueProbability", + "Stage": "Experimental", + "Value": "0" + }, + { + "Member": "const Microsoft.Extensions.AI.DecisionFeatureValueKind Microsoft.Extensions.AI.DecisionFeatureValueKind.ChoiceProbability", + "Stage": "Experimental", + "Value": "2" + }, + { + "Member": "const Microsoft.Extensions.AI.DecisionFeatureValueKind Microsoft.Extensions.AI.DecisionFeatureValueKind.Score", + "Stage": "Experimental", + "Value": "4" + }, + { + "Member": "const Microsoft.Extensions.AI.DecisionFeatureValueKind Microsoft.Extensions.AI.DecisionFeatureValueKind.ScoreProbability", + "Stage": "Experimental", + "Value": "3" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionFeatureVector", + "Stage": "Experimental", + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.DecisionProvenance? Microsoft.Extensions.AI.DecisionFeatureVector.Provenance { get; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionFeatureSchema Microsoft.Extensions.AI.DecisionFeatureVector.Schema { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.DecisionFeatureVector.Values { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "enum Microsoft.Extensions.AI.DecisionKind", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionKind.DecisionKind();", + "Stage": "Experimental" + } + ], + "Fields": [ + { + "Member": "const Microsoft.Extensions.AI.DecisionKind Microsoft.Extensions.AI.DecisionKind.Binary", + "Stage": "Experimental", + "Value": "0" + }, + { + "Member": "const Microsoft.Extensions.AI.DecisionKind Microsoft.Extensions.AI.DecisionKind.Choice", + "Stage": "Experimental", + "Value": "1" + }, + { + "Member": "const Microsoft.Extensions.AI.DecisionKind Microsoft.Extensions.AI.DecisionKind.Score", + "Stage": "Experimental", + "Value": "2" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.DecisionOptions", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionOptions.DecisionOptions();", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionOptions.DecisionOptions(Microsoft.Extensions.AI.DecisionOptions? other);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.DecisionOptions Microsoft.Extensions.AI.DecisionOptions.Clone();", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.DecisionOptions.AdditionalProperties { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.DecisionOptions.ModelId { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "System.Func? Microsoft.Extensions.AI.DecisionOptions.RawRepresentationFactory { get; set; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "enum Microsoft.Extensions.AI.DecisionPrecision", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionPrecision.DecisionPrecision();", + "Stage": "Experimental" + } + ], + "Fields": [ + { + "Member": "const Microsoft.Extensions.AI.DecisionPrecision Microsoft.Extensions.AI.DecisionPrecision.FourDecimalPlaces", + "Stage": "Experimental", + "Value": "1" + }, + { + "Member": "const Microsoft.Extensions.AI.DecisionPrecision Microsoft.Extensions.AI.DecisionPrecision.HighPrecision", + "Stage": "Experimental", + "Value": "0" + }, + { + "Member": "const Microsoft.Extensions.AI.DecisionPrecision Microsoft.Extensions.AI.DecisionPrecision.TwoDecimalPlaces", + "Stage": "Experimental", + "Value": "2" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionProbability", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionProbability.DecisionProbability(string id, double value);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.DecisionProbability.Id { get; }", + "Stage": "Experimental" + }, + { + "Member": "double Microsoft.Extensions.AI.DecisionProbability.Value { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionProtocolException : System.Exception", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionProtocolException.DecisionProtocolException();", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionProtocolException.DecisionProtocolException(string message);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionProtocolException.DecisionProtocolException(string message, System.Exception innerException);", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionProvenance", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionProvenance.DecisionProvenance(string? providerName = null, System.Uri? providerUri = null, string? modelId = null, string? responseId = null, Microsoft.Extensions.AI.DecisionPrecision? precision = null);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "string? Microsoft.Extensions.AI.DecisionProvenance.ModelId { get; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionPrecision? Microsoft.Extensions.AI.DecisionProvenance.Precision { get; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.DecisionProvenance.ProviderName { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Uri? Microsoft.Extensions.AI.DecisionProvenance.ProviderUri { get; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.DecisionProvenance.ResponseId { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "abstract class Microsoft.Extensions.AI.DecisionQuestion", + "Stage": "Experimental", + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.DecisionQuestion.Id { get; }", + "Stage": "Experimental" + }, + { + "Member": "string Microsoft.Extensions.AI.DecisionQuestion.Instructions { get; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionKind Microsoft.Extensions.AI.DecisionQuestion.Kind { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionRequest", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionRequest.DecisionRequest(System.Text.Json.JsonElement state, System.Collections.Generic.IReadOnlyList questions);", + "Stage": "Experimental" + }, + { + "Member": "static Microsoft.Extensions.AI.DecisionRequest Microsoft.Extensions.AI.DecisionRequest.Create(TState state, System.Text.Json.Serialization.Metadata.JsonTypeInfo stateTypeInfo, System.Collections.Generic.IEnumerable questions);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.DecisionRequest.Questions { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Text.Json.JsonElement Microsoft.Extensions.AI.DecisionRequest.State { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionResponse", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionResponse.DecisionResponse(Microsoft.Extensions.AI.DecisionRequest request, System.Collections.Generic.IReadOnlyList answers, Microsoft.Extensions.AI.DecisionProvenance? provenance = null, Microsoft.Extensions.AI.UsageDetails? usage = null, object? rawRepresentation = null, System.Collections.Generic.IEnumerable>? additionalProperties = null);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionAnswer Microsoft.Extensions.AI.DecisionResponse.GetAnswer(string questionId);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyDictionary? Microsoft.Extensions.AI.DecisionResponse.AdditionalProperties { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.DecisionResponse.Answers { get; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionProvenance? Microsoft.Extensions.AI.DecisionResponse.Provenance { get; }", + "Stage": "Experimental" + }, + { + "Member": "object? Microsoft.Extensions.AI.DecisionResponse.RawRepresentation { get; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.DecisionRequest Microsoft.Extensions.AI.DecisionResponse.Request { get; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.UsageDetails? Microsoft.Extensions.AI.DecisionResponse.Usage { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionResultBinding", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionResultBinding.DecisionResultBinding(System.Text.Json.Serialization.Metadata.JsonTypeInfo resultTypeInfo, System.Func map);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "System.Text.Json.Serialization.Metadata.JsonTypeInfo Microsoft.Extensions.AI.DecisionResultBinding.ResultTypeInfo { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionScoreLevel", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DecisionScoreLevel.DecisionScoreLevel(string id, string description);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.DecisionScoreLevel.Description { get; }", + "Stage": "Experimental" + }, + { + "Member": "string Microsoft.Extensions.AI.DecisionScoreLevel.Id { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "interface Microsoft.Extensions.AI.IDecisionClient : System.IDisposable", + "Stage": "Experimental", + "Methods": [ + { + "Member": "System.Threading.Tasks.Task Microsoft.Extensions.AI.IDecisionClient.GetResponseAsync(Microsoft.Extensions.AI.DecisionRequest request, Microsoft.Extensions.AI.DecisionOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "object? Microsoft.Extensions.AI.IDecisionClient.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.ScoreDecisionAnswer : Microsoft.Extensions.AI.DecisionAnswer", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ScoreDecisionAnswer.ScoreDecisionAnswer(string questionId, double score, System.Collections.Generic.IReadOnlyList probabilities, Microsoft.Extensions.AI.DecisionPrecision precision = Microsoft.Extensions.AI.DecisionPrecision.HighPrecision, object? rawRepresentation = null, System.Collections.Generic.IEnumerable>? additionalProperties = null);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "double Microsoft.Extensions.AI.ScoreDecisionAnswer.ExpectedScore { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.ScoreDecisionAnswer.Probabilities { get; }", + "Stage": "Experimental" + }, + { + "Member": "double Microsoft.Extensions.AI.ScoreDecisionAnswer.Score { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.ScoreDecisionQuestion : Microsoft.Extensions.AI.DecisionQuestion", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ScoreDecisionQuestion.ScoreDecisionQuestion(string id, string? instructions, System.Collections.Generic.IReadOnlyList levels);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.ScoreDecisionQuestion.Levels { get; }", + "Stage": "Experimental" + } + ] } ] } \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs index 70370c0170b..21c057ae6fa 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs @@ -143,6 +143,22 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(HostedFileClientOptions))] [JsonSerializable(typeof(HostedFileClientMetadata))] + // Decisions + [JsonSerializable(typeof(DecisionOptions))] + [JsonSerializable(typeof(DecisionRequest))] + [JsonSerializable(typeof(DecisionQuestion))] + [JsonSerializable(typeof(BinaryDecisionQuestion))] + [JsonSerializable(typeof(ChoiceDecisionQuestion))] + [JsonSerializable(typeof(ScoreDecisionQuestion))] + [JsonSerializable(typeof(DecisionCandidate))] + [JsonSerializable(typeof(DecisionScoreLevel))] + [JsonSerializable(typeof(DecisionResponse))] + [JsonSerializable(typeof(DecisionAnswer))] + [JsonSerializable(typeof(BinaryDecisionAnswer))] + [JsonSerializable(typeof(ChoiceDecisionAnswer))] + [JsonSerializable(typeof(ScoreDecisionAnswer))] + [JsonSerializable(typeof(DecisionProbability))] + [EditorBrowsable(EditorBrowsableState.Never)] // Never use JsonContext directly, use DefaultOptions instead. private sealed partial class JsonContext : JsonSerializerContext; diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs index fd5a3780f3e..0f1377655b7 100644 --- a/src/Shared/DiagnosticIds/DiagnosticIds.cs +++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs @@ -62,6 +62,7 @@ internal static class Experiments internal const string AIFiles = AIExperiments; internal const string AIOpenAIRequestPolicies = AIExperiments; internal const string AIRoutingChat = AIExperiments; + internal const string AIDecisions = AIExperiments; // These diagnostic IDs are defined by the OpenAI package for its experimental APIs. // We use the same IDs so consumers do not need to suppress additional diagnostics diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs new file mode 100644 index 00000000000..58a3b868f02 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs @@ -0,0 +1,313 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class DecisionTests +{ + [Fact] + public void EnumDefinition_PreservesExactIdsAndRejectsInvalidMappings() + { + DecisionEnumDefinition definition = new( + [ + new(Classification.Approved, "approve|v1", "Approve the request"), + new(Classification.Rejected, "reject|v1", "Reject the request"), + ]); + + Assert.Equal(["approve|v1", "reject|v1"], definition.Candidates.Select(static c => c.Id)); + Assert.Equal(Classification.Approved, definition.Parse("approve|v1")); + Assert.Throws(() => definition.Parse("approve")); + Assert.Throws(() => new DecisionEnumDefinition( + [ + new(Classification.Approved, "one", "One"), + new(Classification.Approved, "two", "Two"), + ])); + Assert.Throws(() => new DecisionEnumDefinition( + [ + new(Classification.Approved, "duplicate", "One"), + new(Classification.Rejected, "duplicate", "Two"), + ])); + + DecisionRequest request = CreateRequest( + definition.CreateQuestion("classification", "Classify the request.")); + DecisionResponse response = new( + request, + [ + new ChoiceDecisionAnswer( + "classification", + "reject|v1", + [ + new("approve|v1", 0.25), + new("reject|v1", 0.75), + ]), + ]); + + Assert.Equal( + Classification.Rejected, + response.Bind( + new DecisionResultBinding( + TestJsonSerializerContext.Default.Classification, + answers => answers.GetChoice("classification", definition)))); + Assert.Equal(["approve|v1", "reject|v1"], ((ChoiceDecisionAnswer)response.Answers[0]).Probabilities.Select(static p => p.Id)); + } + + [Fact] + public void MixedAnswers_BindToOwnedResultAndRetainRoundedScoreEvidence() + { + DecisionResponse response = CreateMixedResponse(); + + DecisionResult result = response.Bind( + new DecisionResultBinding( + TestJsonSerializerContext.Default.DecisionResult, + answers => + { + ScoreDecisionAnswer score = answers.GetScore("score"); + return new DecisionResult + { + Approved = answers.GetBinary("binary").TrueProbability > 0.5, + Selected = answers.GetChoice("choice").SelectedCandidateId, + Score = score.Score, + ExpectedScore = score.ExpectedScore, + }; + })); + + Assert.True(result.Approved); + Assert.Equal("blue|candidate", result.Selected); + Assert.Equal(1.58, result.Score); + Assert.Equal(1.6, result.ExpectedScore, precision: 12); + Assert.Equal(3, ((ScoreDecisionAnswer)response.Answers[2]).Probabilities.Count); + } + + [Fact] + public void DynamicIds_AreCorrelatedPerRequestAndRejectStaleCandidates() + { + string activeId = "route|" + Guid.NewGuid().ToString("N"); + ChoiceDecisionQuestion question = new( + "route", + "Select the active route.", + [new DecisionCandidate(activeId, "Current route")]); + DecisionRequest request = CreateRequest(question); + + DecisionResponse response = new( + request, + [new ChoiceDecisionAnswer("route", activeId, [new DecisionProbability(activeId, 1)])]); + + Assert.Equal(activeId, ((ChoiceDecisionAnswer)response.Answers[0]).SelectedCandidateId); + Assert.Throws(() => new DecisionResponse( + request, + [new ChoiceDecisionAnswer("route", "stale", [new DecisionProbability("stale", 1)])])); + } + + [Fact] + public void FeatureProjection_UsesNamedNativeDoubleCoordinatesAndProvenance() + { + DecisionResponse response = CreateMixedResponse(); + DecisionFeatureSchema schema = new( + "routing-decision-v1", + 2, + [ + new("binary.true", "binary", DecisionFeatureValueKind.BinaryTrueProbability), + new("choice.blue", "choice", DecisionFeatureValueKind.ChoiceProbability, "blue|candidate"), + new("score.high", "score", DecisionFeatureValueKind.ScoreProbability, "high"), + new("score.value", "score", DecisionFeatureValueKind.Score), + ]); + + DecisionFeatureVector vector = DecisionFeatureProjection.Project(response, schema); + + Assert.Equal("routing-decision-v1", vector.Schema.Id); + Assert.Equal(2, vector.Schema.Version); + Assert.Equal(["binary.true", "choice.blue", "score.high", "score.value"], vector.Values.Select(static value => value.Name)); + Assert.Equal(0.75, vector.Values[0].Value); + Assert.Equal(0.5, vector.Values[1].Value); + Assert.Equal(0.7, vector.Values[2].Value); + Assert.Equal(1.58, vector.Values[3].Value); + Assert.Equal("test-provider", vector.Provenance!.ProviderName); + } + + [Fact] + public void ReflectionDisabledJson_UsesExplicitSourceGeneratedContracts() + { + DecisionQuestion question = new BinaryDecisionQuestion("q", "Is this valid?"); + DecisionRequest request = DecisionRequest.Create( + new DecisionState { Value = "payload" }, + TestJsonSerializerContext.Default.DecisionState, + [question]); + + string json = JsonSerializer.Serialize(request, TestJsonSerializerContext.Default.DecisionRequest); + DecisionRequest? roundtrip = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.DecisionRequest); + + Assert.NotNull(roundtrip); + Assert.Equal("payload", roundtrip.State.GetProperty("value").GetString()); + Assert.Equal("q", roundtrip.Questions[0].Id); + } + + [Fact] + public async Task ClientCancellation_IsNotConvertedToFabricatedResponse() + { + using CancellationClient client = new(); + DecisionRequest request = CreateRequest(new BinaryDecisionQuestion("q", "Is this valid?")); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => client.GetResponseAsync(request, cancellationToken: cancellation.Token)); + Assert.Equal(1, client.CallCount); + } + + [Fact] + public async Task Projection_IsSafeToRunConcurrentlyOnAnOwnedResponse() + { + DecisionResponse response = CreateMixedResponse(); + DecisionFeatureSchema schema = new( + "concurrent-v1", + 1, + [new("choice.blue", "choice", DecisionFeatureValueKind.ChoiceProbability, "blue|candidate")]); + + DecisionFeatureVector[] vectors = await Task.WhenAll( + Enumerable.Range(0, 16).Select(_ => Task.Run(() => DecisionFeatureProjection.Project(response, schema)))); + + Assert.All(vectors, vector => Assert.Equal(0.5, vector.Values[0].Value)); + } + + [Fact] + public void BindingMapperFailure_IsPropagatedWithoutPartialSuccess() + { + DecisionResponse response = CreateMixedResponse(); + DecisionResultBinding binding = new( + TestJsonSerializerContext.Default.DecisionResult, + _ => throw new InvalidOperationException("mapper failed")); + + InvalidOperationException exception = Assert.Throws(() => response.Bind(binding)); + Assert.Equal("mapper failed", exception.Message); + } + + [Fact] + public void RequestsAndResponses_OwnCollectionSnapshots() + { + List questions = [new BinaryDecisionQuestion("q", "Is this valid?")]; + DecisionRequest request = CreateRequest(questions.ToArray()); + questions.Clear(); + + List answers = [new BinaryDecisionAnswer("q", 0.75)]; + DecisionResponse response = new(request, answers); + answers.Clear(); + + Assert.Single(request.Questions); + Assert.Single(response.Answers); + } + + [Fact] + public void OptionsClone_OwnsProviderExtensionSnapshot() + { + DecisionOptions options = new() + { + ModelId = "model", + AdditionalProperties = new AdditionalPropertiesDictionary { ["mode"] = "original" }, + }; + + DecisionOptions clone = options.Clone(); + options.AdditionalProperties["mode"] = "changed"; + + Assert.Equal("model", clone.ModelId); + Assert.Equal("original", clone.AdditionalProperties!["mode"]); + } + + private static DecisionRequest CreateRequest(params DecisionQuestion[] questions) + { + using JsonDocument document = JsonDocument.Parse("{}"); + return new(document.RootElement.Clone(), questions); + } + + private static DecisionResponse CreateMixedResponse() + { + DecisionRequest request = CreateRequest( + new BinaryDecisionQuestion("binary", "Is the request approved?"), + new ChoiceDecisionQuestion( + "choice", + "Select a color.", + [ + new("red|candidate", "Red"), + new("blue|candidate", "Blue"), + new("green|candidate", "Green"), + ]), + new ScoreDecisionQuestion( + "score", + "Score the request.", + [ + new("low", "Low"), + new("medium", "Medium"), + new("high", "High"), + ])); + + return new DecisionResponse( + request, + [ + new BinaryDecisionAnswer("binary", 0.75), + new ChoiceDecisionAnswer( + "choice", + "blue|candidate", + [ + new("red|candidate", 0.2), + new("blue|candidate", 0.5), + new("green|candidate", 0.3), + ]), + new ScoreDecisionAnswer( + "score", + 1.58, + [ + new("low", 0.1), + new("medium", 0.2), + new("high", 0.7), + ], + DecisionPrecision.TwoDecimalPlaces), + ], + new DecisionProvenance(providerName: "test-provider", modelId: "test-model")); + } + + internal enum Classification + { + Approved, + Rejected, + } + + internal sealed class DecisionState + { + public string? Value { get; set; } + } + + internal sealed class DecisionResult + { + public bool Approved { get; set; } + public string? Selected { get; set; } + public double Score { get; set; } + public double ExpectedScore { get; set; } + } + + private sealed class CancellationClient : IDecisionClient + { + public int CallCount { get; private set; } + + public Task GetResponseAsync( + DecisionRequest request, + DecisionOptions? options = null, + CancellationToken cancellationToken = default) + { + CallCount++; + return Task.FromCanceled(cancellationToken); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs index c5c111a8aa2..5744ef0c119 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs @@ -53,4 +53,21 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(ReasoningEffort))] [JsonSerializable(typeof(ReasoningOutput))] [JsonSerializable(typeof(HostedFileClientOptions))] +[JsonSerializable(typeof(DecisionOptions))] +[JsonSerializable(typeof(DecisionRequest))] +[JsonSerializable(typeof(DecisionQuestion))] +[JsonSerializable(typeof(BinaryDecisionQuestion))] +[JsonSerializable(typeof(ChoiceDecisionQuestion))] +[JsonSerializable(typeof(ScoreDecisionQuestion))] +[JsonSerializable(typeof(DecisionCandidate))] +[JsonSerializable(typeof(DecisionScoreLevel))] +[JsonSerializable(typeof(DecisionResponse))] +[JsonSerializable(typeof(DecisionAnswer))] +[JsonSerializable(typeof(BinaryDecisionAnswer))] +[JsonSerializable(typeof(ChoiceDecisionAnswer))] +[JsonSerializable(typeof(ScoreDecisionAnswer))] +[JsonSerializable(typeof(DecisionProbability))] +[JsonSerializable(typeof(DecisionTests.Classification))] +[JsonSerializable(typeof(DecisionTests.DecisionState))] +[JsonSerializable(typeof(DecisionTests.DecisionResult))] internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; From 6aa33bf5ef33c6818829d1f223988e4d8d3c6064 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Wed, 30 Sep 2026 19:19:43 -0400 Subject: [PATCH 02/11] Fix decision JSON and rounding validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b735e048-cd3e-4bfb-ad05-3f68ad46082e --- .../Decisions/DecisionAnswers.cs | 56 ++++++++--- .../Decisions/DecisionClient.cs | 2 +- .../Microsoft.Extensions.AI.Abstractions.json | 8 +- .../Utilities/AIJsonUtilities.Defaults.cs | 1 + .../Decisions/DecisionTests.cs | 95 ++++++++++++++++++- .../TestJsonSerializerContext.cs | 1 + 6 files changed, 142 insertions(+), 21 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs index e02ec882246..e5b1a683ff8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs @@ -66,7 +66,7 @@ private protected DecisionAnswer( DecisionKind kind, DecisionPrecision precision, object? rawRepresentation, - IEnumerable>? additionalProperties) + IReadOnlyDictionary? additionalProperties) { QuestionId = Throw.IfNullOrWhitespace(questionId); if (!Enum.IsDefined(typeof(DecisionPrecision), precision)) @@ -110,13 +110,20 @@ internal static void ValidateProbability(double value) } } - internal static void ValidateDistribution(IReadOnlyList probabilities) + 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."); @@ -126,12 +133,38 @@ internal static void ValidateDistribution(IReadOnlyList pro sum += probability.Value; } - if (Math.Abs(sum - 1) > DistributionTolerance) + double tolerance = GetDistributionTolerance(precision, probabilities.Count); + if (Math.Abs(sum - 1) > tolerance) { throw new DecisionProtocolException( - "A probability distribution must sum to 1 within an absolute tolerance of 1e-6."); + "A probability distribution must sum to 1 within the bound allowed by 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 GetScoreTolerance(DecisionPrecision precision, int count) + { + double roundingUnit = precision switch + { + DecisionPrecision.HighPrecision => 0, + DecisionPrecision.FourDecimalPlaces => 0.00005, + DecisionPrecision.TwoDecimalPlaces => 0.005, + _ => throw new ArgumentOutOfRangeException(nameof(precision)), + }; + + double probabilityError = (roundingUnit * count * (count - 1)) / 2; + return probabilityError + roundingUnit + DistributionTolerance; + } } /// Represents the probability that a binary proposition is true. @@ -145,7 +178,7 @@ public BinaryDecisionAnswer( double trueProbability, DecisionPrecision precision = DecisionPrecision.HighPrecision, object? rawRepresentation = null, - IEnumerable>? additionalProperties = null) + IReadOnlyDictionary? additionalProperties = null) : base(questionId, DecisionKind.Binary, precision, rawRepresentation, additionalProperties) { ValidateProbability(trueProbability); @@ -168,7 +201,7 @@ public ChoiceDecisionAnswer( IReadOnlyList probabilities, DecisionPrecision precision = DecisionPrecision.HighPrecision, object? rawRepresentation = null, - IEnumerable>? additionalProperties = null) + IReadOnlyDictionary? additionalProperties = null) : base(questionId, DecisionKind.Choice, precision, rawRepresentation, additionalProperties) { SelectedCandidateId = Throw.IfNullOrWhitespace(selectedCandidateId); @@ -180,7 +213,7 @@ public ChoiceDecisionAnswer( throw new DecisionProtocolException("A choice answer requires a complete distribution."); } - ValidateDistribution(copy); + 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."); @@ -208,7 +241,7 @@ public ScoreDecisionAnswer( IReadOnlyList probabilities, DecisionPrecision precision = DecisionPrecision.HighPrecision, object? rawRepresentation = null, - IEnumerable>? additionalProperties = null) + IReadOnlyDictionary? additionalProperties = null) : base(questionId, DecisionKind.Score, precision, rawRepresentation, additionalProperties) { _ = Throw.IfNull(probabilities); @@ -219,15 +252,14 @@ public ScoreDecisionAnswer( throw new DecisionProtocolException("A score answer requires at least two levels."); } - ValidateDistribution(copy); + ValidateDistribution(copy, precision); if (double.IsNaN(score) || double.IsInfinity(score) || score < 0 || score > 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 (precision == DecisionPrecision.HighPrecision && - Math.Abs(score - expectedScore) > DistributionTolerance * (copy.Length - 1)) + if (Math.Abs(score - expectedScore) > GetScoreTolerance(precision, copy.Length)) { throw new DecisionProtocolException("The score does not agree with its probability distribution."); } @@ -272,7 +304,7 @@ public DecisionProtocolException(string message, Exception innerException) internal static class DecisionSnapshots { internal static IReadOnlyDictionary? Properties( - IEnumerable>? entries) + IReadOnlyDictionary? entries) { if (entries is null) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs index b2eabb76630..ed2907146cc 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs @@ -234,7 +234,7 @@ public DecisionResponse( DecisionProvenance? provenance = null, UsageDetails? usage = null, object? rawRepresentation = null, - IEnumerable>? additionalProperties = null) + IReadOnlyDictionary? additionalProperties = null) { Request = Throw.IfNull(request); _ = Throw.IfNull(answers); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index bd3719c2c9e..d9caabbd5c1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -4945,7 +4945,7 @@ "Stage": "Experimental", "Methods": [ { - "Member": "Microsoft.Extensions.AI.BinaryDecisionAnswer.BinaryDecisionAnswer(string questionId, double trueProbability, Microsoft.Extensions.AI.DecisionPrecision precision = Microsoft.Extensions.AI.DecisionPrecision.HighPrecision, object? rawRepresentation = null, System.Collections.Generic.IEnumerable>? additionalProperties = null);", + "Member": "Microsoft.Extensions.AI.BinaryDecisionAnswer.BinaryDecisionAnswer(string questionId, double trueProbability, Microsoft.Extensions.AI.DecisionPrecision precision = Microsoft.Extensions.AI.DecisionPrecision.HighPrecision, object? rawRepresentation = null, System.Collections.Generic.IReadOnlyDictionary? additionalProperties = null);", "Stage": "Experimental" } ], @@ -4981,7 +4981,7 @@ "Stage": "Experimental", "Methods": [ { - "Member": "Microsoft.Extensions.AI.ChoiceDecisionAnswer.ChoiceDecisionAnswer(string questionId, string selectedCandidateId, System.Collections.Generic.IReadOnlyList probabilities, Microsoft.Extensions.AI.DecisionPrecision precision = Microsoft.Extensions.AI.DecisionPrecision.HighPrecision, object? rawRepresentation = null, System.Collections.Generic.IEnumerable>? additionalProperties = null);", + "Member": "Microsoft.Extensions.AI.ChoiceDecisionAnswer.ChoiceDecisionAnswer(string questionId, string selectedCandidateId, System.Collections.Generic.IReadOnlyList probabilities, Microsoft.Extensions.AI.DecisionPrecision precision = Microsoft.Extensions.AI.DecisionPrecision.HighPrecision, object? rawRepresentation = null, System.Collections.Generic.IReadOnlyDictionary? additionalProperties = null);", "Stage": "Experimental" } ], @@ -5554,7 +5554,7 @@ "Stage": "Experimental", "Methods": [ { - "Member": "Microsoft.Extensions.AI.DecisionResponse.DecisionResponse(Microsoft.Extensions.AI.DecisionRequest request, System.Collections.Generic.IReadOnlyList answers, Microsoft.Extensions.AI.DecisionProvenance? provenance = null, Microsoft.Extensions.AI.UsageDetails? usage = null, object? rawRepresentation = null, System.Collections.Generic.IEnumerable>? additionalProperties = null);", + "Member": "Microsoft.Extensions.AI.DecisionResponse.DecisionResponse(Microsoft.Extensions.AI.DecisionRequest request, System.Collections.Generic.IReadOnlyList answers, Microsoft.Extensions.AI.DecisionProvenance? provenance = null, Microsoft.Extensions.AI.UsageDetails? usage = null, object? rawRepresentation = null, System.Collections.Generic.IReadOnlyDictionary? additionalProperties = null);", "Stage": "Experimental" }, { @@ -5644,7 +5644,7 @@ "Stage": "Experimental", "Methods": [ { - "Member": "Microsoft.Extensions.AI.ScoreDecisionAnswer.ScoreDecisionAnswer(string questionId, double score, System.Collections.Generic.IReadOnlyList probabilities, Microsoft.Extensions.AI.DecisionPrecision precision = Microsoft.Extensions.AI.DecisionPrecision.HighPrecision, object? rawRepresentation = null, System.Collections.Generic.IEnumerable>? additionalProperties = null);", + "Member": "Microsoft.Extensions.AI.ScoreDecisionAnswer.ScoreDecisionAnswer(string questionId, double score, System.Collections.Generic.IReadOnlyList probabilities, Microsoft.Extensions.AI.DecisionPrecision precision = Microsoft.Extensions.AI.DecisionPrecision.HighPrecision, object? rawRepresentation = null, System.Collections.Generic.IReadOnlyDictionary? additionalProperties = null);", "Stage": "Experimental" } ], diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs index 21c057ae6fa..3f931ce6936 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs @@ -145,6 +145,7 @@ private static JsonSerializerOptions CreateDefaultOptions() // Decisions [JsonSerializable(typeof(DecisionOptions))] + [JsonSerializable(typeof(DecisionClientMetadata))] [JsonSerializable(typeof(DecisionRequest))] [JsonSerializable(typeof(DecisionQuestion))] [JsonSerializable(typeof(BinaryDecisionQuestion))] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs index 58a3b868f02..33db2a5916e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs @@ -86,6 +86,70 @@ public void MixedAnswers_BindToOwnedResultAndRetainRoundedScoreEvidence() Assert.Equal(3, ((ScoreDecisionAnswer)response.Answers[2]).Probabilities.Count); } + [Fact] + public void ProbabilityValidation_RejectsNullEntriesAndAllowsDeclaredRounding() + { + Assert.Throws(() => new ChoiceDecisionAnswer( + "choice", + "a", + [new DecisionProbability("a", 1), null!])); + + ChoiceDecisionAnswer twoDecimal = new( + "choice", + "a", + [ + new("a", 0.33), + new("b", 0.33), + new("c", 0.33), + ], + DecisionPrecision.TwoDecimalPlaces); + ChoiceDecisionAnswer fourDecimal = new( + "choice", + "a", + [ + new("a", 0.3333), + new("b", 0.3333), + new("c", 0.3333), + ], + DecisionPrecision.FourDecimalPlaces); + + Assert.Equal(3, twoDecimal.Probabilities.Count); + Assert.Equal(3, fourDecimal.Probabilities.Count); + Assert.Throws(() => new ChoiceDecisionAnswer( + "choice", + "a", + [ + new("a", 0.33), + new("b", 0.33), + new("c", 0.33), + ])); + } + + [Fact] + public void RoundedScoreValidation_UsesProbabilityAndScoreBounds() + { + ScoreDecisionAnswer valid = new( + "score", + 1.58, + [ + new("low", 0.1), + new("medium", 0.2), + new("high", 0.7), + ], + DecisionPrecision.TwoDecimalPlaces); + + Assert.Equal(1.58, valid.Score); + Assert.Throws(() => new ScoreDecisionAnswer( + "score", + 2, + [ + new("low", 1), + new("medium", 0), + new("high", 0), + ], + DecisionPrecision.TwoDecimalPlaces)); + } + [Fact] public void DynamicIds_AreCorrelatedPerRequestAndRejectStaleCandidates() { @@ -149,6 +213,23 @@ public void ReflectionDisabledJson_UsesExplicitSourceGeneratedContracts() Assert.Equal("q", roundtrip.Questions[0].Id); } + [Fact] + public void ReflectionDisabledJson_RoundTripsAnswerAndResponseSnapshots() + { + DecisionResponse original = CreateMixedResponse(); + + string json = JsonSerializer.Serialize(original, TestJsonSerializerContext.Default.DecisionResponse); + DecisionResponse? roundtrip = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.DecisionResponse); + + Assert.NotNull(roundtrip); + Assert.Equal(3, roundtrip.Answers.Count); + Assert.Equal("binary", ((BinaryDecisionAnswer)roundtrip.Answers[0]).AdditionalProperties!["kind"]!.ToString()); + Assert.Equal("choice", ((ChoiceDecisionAnswer)roundtrip.Answers[1]).AdditionalProperties!["kind"]!.ToString()); + Assert.Equal("score", ((ScoreDecisionAnswer)roundtrip.Answers[2]).AdditionalProperties!["kind"]!.ToString()); + Assert.Equal("response", roundtrip.AdditionalProperties!["kind"]!.ToString()); + Assert.Equal(1.58, ((ScoreDecisionAnswer)roundtrip.Answers[2]).Score); + } + [Fact] public async Task ClientCancellation_IsNotConvertedToFabricatedResponse() { @@ -250,7 +331,10 @@ private static DecisionResponse CreateMixedResponse() return new DecisionResponse( request, [ - new BinaryDecisionAnswer("binary", 0.75), + new BinaryDecisionAnswer( + "binary", + 0.75, + additionalProperties: new Dictionary { ["kind"] = "binary" }), new ChoiceDecisionAnswer( "choice", "blue|candidate", @@ -258,7 +342,8 @@ private static DecisionResponse CreateMixedResponse() new("red|candidate", 0.2), new("blue|candidate", 0.5), new("green|candidate", 0.3), - ]), + ], + additionalProperties: new Dictionary { ["kind"] = "choice" }), new ScoreDecisionAnswer( "score", 1.58, @@ -267,9 +352,11 @@ private static DecisionResponse CreateMixedResponse() new("medium", 0.2), new("high", 0.7), ], - DecisionPrecision.TwoDecimalPlaces), + DecisionPrecision.TwoDecimalPlaces, + additionalProperties: new Dictionary { ["kind"] = "score" }), ], - new DecisionProvenance(providerName: "test-provider", modelId: "test-model")); + new DecisionProvenance(providerName: "test-provider", modelId: "test-model"), + additionalProperties: new Dictionary { ["kind"] = "response" }); } internal enum Classification diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs index 5744ef0c119..9c7d824dafa 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs @@ -54,6 +54,7 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(ReasoningOutput))] [JsonSerializable(typeof(HostedFileClientOptions))] [JsonSerializable(typeof(DecisionOptions))] +[JsonSerializable(typeof(DecisionClientMetadata))] [JsonSerializable(typeof(DecisionRequest))] [JsonSerializable(typeof(DecisionQuestion))] [JsonSerializable(typeof(BinaryDecisionQuestion))] From 7848b09a1f6c442851377aca4752b35de8830c65 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Thu, 1 Oct 2026 09:24:34 -0400 Subject: [PATCH 03/11] Document business decision example Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b735e048-cd3e-4bfb-ad05-3f68ad46082e --- .../Decisions/README.md | 97 ++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index 6a6dd49a1c4..f1064e163d1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -1,7 +1,100 @@ # Experimental decision abstractions -The `Microsoft.Extensions.AI.Abstractions` decision contracts provide a small provider-neutral Layer 1 capability for heterogeneous binary, choice, and ordinal-score questions. Requests preserve caller-owned question and candidate IDs and order; responses preserve complete probability observations, score precision, provenance, usage, and provider extensions without applying thresholds, calibration, or probability repair. +The `Microsoft.Extensions.AI.Abstractions` decision contracts provide a small provider-neutral proposed Layer 1 capability for heterogeneous binary, choice, and ordinal-score questions. Requests preserve caller-owned question and candidate IDs and order; responses preserve complete probability observations, score precision, provenance, usage, and provider extensions without applying thresholds, calibration, or probability repair. -Typed application results use an explicit `JsonTypeInfo` and mapper supplied by the application. Feature projection is likewise explicit: callers define a versioned schema of named `double` coordinates over the complete observations. The contracts snapshot collections and JSON state; `DecisionOptions.Clone` deliberately snapshots provider extension data before a request is shared. +## A business example + +Suppose a retailer annotates customer-support tickets. The application wants a normal .NET category, a probability that the ticket is a refund request, and the complete provider evidence for reporting, human review, or downstream training. It is not automatically authorizing a refund or routing a chat. + +The domain types remain ordinary application-owned types: + +```csharp +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +public sealed record SupportTicket(string Message); + +public enum TicketCategory +{ + Billing, + Technical, + Account, +} + +public sealed record TicketAnalysis( + TicketCategory Category, + double RefundRequestProbability); +``` + +The application defines the questions once, then reuses each question's exact caller-owned ID when it maps the response. The client and cancellation token are supplied and owned by the host: + +```csharp +public static async Task<(TicketAnalysis Result, DecisionResponse Evidence)> AnalyzeAsync( + IDecisionClient client, + SupportTicket ticket, + CancellationToken cancellationToken) +{ + DecisionEnumDefinition categories = new( + [ + new(TicketCategory.Billing, "billing", "The ticket concerns invoices, charges, or payment."), + new(TicketCategory.Technical, "technical", "The ticket concerns a product or service problem."), + new(TicketCategory.Account, "account", "The ticket concerns access, profile, or account details."), + ]); + + ChoiceDecisionQuestion categoryQuestion = categories.CreateQuestion( + "category", + "Classify the support ticket as billing, technical, or account."); + BinaryDecisionQuestion refundQuestion = new( + "refund-request", + "Does the ticket request or describe a refund?"); + + DecisionResultBinding binding = new( + TicketJsonContext.Default.TicketAnalysis, + answers => new TicketAnalysis( + answers.GetChoice(categoryQuestion.Id, categories), + answers.GetBinary(refundQuestion.Id).TrueProbability)); + + DecisionResponse response = await client.GetResponseAsync( + ticket, + TicketJsonContext.Default.SupportTicket, + [categoryQuestion, refundQuestion], + cancellationToken: cancellationToken); + + return (response.Bind(binding), response); +} +``` + +The JSON metadata setup is separate from the business call: + +```csharp +using System.Text.Json.Serialization; + +[JsonSourceGenerationOptions(UseStringEnumConverter = true)] +[JsonSerializable(typeof(SupportTicket))] +[JsonSerializable(typeof(TicketAnalysis))] +public partial class TicketJsonContext : JsonSerializerContext; +``` + +`TicketCategory` is a normal caller-owned enum. `DecisionEnumDefinition` is only an explicit ID-to-enum lookup plus candidate descriptions; it does not generate an enum, act as an ORM, or infer a domain model. The simple IDs (`"billing"`, `"technical"`, and `"account"`) are exact wire-correlation identities chosen by the caller. They can be assigned once and reused through `categoryQuestion.Id`; no pipe or version syntax is required. The same rule applies to the `"refund-request"` question ID. + +`JsonTypeInfo` describes the serialized shape of state or a result. In this example it is supplied by source generation; a configured reflection resolver is also a platform option. It is not handwritten JSON schema and it does not replace the decision instructions or exact provider-facing IDs. The current `DecisionResultBinding` API requires result metadata even though the application mapper shown here does not use metadata to perform its mapping; simplifying that requirement is an API review candidate, not an implicit behavior of this example. + +The returned `Evidence` is the complete `DecisionResponse`, including the category distribution and binary observation. The mapped `TicketAnalysis` is therefore not a claim that a hidden threshold or calibration step established a business fact. Applications can retain or inspect the complete evidence for reporting, review, or later training. + +Typed application results use an explicit `JsonTypeInfo` and mapper supplied by the application. Feature projection is likewise explicit and optional: callers define a versioned schema of named `double` coordinates over the complete observations when a downstream feature export is useful. A `DecisionFeatureSchema` is a downstream column/coordinate contract, distinct from JSON serialization metadata and not a prerequisite for typed classification. + +## .NET platform reuse / ergonomics under review + +Layer 1 intentionally reuses existing .NET and MEAI primitives rather than adding a builder, `IQueryable`, ORM, or new source-generator dependency: + +- [`JsonSchemaExporter`](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/extract-schema) was introduced in .NET 9. The decision contracts use `JsonTypeInfo` and serializer options for explicit metadata; they do not add another schema exporter, and `AIJsonUtilities` provides the existing MEAI compatibility helpers where applicable. +- [System.Text.Json source generation](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/source-generation) is the AOT-friendly way to supply `JsonTypeInfo`. `JsonTypeInfo` itself is not synonymous with generated code; ordinary JIT reflection through a configured resolver remains a platform option. +- [`JsonStringEnumMemberNameAttribute`](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/customize-properties#custom-enum-member-names) and the generic [`JsonStringEnumConverter`](https://learn.microsoft.com/dotnet/api/system.text.json.serialization.jsonstringenumconverter-1) are useful for JSON enum representation. They do not replace decision ID validation: enum conversion is case-insensitive and permits integer values by default, while decision IDs are exact ordinal identities. +- [`AIFunctionFactory`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.aifunctionfactory) remains the existing MEAI primitive for inspecting an explicitly declared delegate, producing a tool schema, and marshalling inputs and outputs. Tool interoperability is a later layer, not a prerequisite for this decision foundation. +- [Structured output](https://learn.microsoft.com/dotnet/ai/quickstarts/structured-output) already supports enum/record-shaped chat results. If an application only needs a label or JSON result, `IChatClient.GetResponseAsync` may be the simpler choice; that path does not by itself establish a provider-reported probability distribution. +- [EF Core model conventions and explicit overrides](https://learn.microsoft.com/ef/core/modeling/) are useful precedent for bounded opt-in conventions, not a reason to add an EF dependency or `DbContext`. [EF Core query providers](https://learn.microsoft.com/ef/core/querying/) likewise do not justify an `IQueryable` decision API for explicit paid asynchronous model inference; ordinary LINQ projection over returned probabilities remains appropriate. + +Automated arbitrary semantic inference is out. Bounded opt-in conventions for known enum naming or member mappings remain possible future ergonomics if they are explicit, source-generation/AOT-compatible, and do not hide provider-facing identities or application-owned result mapping. This layer intentionally does not define tools, routing composition, MEDI processors, provider adapters, ML.NET or Arrow integrations, or automatic POCO/union inference. Those concerns require a later layer and are not implied by these abstractions. From 157f9e0ed9a0c64d316e84835cdc03c85144fcc6 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Thu, 1 Oct 2026 09:31:22 -0400 Subject: [PATCH 04/11] Clarify decision example snippet Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b735e048-cd3e-4bfb-ad05-3f68ad46082e --- .../Decisions/README.md | 67 ++++++++++--------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index f1064e163d1..b5b8dff48ee 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -9,6 +9,8 @@ Suppose a retailer annotates customer-support tickets. The application wants a n The domain types remain ordinary application-owned types: ```csharp +#pragma warning disable MEAI001 + using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -30,38 +32,41 @@ public sealed record TicketAnalysis( The application defines the questions once, then reuses each question's exact caller-owned ID when it maps the response. The client and cancellation token are supplied and owned by the host: ```csharp -public static async Task<(TicketAnalysis Result, DecisionResponse Evidence)> AnalyzeAsync( - IDecisionClient client, - SupportTicket ticket, - CancellationToken cancellationToken) +public static class SupportTicketAnalyzer { - DecisionEnumDefinition categories = new( - [ - new(TicketCategory.Billing, "billing", "The ticket concerns invoices, charges, or payment."), - new(TicketCategory.Technical, "technical", "The ticket concerns a product or service problem."), - new(TicketCategory.Account, "account", "The ticket concerns access, profile, or account details."), - ]); - - ChoiceDecisionQuestion categoryQuestion = categories.CreateQuestion( - "category", - "Classify the support ticket as billing, technical, or account."); - BinaryDecisionQuestion refundQuestion = new( - "refund-request", - "Does the ticket request or describe a refund?"); - - DecisionResultBinding binding = new( - TicketJsonContext.Default.TicketAnalysis, - answers => new TicketAnalysis( - answers.GetChoice(categoryQuestion.Id, categories), - answers.GetBinary(refundQuestion.Id).TrueProbability)); - - DecisionResponse response = await client.GetResponseAsync( - ticket, - TicketJsonContext.Default.SupportTicket, - [categoryQuestion, refundQuestion], - cancellationToken: cancellationToken); - - return (response.Bind(binding), response); + public static async Task<(TicketAnalysis Result, DecisionResponse Evidence)> AnalyzeAsync( + IDecisionClient client, + SupportTicket ticket, + CancellationToken cancellationToken) + { + DecisionEnumDefinition categories = new( + [ + new(TicketCategory.Billing, "billing", "The ticket concerns invoices, charges, refunds, or payment."), + new(TicketCategory.Technical, "technical", "The ticket concerns product defects, errors, outages, or troubleshooting."), + new(TicketCategory.Account, "account", "The ticket concerns sign-in, profile, or account access."), + ]); + + ChoiceDecisionQuestion categoryQuestion = categories.CreateQuestion( + "category", + "Classify the support ticket as billing, technical, or account access."); + BinaryDecisionQuestion refundQuestion = new( + "refund-request", + "Does the ticket request or describe a refund?"); + + DecisionResultBinding binding = new( + TicketJsonContext.Default.TicketAnalysis, + answers => new TicketAnalysis( + answers.GetChoice(categoryQuestion.Id, categories), + answers.GetBinary(refundQuestion.Id).TrueProbability)); + + DecisionResponse response = await client.GetResponseAsync( + ticket, + TicketJsonContext.Default.SupportTicket, + [categoryQuestion, refundQuestion], + cancellationToken: cancellationToken); + + return (response.Bind(binding), response); + } } ``` From c08023479bc4b8f6b963e8fca5a3e0c59834c50a Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Thu, 1 Oct 2026 10:57:38 -0400 Subject: [PATCH 05/11] Add typed decision ergonomics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b735e048-cd3e-4bfb-ad05-3f68ad46082e --- .../Decisions/DecisionClient.cs | 110 ++++ .../Decisions/DecisionDefinitions.cs | 494 ++++++++++++++++++ .../Decisions/README.md | 115 ++-- .../Microsoft.Extensions.AI.Abstractions.json | 80 ++- .../Decisions/DecisionTests.cs | 285 ++++++++++ .../TestJsonSerializerContext.cs | 8 + 6 files changed, 1054 insertions(+), 38 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs index ed2907146cc..8ad92d61d0b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs @@ -185,6 +185,116 @@ public static Task GetResponseAsync( _ = Throw.IfNull(stateTypeInfo); return client.GetResponseAsync(DecisionRequest.Create(state, stateTypeInfo, questions), options, cancellationToken); } + + /// 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); + + DecisionResponse response = await client.GetResponseAsync( + new DecisionRequest(state, definition.Questions), + options, + cancellationToken).ConfigureAwait(false); + return definition.Bind(response); + } + + /// 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. 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..8d98889d53f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs @@ -0,0 +1,494 @@ +// 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. + /// An immutable decision definition. + public static DecisionDefinition Create( + Action configure, + JsonSerializerOptions? serializerOptions = null) + { + _ = Throw.IfNull(configure); + serializerOptions = new JsonSerializerOptions(serializerOptions ?? AIJsonUtilities.DefaultOptions); + 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) + { + _ = Throw.IfNull(response); + + 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: + writer.WriteNumber(question.PropertyName, ((ScoreDecisionAnswer)answer).Score); + break; + + default: + throw new DecisionProtocolException($"Unsupported decision kind '{question.Kind}'."); + } + } + + writer.WriteEndObject(); + } + + TResult result; + try + { + result = JsonSerializer.Deserialize(stream.ToArray(), 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."); + } + + 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) + where TEnum : struct, Enum + { + if (property.PropertyType.IsDefined(typeof(FlagsAttribute), inherit: false)) + { + Throw.ArgumentException("selector", "Flags enums are not supported as closed decision choices."); + } + + 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 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 + + internal Builder(JsonTypeInfo resultTypeInfo) + { + _resultTypeInfo = resultTypeInfo; + } + + /// Declares an enum-backed choice question. + /// The result property enum type. + /// A direct result property selector. + /// Optional explicit question instructions. + public void Choice<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] TEnum>( + Expression> selector, + string? instructions = null) + where TEnum : struct, Enum + { + PropertyInfo property = GetDirectProperty(selector); + EnsurePropertyCanBeDeclared(property, typeof(TEnum)); + + DecisionEnumDefinition enumDefinition = CreateEnumDefinition(property, _resultTypeInfo); + JsonPropertyInfo jsonProperty = GetJsonProperty(_resultTypeInfo, property); + AddQuestion( + property, + jsonProperty.Name, + new ChoiceDecisionQuestion(jsonProperty.Name, GetInstructions(jsonProperty, property, instructions), enumDefinition.Candidates), + enumDefinition, + DecisionKind.Choice); + } + + /// Declares a binary probability question backed by a result property. + /// A direct result property selector. + /// Optional explicit question instructions. + public void BinaryProbability( + Expression> selector, + string? instructions = null) + { + PropertyInfo property = GetDirectProperty(selector); + EnsurePropertyCanBeDeclared(property, typeof(double)); + + JsonPropertyInfo jsonProperty = GetJsonProperty(_resultTypeInfo, property); + AddQuestion( + property, + jsonProperty.Name, + new BinaryDecisionQuestion(jsonProperty.Name, GetInstructions(jsonProperty, property, instructions)), + enumDefinition: null, + DecisionKind.Binary); + } + + /// Declares an ordinal score question with an explicit ordered rubric. + /// A direct result property selector. + /// The caller-defined ordered score levels. + /// Optional explicit question instructions. + public void Score( + Expression> selector, + IReadOnlyList levels, + string? instructions = null) + { + PropertyInfo property = GetDirectProperty(selector); + EnsurePropertyCanBeDeclared(property, typeof(double)); + _ = Throw.IfNull(levels); + + JsonPropertyInfo jsonProperty = GetJsonProperty(_resultTypeInfo, property); + AddQuestion( + property, + jsonProperty.Name, + new ScoreDecisionQuestion(jsonProperty.Name, GetInstructions(jsonProperty, property, instructions), levels), + enumDefinition: null, + DecisionKind.Score); + } + + internal DecisionDefinition Build() + { + if (_questions.Count == 0) + { + Throw.ArgumentException("configure", "A decision definition must declare at least one question."); + } + + return new DecisionDefinition( + _resultTypeInfo, + _resultTypeInfo.Options, + _questions); + } + + private void AddQuestion( + PropertyInfo property, + string propertyName, + DecisionQuestion question, + object? enumDefinition, + DecisionKind kind) + { + if (_questions.Exists(existing => existing.Property == property || string.Equals(existing.PropertyName, propertyName, StringComparison.Ordinal))) + { + Throw.ArgumentException("selector", "A result property or JSON property name can only be declared once."); + } + + _questions.Add(new DecisionDefinitionQuestion(property, propertyName, question, enumDefinition, kind)); + } + } + +} + +/// Contains a typed result and the complete neutral evidence that produced it. +/// The application-owned result type. +[Experimental(DiagnosticIds.Experiments.AIDecisions, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class DecisionResponse +{ + private readonly DecisionDefinition _definition; + + internal DecisionResponse(TResult result, DecisionResponse evidence, DecisionDefinition definition) + { + Result = result; + Evidence = evidence; + _definition = definition; + } + + /// Gets the application-owned materialized result. + public TResult Result { get; } + + /// Gets the complete original response, including distributions and metadata. + public DecisionResponse Evidence { get; } + + /// Gets the complete probability distribution for an enum-backed choice property. + /// The enum property type. + /// The direct result property selector. + /// An immutable enum-to-probability snapshot. + public IReadOnlyDictionary GetDistribution( + Expression> selector) + where TEnum : struct, Enum => + _definition.GetDistribution(this, selector); +} + +internal sealed class DecisionDefinitionQuestion +{ + internal DecisionDefinitionQuestion( + PropertyInfo property, + string propertyName, + DecisionQuestion question, + object? enumDefinition, + DecisionKind kind) + { + Property = property; + PropertyName = propertyName; + Question = question; + EnumDefinition = enumDefinition; + Kind = kind; + } + + internal PropertyInfo Property { get; } + internal string PropertyName { get; } + internal DecisionQuestion Question { get; } + internal object? EnumDefinition { get; } + internal DecisionKind Kind { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index b5b8dff48ee..d237f4dc1eb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -1,16 +1,18 @@ # Experimental decision abstractions -The `Microsoft.Extensions.AI.Abstractions` decision contracts provide a small provider-neutral proposed Layer 1 capability for heterogeneous binary, choice, and ordinal-score questions. Requests preserve caller-owned question and candidate IDs and order; responses preserve complete probability observations, score precision, provenance, usage, and provider extensions without applying thresholds, calibration, or probability repair. +The `Microsoft.Extensions.AI.Abstractions` decision contracts provide a small provider-neutral proposed Layer 1 capability for heterogeneous binary, choice, and ordinal-score questions. Requests preserve caller-owned question and candidate IDs and order; responses preserve complete probability observations, score precision, provenance, usage, and provider extensions without applying thresholds, calibration, probability repair, retry policy, or a joint-distribution promise. ## A business example -Suppose a retailer annotates customer-support tickets. The application wants a normal .NET category, a probability that the ticket is a refund request, and the complete provider evidence for reporting, human review, or downstream training. It is not automatically authorizing a refund or routing a chat. +Suppose a retailer annotates customer-support tickets. The application wants a normal .NET category, a probability that the ticket is a refund request, and the complete provider evidence for reporting, human review, or downstream training. It is not automatically authorizing a refund, choosing a route, or treating a probability as a calibrated business decision. -The domain types remain ordinary application-owned types: +The domain types remain ordinary application-owned types. The standard `DescriptionAttribute` on the positional record parameters supplies business instructions for the selected result properties, while the enum members supply separate candidate criteria: ```csharp #pragma warning disable MEAI001 +using System.ComponentModel; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -19,57 +21,95 @@ public sealed record SupportTicket(string Message); public enum TicketCategory { + [Description("Payment, invoice, or charge concerns.")] Billing, + + [Description("Product defects, errors, outages, or troubleshooting.")] Technical, + + [Description("Sign-in, profile, or account-access concerns.")] Account, } public sealed record TicketAnalysis( - TicketCategory Category, - double RefundRequestProbability); + [Description("Classify the customer's main concern.")] TicketCategory Category, + [Description("Is the customer requesting a refund or payment reversal?")] double RefundRequestProbability); ``` -The application defines the questions once, then reuses each question's exact caller-owned ID when it maps the response. The client and cancellation token are supplied and owned by the host: +The business call defines a reusable, explicit declaration once. `Choice` declares the closed enum domain and uses its configured JSON enum names as exact candidate IDs; `BinaryProbability` maps the binary answer's `TrueProbability` to a `double`. No handwritten question IDs, enum dictionary, result mapper, JSON schema, or feature schema is required for this ordinary flow. The client and cancellation token are supplied and owned by the host: ```csharp public static class SupportTicketAnalyzer { - public static async Task<(TicketAnalysis Result, DecisionResponse Evidence)> AnalyzeAsync( + private static readonly DecisionDefinition Definition = + DecisionDefinition.Create(definition => + { + definition.Choice(result => result.Category); + definition.BinaryProbability(result => result.RefundRequestProbability); + }); + + public static async Task> AnalyzeAsync( IDecisionClient client, SupportTicket ticket, CancellationToken cancellationToken) { - DecisionEnumDefinition categories = new( - [ - new(TicketCategory.Billing, "billing", "The ticket concerns invoices, charges, refunds, or payment."), - new(TicketCategory.Technical, "technical", "The ticket concerns product defects, errors, outages, or troubleshooting."), - new(TicketCategory.Account, "account", "The ticket concerns sign-in, profile, or account access."), - ]); - - ChoiceDecisionQuestion categoryQuestion = categories.CreateQuestion( - "category", - "Classify the support ticket as billing, technical, or account access."); - BinaryDecisionQuestion refundQuestion = new( - "refund-request", - "Does the ticket request or describe a refund?"); - - DecisionResultBinding binding = new( - TicketJsonContext.Default.TicketAnalysis, - answers => new TicketAnalysis( - answers.GetChoice(categoryQuestion.Id, categories), - answers.GetBinary(refundQuestion.Id).TrueProbability)); - - DecisionResponse response = await client.GetResponseAsync( + return await client.GetResponseAsync( ticket, - TicketJsonContext.Default.SupportTicket, - [categoryQuestion, refundQuestion], + Definition, cancellationToken: cancellationToken); - - return (response.Bind(binding), response); } } ``` +The typed response exposes both the application result and the complete original evidence: + +```csharp +DecisionResponse analyzed = await SupportTicketAnalyzer.AnalyzeAsync(client, ticket, cancellationToken); +TicketAnalysis result = analyzed.Result; +IReadOnlyDictionary categoryDistribution = + analyzed.GetDistribution(value => value.Category); +DecisionResponse evidence = analyzed.Evidence; +``` + +The same definition can include an explicitly ordered score rubric, and heterogeneous declarations remain independent: + +```csharp +DecisionDefinition definition = DecisionDefinition.Create(builder => +{ + builder.Choice(result => result.Category, "Classify the customer's main concern."); + builder.BinaryProbability(result => result.RefundRequestProbability); + // A score property would be declared with an explicit DecisionScoreLevel list: + // builder.Score(result => result.Satisfaction, [new("low", "Low"), new("high", "High")]); +}); +``` + +At an explicit JSON boundary, callers can use source-generated metadata for both state and result contracts. The explicit generic arguments are optional for inference, but make the boundary visible: + +```csharp +DecisionDefinition definition = + DecisionDefinition.Create(TicketJsonContext.Default.TicketAnalysis, builder => + { + builder.Choice(result => result.Category); + builder.BinaryProbability(result => result.RefundRequestProbability); + }); + +DecisionResponse analyzed = await client.GetResponseAsync( + ticket, + TicketJsonContext.Default.SupportTicket, + definition, + cancellationToken: cancellationToken); +``` + +Applications that only need the neutral response can keep the no-binding path: + +```csharp +DecisionResponse evidence = await client.GetResponseAsync( + ticket, + TicketJsonContext.Default.SupportTicket, + definition.Questions, + cancellationToken: cancellationToken); +``` + The JSON metadata setup is separate from the business call: ```csharp @@ -77,17 +117,18 @@ using System.Text.Json.Serialization; [JsonSourceGenerationOptions(UseStringEnumConverter = true)] [JsonSerializable(typeof(SupportTicket))] +[JsonSerializable(typeof(TicketCategory))] [JsonSerializable(typeof(TicketAnalysis))] public partial class TicketJsonContext : JsonSerializerContext; ``` -`TicketCategory` is a normal caller-owned enum. `DecisionEnumDefinition` is only an explicit ID-to-enum lookup plus candidate descriptions; it does not generate an enum, act as an ORM, or infer a domain model. The simple IDs (`"billing"`, `"technical"`, and `"account"`) are exact wire-correlation identities chosen by the caller. They can be assigned once and reused through `categoryQuestion.Id`; no pipe or version syntax is required. The same rule applies to the `"refund-request"` question ID. +`TicketCategory` is a normal caller-owned enum. `DecisionDefinition` is an explicit declaration of which result members participate; it is not an enum generator, ORM, arbitrary POCO/union inference engine, or LINQ query provider. The candidate IDs come from the configured JSON enum contract and are exact ordinal wire-correlation identities. They are stable simple strings reused through the question definition; no pipe/version syntax is required. If a domain needs dynamic IDs instead of a closed enum, use the lower-level `ChoiceDecisionQuestion` and explicit mapping APIs so stale or unknown IDs remain rejected. -`JsonTypeInfo` describes the serialized shape of state or a result. In this example it is supplied by source generation; a configured reflection resolver is also a platform option. It is not handwritten JSON schema and it does not replace the decision instructions or exact provider-facing IDs. The current `DecisionResultBinding` API requires result metadata even though the application mapper shown here does not use metadata to perform its mapping; simplifying that requirement is an API review candidate, not an implicit behavior of this example. +Attributes are an ergonomic fallback for instructions and enum candidate descriptions. A fluent instruction supplied to `Choice`, `BinaryProbability`, or `Score` wins over an attribute. The metadata reader checks an attributed property first, then an associated positional-record constructor parameter, then a type-level fallback. JSON enum serialization controls the candidate IDs, but JSON conversion's permissive/case-insensitive behavior does not replace exact decision correlation validation. -The returned `Evidence` is the complete `DecisionResponse`, including the category distribution and binary observation. The mapped `TicketAnalysis` is therefore not a claim that a hidden threshold or calibration step established a business fact. Applications can retain or inspect the complete evidence for reporting, review, or later training. +`JsonTypeInfo` describes the serialized shape of state or a result. It may come from source generation or a configured reflection resolver; it is not handwritten JSON schema and it does not replace decision instructions, exact IDs, complete distributions, or stale-domain checks. The older `DecisionResultBinding` API still requires result metadata even though a delegate mapper may not use it; simplifying that requirement is an API review candidate, not an implicit behavior of this convenience layer. -Typed application results use an explicit `JsonTypeInfo` and mapper supplied by the application. Feature projection is likewise explicit and optional: callers define a versioned schema of named `double` coordinates over the complete observations when a downstream feature export is useful. A `DecisionFeatureSchema` is a downstream column/coordinate contract, distinct from JSON serialization metadata and not a prerequisite for typed classification. +The returned `Evidence` is the complete `DecisionResponse`, including every category probability and the binary observation. The mapped `TicketAnalysis` is therefore not a claim that a hidden threshold or calibration step established a business fact. Applications can retain or inspect the complete evidence for reporting, review, or later training. Feature projection is an optional follow-on: callers may define a versioned `DecisionFeatureSchema` of named native-`double` coordinates over complete observations when a downstream feature export is useful. That schema is a downstream column/coordinate contract, distinct from JSON metadata and not a prerequisite for typed classification. ## .NET platform reuse / ergonomics under review @@ -102,4 +143,4 @@ Layer 1 intentionally reuses existing .NET and MEAI primitives rather than addin Automated arbitrary semantic inference is out. Bounded opt-in conventions for known enum naming or member mappings remain possible future ergonomics if they are explicit, source-generation/AOT-compatible, and do not hide provider-facing identities or application-owned result mapping. -This layer intentionally does not define tools, routing composition, MEDI processors, provider adapters, ML.NET or Arrow integrations, or automatic POCO/union inference. Those concerns require a later layer and are not implied by these abstractions. +This layer intentionally does not define tools, routing composition, MEDI processors, provider adapters, ML.NET or Arrow integrations, or automatic arbitrary POCO/union semantic inference. AIFunction/tool interoperability is a Layer 2 concern and is not implied by these abstractions. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index d9caabbd5c1..e4b5a9a8cfa 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -5135,6 +5135,18 @@ { "Member": "static TService? Microsoft.Extensions.AI.DecisionClientExtensions.GetService(this Microsoft.Extensions.AI.IDecisionClient client, object? serviceKey = null);", "Stage": "Experimental" + }, + { + "Member": "static System.Threading.Tasks.Task> Microsoft.Extensions.AI.DecisionClientExtensions.GetResponseAsync(this Microsoft.Extensions.AI.IDecisionClient client, System.Text.Json.JsonElement state, Microsoft.Extensions.AI.DecisionDefinition definition, Microsoft.Extensions.AI.DecisionOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "static System.Threading.Tasks.Task> Microsoft.Extensions.AI.DecisionClientExtensions.GetResponseAsync(this Microsoft.Extensions.AI.IDecisionClient client, TState state, Microsoft.Extensions.AI.DecisionDefinition definition, Microsoft.Extensions.AI.DecisionOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "static System.Threading.Tasks.Task> Microsoft.Extensions.AI.DecisionClientExtensions.GetResponseAsync(this Microsoft.Extensions.AI.IDecisionClient client, TState state, System.Text.Json.Serialization.Metadata.JsonTypeInfo stateTypeInfo, Microsoft.Extensions.AI.DecisionDefinition definition, Microsoft.Extensions.AI.DecisionOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" } ] }, @@ -5678,6 +5690,72 @@ "Stage": "Experimental" } ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionDefinition", + "Stage": "Experimental", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.DecisionDefinition Microsoft.Extensions.AI.DecisionDefinition.Create(System.Action.Builder> configure, System.Text.Json.JsonSerializerOptions? serializerOptions = null);", + "Stage": "Experimental" + }, + { + "Member": "static Microsoft.Extensions.AI.DecisionDefinition Microsoft.Extensions.AI.DecisionDefinition.Create(System.Text.Json.Serialization.Metadata.JsonTypeInfo resultTypeInfo, System.Action.Builder> configure);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.DecisionDefinition.Questions { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Text.Json.Serialization.Metadata.JsonTypeInfo Microsoft.Extensions.AI.DecisionDefinition.ResultTypeInfo { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.DecisionDefinition.SerializerOptions { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionDefinition.Builder", + "Stage": "Experimental", + "Methods": [ + { + "Member": "void Microsoft.Extensions.AI.DecisionDefinition.Builder.BinaryProbability(System.Linq.Expressions.Expression> selector, string? instructions = null);", + "Stage": "Experimental" + }, + { + "Member": "void Microsoft.Extensions.AI.DecisionDefinition.Builder.Choice(System.Linq.Expressions.Expression> selector, string? instructions = null);", + "Stage": "Experimental" + }, + { + "Member": "void Microsoft.Extensions.AI.DecisionDefinition.Builder.Score(System.Linq.Expressions.Expression> selector, System.Collections.Generic.IReadOnlyList levels, string? instructions = null);", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.DecisionResponse", + "Stage": "Experimental", + "Methods": [ + { + "Member": "System.Collections.Generic.IReadOnlyDictionary Microsoft.Extensions.AI.DecisionResponse.GetDistribution(System.Linq.Expressions.Expression> selector);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.DecisionResponse Microsoft.Extensions.AI.DecisionResponse.Evidence { get; }", + "Stage": "Experimental" + }, + { + "Member": "TResult Microsoft.Extensions.AI.DecisionResponse.Result { get; }", + "Stage": "Experimental" + } + ] } ] -} \ No newline at end of file +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs index 33db2a5916e..5b1ac563010 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs @@ -3,8 +3,10 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -301,6 +303,203 @@ public void OptionsClone_OwnsProviderExtensionSnapshot() Assert.Equal("original", clone.AdditionalProperties!["mode"]); } + [Fact] + public void DecisionDefinition_UsesExplicitDeclarationsAndMetadataDescriptions() + { + DecisionDefinition definition = DecisionDefinition.Create( + TestJsonSerializerContext.Default.TypedDecisionResult, + builder => + { + builder.Choice(result => result.Category); + builder.BinaryProbability(result => result.RefundRequestProbability, "Explicit refund instruction."); + builder.Score( + result => result.Satisfaction, + [ + new("low", "Low"), + new("medium", "Medium"), + new("high", "High"), + ]); + }); + + Assert.Equal(["category", "refundRequestProbability", "satisfaction"], definition.Questions.Select(static q => q.Id)); + Assert.Equal("Classify the customer's main concern.", definition.Questions[0].Instructions); + Assert.Equal("Explicit refund instruction.", definition.Questions[1].Instructions); + Assert.Equal("Rate the customer's satisfaction.", definition.Questions[2].Instructions); + + ChoiceDecisionQuestion category = Assert.IsType(definition.Questions[0]); + Assert.Equal(["Billing", "Technical", "Account"], category.Candidates.Select(static candidate => candidate.Id)); + Assert.Equal( + ["Payment, invoice, or charge concerns.", "Product defects, errors, outages, or troubleshooting.", "Sign-in, profile, or account-access concerns."], + category.Candidates.Select(static candidate => candidate.Description)); + } + + [Fact] + public async Task DecisionDefinition_BindsTypedResultOnceAndRetainsEvidence() + { + DecisionDefinition definition = CreateTypedDefinition(); + DecisionResponse providerResponse = CreateTypedDefinitionResponse(); + using RecordingDecisionClient client = new(providerResponse); + DecisionOptions options = new() { ModelId = "model" }; + + DecisionResponse response = await client.GetResponseAsync( + new TypedDecisionState("I need a refund for a defective product."), + TestJsonSerializerContext.Default.TypedDecisionState, + definition, + options); + + Assert.Equal(1, client.CallCount); + Assert.Same(options, client.Options); + Assert.Equal(TicketCategory.Technical, response.Result.Category); + Assert.Equal(0.25, response.Result.RefundRequestProbability); + Assert.Equal(1.58, response.Result.Satisfaction); + Assert.Same(providerResponse, response.Evidence); + Assert.Equal( + new Dictionary + { + [TicketCategory.Billing] = 0.2, + [TicketCategory.Technical] = 0.5, + [TicketCategory.Account] = 0.3, + }, + response.GetDistribution(result => result.Category)); + } + + [Fact] + public async Task DecisionDefinition_InferredStateOverloadUsesOneProviderInvocation() + { + DecisionDefinition definition = CreateTypedDefinition(); + using RecordingDecisionClient client = new(CreateTypedDefinitionResponse()); + + DecisionResponse response = await client.GetResponseAsync( + new TypedDecisionState("Please reverse this payment."), + definition, + cancellationToken: CancellationToken.None); + + Assert.Equal(TicketCategory.Technical, response.Result.Category); + Assert.Equal(1, client.CallCount); + } + + [Fact] + public void DecisionDefinition_RejectsNestedDuplicateAndFlagsSelectors() + { + Assert.Throws(() => DecisionDefinition.Create( + TestJsonSerializerContext.Default.TypedDecisionResult, + builder => builder.Choice(result => result.Nested.Category))); + + Assert.Throws(() => DecisionDefinition.Create( + TestJsonSerializerContext.Default.TypedDecisionResult, + builder => + { + builder.BinaryProbability(result => result.RefundRequestProbability); + builder.Score(result => result.RefundRequestProbability, [new("low", "Low"), new("high", "High")]); + })); + + Assert.Throws(() => DecisionDefinition.Create( + TestJsonSerializerContext.Default.FlagsResult, + builder => builder.Choice(result => result.Value))); + } + + [Fact] + public void DecisionDefinition_UsesPropertyDescriptionAndFluentPrecedence() + { + DecisionDefinition propertyDefinition = DecisionDefinition.Create( + TestJsonSerializerContext.Default.PropertyDescriptionResult, + builder => builder.BinaryProbability(result => result.Probability)); + Assert.Equal("Description on the property.", propertyDefinition.Questions[0].Instructions); + + DecisionDefinition overrideDefinition = DecisionDefinition.Create( + TestJsonSerializerContext.Default.PropertyDescriptionResult, + builder => builder.BinaryProbability(result => result.Probability, "Fluent instruction.")); + Assert.Equal("Fluent instruction.", overrideDefinition.Questions[0].Instructions); + } + + [Fact] + public void DecisionDefinition_UsesExactConfiguredJsonPropertyName() + { + DecisionDefinition definition = DecisionDefinition.Create( + TestJsonSerializerContext.Default.JsonNamedResult, + builder => builder.BinaryProbability(result => result.Probability)); + + Assert.Equal("refund_probability", definition.Questions[0].Id); + } + + [Fact] + public async Task DecisionDefinition_PropagatesCancellationWithoutFabricatedTypedResult() + { + DecisionDefinition definition = CreateTypedDefinition(); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + using RecordingDecisionClient client = new(CreateTypedDefinitionResponse(), cancel: true); + + await Assert.ThrowsAnyAsync(() => + client.GetResponseAsync( + new TypedDecisionState("cancelled"), + TestJsonSerializerContext.Default.TypedDecisionState, + definition, + cancellationToken: cancellation.Token)); + Assert.Equal(1, client.CallCount); + } + + private static DecisionDefinition CreateTypedDefinition() => + DecisionDefinition.Create( + TestJsonSerializerContext.Default.TypedDecisionResult, + builder => + { + builder.Choice(result => result.Category); + builder.BinaryProbability(result => result.RefundRequestProbability); + builder.Score( + result => result.Satisfaction, + [ + new("low", "Low"), + new("medium", "Medium"), + new("high", "High"), + ]); + }); + + private static DecisionResponse CreateTypedDefinitionResponse() + { + DecisionRequest request = CreateRequest( + new ChoiceDecisionQuestion( + "category", + "Classify the customer's main concern.", + [ + new("Billing", "Payment, invoice, or charge concerns."), + new("Technical", "Product defects, errors, outages, or troubleshooting."), + new("Account", "Sign-in, profile, or account-access concerns."), + ]), + new BinaryDecisionQuestion("refundRequestProbability", "Is the customer requesting a refund or payment reversal?"), + new ScoreDecisionQuestion( + "satisfaction", + "Rate the customer's satisfaction.", + [ + new("low", "Low"), + new("medium", "Medium"), + new("high", "High"), + ])); + + return new DecisionResponse( + request, + [ + new ChoiceDecisionAnswer( + "category", + "Technical", + [ + new("Billing", 0.2), + new("Technical", 0.5), + new("Account", 0.3), + ]), + new BinaryDecisionAnswer("refundRequestProbability", 0.25), + new ScoreDecisionAnswer( + "satisfaction", + 1.58, + [ + new("low", 0.1), + new("medium", 0.2), + new("high", 0.7), + ], + DecisionPrecision.TwoDecimalPlaces), + ]); + } + private static DecisionRequest CreateRequest(params DecisionQuestion[] questions) { using JsonDocument document = JsonDocument.Parse("{}"); @@ -365,6 +564,55 @@ internal enum Classification Rejected, } + internal enum TicketCategory + { + [Description("Payment, invoice, or charge concerns.")] + Billing, + + [Description("Product defects, errors, outages, or troubleshooting.")] + Technical, + + [Description("Sign-in, profile, or account-access concerns.")] + Account, + } + + [Flags] + internal enum FlagCategory + { + None = 0, + First = 1, + Second = 2, + } + + internal sealed record TypedDecisionResult( + [Description("Classify the customer's main concern.")] TicketCategory Category, + [Description("Is the customer requesting a refund or payment reversal?")] double RefundRequestProbability, + [Description("Rate the customer's satisfaction.")] double Satisfaction) + { + public NestedResult Nested { get; } = new(); + } + + internal sealed record NestedResult + { + public TicketCategory Category { get; init; } + } + + internal sealed record TypedDecisionState(string Message); + + internal sealed record PropertyDescriptionResult + { + [Description("Description on the property.")] + public double Probability { get; init; } + } + + internal sealed record JsonNamedResult + { + [JsonPropertyName("refund_probability")] + public double Probability { get; init; } + } + + internal sealed record FlagsResult(FlagCategory Value); + internal sealed class DecisionState { public string? Value { get; set; } @@ -378,6 +626,43 @@ internal sealed class DecisionResult public double ExpectedScore { get; set; } } + internal sealed class RecordingDecisionClient : IDecisionClient + { + private readonly DecisionResponse _response; + private readonly bool _cancel; + + public RecordingDecisionClient(DecisionResponse response, bool cancel = false) + { + _response = response; + _cancel = cancel; + } + + public int CallCount { get; private set; } + + public DecisionOptions? Options { get; private set; } + + public Task GetResponseAsync( + DecisionRequest request, + DecisionOptions? options = null, + CancellationToken cancellationToken = default) + { + CallCount++; + Options = options; + if (_cancel) + { + return Task.FromCanceled(cancellationToken); + } + + return Task.FromResult(_response); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } + private sealed class CancellationClient : IDecisionClient { public int CallCount { get; private set; } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs index 9c7d824dafa..de0e2761ecd 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs @@ -69,6 +69,14 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(ScoreDecisionAnswer))] [JsonSerializable(typeof(DecisionProbability))] [JsonSerializable(typeof(DecisionTests.Classification))] +[JsonSerializable(typeof(DecisionTests.TicketCategory))] +[JsonSerializable(typeof(DecisionTests.FlagCategory))] [JsonSerializable(typeof(DecisionTests.DecisionState))] [JsonSerializable(typeof(DecisionTests.DecisionResult))] +[JsonSerializable(typeof(DecisionTests.TypedDecisionState))] +[JsonSerializable(typeof(DecisionTests.TypedDecisionResult))] +[JsonSerializable(typeof(DecisionTests.NestedResult))] +[JsonSerializable(typeof(DecisionTests.PropertyDescriptionResult))] +[JsonSerializable(typeof(DecisionTests.JsonNamedResult))] +[JsonSerializable(typeof(DecisionTests.FlagsResult))] internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; From d617343d133e6ec94e5f5910288ab56b3d4470e1 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Thu, 1 Oct 2026 11:14:10 -0400 Subject: [PATCH 06/11] Fix typed decision response correlation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b735e048-cd3e-4bfb-ad05-3f68ad46082e --- .../Decisions/DecisionClient.cs | 91 ++++- .../Decisions/DecisionDefinitions.cs | 82 ++++- .../Decisions/README.md | 18 +- .../Decisions/DecisionTests.cs | 310 +++++++++++++++--- .../TestJsonSerializerContext.cs | 6 + 5 files changed, 461 insertions(+), 46 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs index 8ad92d61d0b..11fc1ad7500 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs @@ -204,11 +204,12 @@ public static async Task> GetResponseAsync( _ = Throw.IfNull(client); _ = Throw.IfNull(definition); + DecisionRequest request = new(state, definition.Questions); DecisionResponse response = await client.GetResponseAsync( - new DecisionRequest(state, definition.Questions), + request, options, cancellationToken).ConfigureAwait(false); - return definition.Bind(response); + return definition.Bind(response, request); } /// Evaluates typed application state using a reusable typed decision definition. @@ -408,6 +409,92 @@ public DecisionAnswer GetAnswer(string questionId) 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 || diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs index 8d98889d53f..4d5e2765130 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs @@ -88,9 +88,10 @@ public static DecisionDefinition Create( return builder.Build(); } - internal DecisionResponse Bind(DecisionResponse response) + internal DecisionResponse Bind(DecisionResponse response, DecisionRequest expectedRequest) { _ = Throw.IfNull(response); + response.ValidateAgainst(expectedRequest); using MemoryStream stream = new(); using (Utf8JsonWriter writer = new(stream)) @@ -123,10 +124,11 @@ internal DecisionResponse Bind(DecisionResponse response) writer.WriteEndObject(); } + byte[] resultJson = stream.ToArray(); TResult result; try { - result = JsonSerializer.Deserialize(stream.ToArray(), ResultTypeInfo)!; + result = JsonSerializer.Deserialize(resultJson, ResultTypeInfo)!; } catch (Exception exception) when (exception is JsonException or NotSupportedException) { @@ -140,6 +142,33 @@ internal DecisionResponse Bind(DecisionResponse response) throw new DecisionProtocolException("The decision response produced a null result."); } + try + { + JsonElement materializedJson = JsonSerializer.SerializeToElement(result, ResultTypeInfo); + using JsonDocument expectedJson = JsonDocument.Parse(resultJson); + + foreach (DecisionDefinitionQuestion question in _questions) + { + if (!expectedJson.RootElement.TryGetProperty(question.PropertyName, out JsonElement expectedValue) || + !materializedJson.TryGetProperty(question.PropertyName, out JsonElement materializedValue) || + !JsonElement.DeepEquals(expectedValue, materializedValue)) + { + throw new DecisionProtocolException( + $"The decision response value for result property '{question.Property.Name}' was not preserved by the configured JSON contract."); + } + } + } + catch (DecisionProtocolException) + { + throw; + } + catch (Exception exception) when (exception is JsonException or NotSupportedException) + { + throw new DecisionProtocolException( + "The decision response could not be verified against the configured result contract.", + exception); + } + return new DecisionResponse(result, response, this); } @@ -215,7 +244,8 @@ public sealed class Builder private static DecisionEnumDefinition CreateEnumDefinition<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] TEnum>( PropertyInfo property, - JsonTypeInfo resultTypeInfo) + JsonTypeInfo resultTypeInfo, + JsonPropertyInfo jsonProperty) where TEnum : struct, Enum { if (property.PropertyType.IsDefined(typeof(FlagsAttribute), inherit: false)) @@ -223,6 +253,13 @@ public sealed class Builder 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 { @@ -324,6 +361,40 @@ private static void EnsurePropertyCanBeDeclared(PropertyInfo property, Type expe } } +#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) @@ -358,8 +429,9 @@ internal Builder(JsonTypeInfo resultTypeInfo) PropertyInfo property = GetDirectProperty(selector); EnsurePropertyCanBeDeclared(property, typeof(TEnum)); - DecisionEnumDefinition enumDefinition = CreateEnumDefinition(property, _resultTypeInfo); JsonPropertyInfo jsonProperty = GetJsonProperty(_resultTypeInfo, property); + EnsurePropertyCanBeMaterialized(property, jsonProperty); + DecisionEnumDefinition enumDefinition = CreateEnumDefinition(property, _resultTypeInfo, jsonProperty); AddQuestion( property, jsonProperty.Name, @@ -379,6 +451,7 @@ public void BinaryProbability( EnsurePropertyCanBeDeclared(property, typeof(double)); JsonPropertyInfo jsonProperty = GetJsonProperty(_resultTypeInfo, property); + EnsurePropertyCanBeMaterialized(property, jsonProperty); AddQuestion( property, jsonProperty.Name, @@ -401,6 +474,7 @@ public void Score( _ = Throw.IfNull(levels); JsonPropertyInfo jsonProperty = GetJsonProperty(_resultTypeInfo, property); + EnsurePropertyCanBeMaterialized(property, jsonProperty); AddQuestion( property, jsonProperty.Name, diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index d237f4dc1eb..4cdb0d06c19 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -100,6 +100,20 @@ DecisionResponse analyzed = await client.GetResponseAsync analyzedFromJson = await client.GetResponseAsync( + stateJson, + definition, + cancellationToken: cancellationToken); +``` + Applications that only need the neutral response can keep the no-binding path: ```csharp @@ -132,7 +146,7 @@ The returned `Evidence` is the complete `DecisionResponse`, including every cate ## .NET platform reuse / ergonomics under review -Layer 1 intentionally reuses existing .NET and MEAI primitives rather than adding a builder, `IQueryable`, ORM, or new source-generator dependency: +Layer 1 intentionally reuses existing .NET and MEAI primitives rather than adding `IQueryable`, an ORM, or a new source-generator dependency: - [`JsonSchemaExporter`](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/extract-schema) was introduced in .NET 9. The decision contracts use `JsonTypeInfo` and serializer options for explicit metadata; they do not add another schema exporter, and `AIJsonUtilities` provides the existing MEAI compatibility helpers where applicable. - [System.Text.Json source generation](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/source-generation) is the AOT-friendly way to supply `JsonTypeInfo`. `JsonTypeInfo` itself is not synonymous with generated code; ordinary JIT reflection through a configured resolver remains a platform option. @@ -141,6 +155,6 @@ Layer 1 intentionally reuses existing .NET and MEAI primitives rather than addin - [Structured output](https://learn.microsoft.com/dotnet/ai/quickstarts/structured-output) already supports enum/record-shaped chat results. If an application only needs a label or JSON result, `IChatClient.GetResponseAsync` may be the simpler choice; that path does not by itself establish a provider-reported probability distribution. - [EF Core model conventions and explicit overrides](https://learn.microsoft.com/ef/core/modeling/) are useful precedent for bounded opt-in conventions, not a reason to add an EF dependency or `DbContext`. [EF Core query providers](https://learn.microsoft.com/ef/core/querying/) likewise do not justify an `IQueryable` decision API for explicit paid asynchronous model inference; ordinary LINQ projection over returned probabilities remains appropriate. -Automated arbitrary semantic inference is out. Bounded opt-in conventions for known enum naming or member mappings remain possible future ergonomics if they are explicit, source-generation/AOT-compatible, and do not hide provider-facing identities or application-owned result mapping. +Automated arbitrary semantic inference is out. This layer already uses bounded opt-in conventions for JSON enum naming and standard member descriptions; future conventions for known enum/member mappings remain reasonable only when explicit, source-generation/AOT-compatible, and unable to hide provider-facing identities or application-owned result mapping. This layer intentionally does not define tools, routing composition, MEDI processors, provider adapters, ML.NET or Arrow integrations, or automatic arbitrary POCO/union semantic inference. AIFunction/tool interoperability is a Layer 2 concern and is not implied by these abstractions. diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs index 5b1ac563010..129d7710f88 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs @@ -337,8 +337,7 @@ public void DecisionDefinition_UsesExplicitDeclarationsAndMetadataDescriptions() public async Task DecisionDefinition_BindsTypedResultOnceAndRetainsEvidence() { DecisionDefinition definition = CreateTypedDefinition(); - DecisionResponse providerResponse = CreateTypedDefinitionResponse(); - using RecordingDecisionClient client = new(providerResponse); + using RecordingDecisionClient client = new(CreateTypedDefinitionResponse); DecisionOptions options = new() { ModelId = "model" }; DecisionResponse response = await client.GetResponseAsync( @@ -352,7 +351,7 @@ public async Task DecisionDefinition_BindsTypedResultOnceAndRetainsEvidence() Assert.Equal(TicketCategory.Technical, response.Result.Category); Assert.Equal(0.25, response.Result.RefundRequestProbability); Assert.Equal(1.58, response.Result.Satisfaction); - Assert.Same(providerResponse, response.Evidence); + Assert.Equal("test-provider", response.Evidence.Provenance!.ProviderName); Assert.Equal( new Dictionary { @@ -367,7 +366,7 @@ public async Task DecisionDefinition_BindsTypedResultOnceAndRetainsEvidence() public async Task DecisionDefinition_InferredStateOverloadUsesOneProviderInvocation() { DecisionDefinition definition = CreateTypedDefinition(); - using RecordingDecisionClient client = new(CreateTypedDefinitionResponse()); + using RecordingDecisionClient client = new(CreateTypedDefinitionResponse); DecisionResponse response = await client.GetResponseAsync( new TypedDecisionState("Please reverse this payment."), @@ -428,7 +427,7 @@ public async Task DecisionDefinition_PropagatesCancellationWithoutFabricatedType DecisionDefinition definition = CreateTypedDefinition(); using CancellationTokenSource cancellation = new(); cancellation.Cancel(); - using RecordingDecisionClient client = new(CreateTypedDefinitionResponse(), cancel: true); + using RecordingDecisionClient client = new(CreateTypedDefinitionResponse, cancel: true); await Assert.ThrowsAnyAsync(() => client.GetResponseAsync( @@ -439,6 +438,91 @@ await Assert.ThrowsAnyAsync(() => Assert.Equal(1, client.CallCount); } + [Theory] + [InlineData("case")] + [InlineData("stale")] + [InlineData("missing")] + [InlineData("reordered")] + [InlineData("extra")] + [InlineData("wrong-kind")] + public async Task DecisionDefinition_RejectsResponseNotMatchingSentRequest(string mutation) + { + DecisionDefinition definition = CreateTypedDefinition(); + using RecordingDecisionClient client = new(request => CreateMismatchedResponse(request, mutation)); + + await Assert.ThrowsAsync(() => client.GetResponseAsync( + new TypedDecisionState("request"), + TestJsonSerializerContext.Default.TypedDecisionState, + definition)); + } + + [Fact] + public async Task DecisionDefinition_AllowsEquivalentOwnedResponseSnapshots() + { + DecisionDefinition definition = CreateTypedDefinition(); + using RecordingDecisionClient client = new(request => CreateTypedDefinitionResponse(CloneRequest(request))); + + DecisionResponse response = await client.GetResponseAsync( + new TypedDecisionState("request"), + TestJsonSerializerContext.Default.TypedDecisionState, + definition); + + Assert.Equal(TicketCategory.Technical, response.Result.Category); + } + + [Fact] + public void DecisionDefinition_RejectsReadOnlyUnboundProperties() + { + Assert.Throws(() => DecisionDefinition.Create( + TestJsonSerializerContext.Default.ReadOnlyResult, + builder => builder.BinaryProbability(result => result.Probability))); + } + + [Fact] + public async Task DecisionDefinition_RejectsRequiredUnmappedProperties() + { + DecisionDefinition definition = DecisionDefinition.Create( + TestJsonSerializerContext.Default.RequiredUnmappedResult, + builder => builder.BinaryProbability(result => result.Probability)); + using RecordingDecisionClient client = new(request => new DecisionResponse( + request, + [new BinaryDecisionAnswer(request.Questions[0].Id, 0.25)])); + + await Assert.ThrowsAsync(() => client.GetResponseAsync( + new TypedDecisionState("request"), + definition)); + } + + [Fact] + public void DecisionDefinition_RejectsPropertySpecificEnumConverters() + { + Assert.Throws(() => DecisionDefinition.Create( + TestJsonSerializerContext.Default.PropertyConverterResult, + builder => builder.Choice(result => result.Category))); + } + + [Fact] + public async Task DecisionDefinition_UsesConfiguredEnumConverterForExactCandidateIds() + { + DecisionDefinition definition = DecisionDefinition.Create( + TestJsonSerializerContext.Default.CustomEnumResult, + builder => builder.Choice(result => result.Category)); + ChoiceDecisionQuestion question = Assert.IsType(definition.Questions[0]); + Assert.Equal(["billing-id", "technical-id", "account-id"], question.Candidates.Select(static candidate => candidate.Id)); + + using RecordingDecisionClient client = new(request => new DecisionResponse( + request, + [new ChoiceDecisionAnswer( + request.Questions[0].Id, + "technical-id", + [new("billing-id", 0.2), new("technical-id", 0.5), new("account-id", 0.3)])])); + + DecisionResponse response = await client.GetResponseAsync( + new JsonElementState(), + definition); + Assert.Equal(CustomCategory.Technical, response.Result.Category); + } + private static DecisionDefinition CreateTypedDefinition() => DecisionDefinition.Create( TestJsonSerializerContext.Default.TypedDecisionResult, @@ -455,51 +539,142 @@ private static DecisionDefinition CreateTypedDefinition() = ]); }); - private static DecisionResponse CreateTypedDefinitionResponse() + private static DecisionResponse CreateTypedDefinitionResponse(DecisionRequest request) { - DecisionRequest request = CreateRequest( - new ChoiceDecisionQuestion( - "category", - "Classify the customer's main concern.", - [ - new("Billing", "Payment, invoice, or charge concerns."), - new("Technical", "Product defects, errors, outages, or troubleshooting."), - new("Account", "Sign-in, profile, or account-access concerns."), - ]), - new BinaryDecisionQuestion("refundRequestProbability", "Is the customer requesting a refund or payment reversal?"), - new ScoreDecisionQuestion( - "satisfaction", - "Rate the customer's satisfaction.", - [ - new("low", "Low"), - new("medium", "Medium"), - new("high", "High"), - ])); - return new DecisionResponse( request, [ new ChoiceDecisionAnswer( - "category", - "Technical", + request.Questions[0].Id, + ((ChoiceDecisionQuestion)request.Questions[0]).Candidates[1].Id, [ - new("Billing", 0.2), - new("Technical", 0.5), - new("Account", 0.3), + new(((ChoiceDecisionQuestion)request.Questions[0]).Candidates[0].Id, 0.2), + new(((ChoiceDecisionQuestion)request.Questions[0]).Candidates[1].Id, 0.5), + new(((ChoiceDecisionQuestion)request.Questions[0]).Candidates[2].Id, 0.3), ]), - new BinaryDecisionAnswer("refundRequestProbability", 0.25), + new BinaryDecisionAnswer(request.Questions[1].Id, 0.25), new ScoreDecisionAnswer( - "satisfaction", + request.Questions[2].Id, 1.58, [ - new("low", 0.1), - new("medium", 0.2), - new("high", 0.7), + new(((ScoreDecisionQuestion)request.Questions[2]).Levels[0].Id, 0.1), + new(((ScoreDecisionQuestion)request.Questions[2]).Levels[1].Id, 0.2), + new(((ScoreDecisionQuestion)request.Questions[2]).Levels[2].Id, 0.7), ], DecisionPrecision.TwoDecimalPlaces), + ], + new DecisionProvenance(providerName: "test-provider")); + } + + private static DecisionResponse CreateMismatchedResponse(DecisionRequest request, string mutation) + { + if (mutation == "wrong-kind") + { + DecisionRequest wrongKindRequest = new( + request.State, + [ + new BinaryDecisionQuestion(request.Questions[0].Id, request.Questions[0].Instructions), + request.Questions[1], + request.Questions[2], + ]); + return new DecisionResponse( + wrongKindRequest, + [ + new BinaryDecisionAnswer(wrongKindRequest.Questions[0].Id, 0.5), + new BinaryDecisionAnswer(wrongKindRequest.Questions[1].Id, 0.25), + new ScoreDecisionAnswer( + wrongKindRequest.Questions[2].Id, + 1.58, + [new("low", 0.1), new("medium", 0.2), new("high", 0.7)], + DecisionPrecision.TwoDecimalPlaces), + ]); + } + + ChoiceDecisionQuestion original = (ChoiceDecisionQuestion)request.Questions[0]; + IReadOnlyList candidates = mutation switch + { + "case" => + [ + new("Billing", "Payment, invoice, or charge concerns."), + new("technical", "Product defects, errors, outages, or troubleshooting."), + new("Account", "Sign-in, profile, or account-access concerns."), + ], + "stale" => + [ + new("Billing", "Old billing instruction."), + new("Technical", "Old technical instruction."), + new("Account", "Old account instruction."), + ], + "missing" => + [ + original.Candidates[0], + original.Candidates[1], + ], + "reordered" => + [ + original.Candidates[1], + original.Candidates[0], + original.Candidates[2], + ], + "extra" => + [ + original.Candidates[0], + original.Candidates[1], + original.Candidates[2], + new("Other", "Other"), + ], + _ => throw new ArgumentOutOfRangeException(nameof(mutation)), + }; + + DecisionQuestion changedChoice = mutation == "stale" + ? new ChoiceDecisionQuestion(original.Id, "A stale instruction.", candidates) + : new ChoiceDecisionQuestion(original.Id, original.Instructions, candidates); + DecisionRequest changedRequest = new( + request.State, + [changedChoice, request.Questions[1], request.Questions[2]]); + return CreateResponseForRequest(changedRequest); + } + + private static DecisionResponse CreateResponseForRequest(DecisionRequest request) + { + ChoiceDecisionQuestion choice = (ChoiceDecisionQuestion)request.Questions[0]; + ScoreDecisionQuestion score = (ScoreDecisionQuestion)request.Questions[2]; + return new DecisionResponse( + request, + [ + new ChoiceDecisionAnswer( + choice.Id, + choice.Candidates[0].Id, + choice.Candidates.Select(static (candidate, index) => new DecisionProbability(candidate.Id, index == 0 ? 1 : 0)).ToArray()), + new BinaryDecisionAnswer(request.Questions[1].Id, 0.25), + new ScoreDecisionAnswer( + score.Id, + 1.58, + score.Levels.Select(static (level, index) => new DecisionProbability(level.Id, index == 1 ? 1 : 0)).ToArray(), + DecisionPrecision.TwoDecimalPlaces), ]); } + private static DecisionRequest CloneRequest(DecisionRequest request) => + new( + request.State.Clone(), + request.Questions.Select(CloneQuestion).ToArray()); + + private static DecisionQuestion CloneQuestion(DecisionQuestion question) => + question switch + { + BinaryDecisionQuestion binary => new BinaryDecisionQuestion(binary.Id, binary.Instructions, binary.TrueDescription, binary.FalseDescription), + ChoiceDecisionQuestion choice => new ChoiceDecisionQuestion( + choice.Id, + choice.Instructions, + choice.Candidates.Select(static candidate => new DecisionCandidate(candidate.Id, candidate.Description)).ToArray()), + ScoreDecisionQuestion score => new ScoreDecisionQuestion( + score.Id, + score.Instructions, + score.Levels.Select(static level => new DecisionScoreLevel(level.Id, level.Description)).ToArray()), + _ => throw new ArgumentOutOfRangeException(nameof(question)), + }; + private static DecisionRequest CreateRequest(params DecisionQuestion[] questions) { using JsonDocument document = JsonDocument.Parse("{}"); @@ -611,6 +786,60 @@ internal sealed record JsonNamedResult public double Probability { get; init; } } + internal sealed class ReadOnlyResult + { + public double Probability { get; } + } + + internal sealed class RequiredUnmappedResult + { + public double Probability { get; set; } + + [JsonRequired] + public string Required { get; set; } = null!; + } + + [JsonConverter(typeof(CustomCategoryConverter))] + internal enum CustomCategory + { + [Description("Billing category.")] + Billing, + + [Description("Technical category.")] + Technical, + + [Description("Account category.")] + Account, + } + + internal sealed record CustomEnumResult(CustomCategory Category); + + internal sealed record PropertyConverterResult( + [property: JsonConverter(typeof(CustomCategoryConverter))] CustomCategory Category); + + internal sealed record JsonElementState; + + internal sealed class CustomCategoryConverter : JsonConverter + { + public override CustomCategory Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.GetString() switch + { + "billing-id" => CustomCategory.Billing, + "technical-id" => CustomCategory.Technical, + "account-id" => CustomCategory.Account, + _ => throw new JsonException(), + }; + + public override void Write(Utf8JsonWriter writer, CustomCategory value, JsonSerializerOptions options) => + writer.WriteStringValue(value switch + { + CustomCategory.Billing => "billing-id", + CustomCategory.Technical => "technical-id", + CustomCategory.Account => "account-id", + _ => throw new JsonException(), + }); + } + internal sealed record FlagsResult(FlagCategory Value); internal sealed class DecisionState @@ -628,12 +857,17 @@ internal sealed class DecisionResult internal sealed class RecordingDecisionClient : IDecisionClient { - private readonly DecisionResponse _response; + private readonly Func _responseFactory; private readonly bool _cancel; public RecordingDecisionClient(DecisionResponse response, bool cancel = false) + : this(_ => response, cancel) + { + } + + public RecordingDecisionClient(Func responseFactory, bool cancel = false) { - _response = response; + _responseFactory = responseFactory; _cancel = cancel; } @@ -653,7 +887,7 @@ public Task GetResponseAsync( return Task.FromCanceled(cancellationToken); } - return Task.FromResult(_response); + return Task.FromResult(_responseFactory(request)); } public object? GetService(Type serviceType, object? serviceKey = null) => null; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs index de0e2761ecd..cac7ea1e919 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs @@ -79,4 +79,10 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(DecisionTests.PropertyDescriptionResult))] [JsonSerializable(typeof(DecisionTests.JsonNamedResult))] [JsonSerializable(typeof(DecisionTests.FlagsResult))] +[JsonSerializable(typeof(DecisionTests.ReadOnlyResult))] +[JsonSerializable(typeof(DecisionTests.RequiredUnmappedResult))] +[JsonSerializable(typeof(DecisionTests.CustomCategory))] +[JsonSerializable(typeof(DecisionTests.CustomEnumResult))] +[JsonSerializable(typeof(DecisionTests.PropertyConverterResult))] +[JsonSerializable(typeof(DecisionTests.JsonElementState))] internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; From ac4972034b9eed31377af9e323206c8a1b68e709 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Thu, 1 Oct 2026 11:37:08 -0400 Subject: [PATCH 07/11] Simplify decision usage examples Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b735e048-cd3e-4bfb-ad05-3f68ad46082e --- .../Decisions/README.md | 65 ++++++------------- 1 file changed, 21 insertions(+), 44 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index 4cdb0d06c19..fd21a8c83fb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -12,10 +12,6 @@ The domain types remain ordinary application-owned types. The standard `Descript #pragma warning disable MEAI001 using System.ComponentModel; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; public sealed record SupportTicket(string Message); @@ -36,45 +32,29 @@ public sealed record TicketAnalysis( [Description("Is the customer requesting a refund or payment reversal?")] double RefundRequestProbability); ``` -The business call defines a reusable, explicit declaration once. `Choice` declares the closed enum domain and uses its configured JSON enum names as exact candidate IDs; `BinaryProbability` maps the binary answer's `TrueProbability` to a `double`. No handwritten question IDs, enum dictionary, result mapper, JSON schema, or feature schema is required for this ordinary flow. The client and cancellation token are supplied and owned by the host: +The business call is ordinary application code. Assume the host supplies the `IDecisionClient client`; the declaration is reusable and explicit. `Choice` declares the closed enum domain and uses its configured JSON enum names as exact candidate IDs; `BinaryProbability` maps the binary answer's `TrueProbability` to a `double`. No handwritten question IDs, enum dictionary, result mapper, JSON schema, or feature schema is required for this ordinary flow: ```csharp -public static class SupportTicketAnalyzer -{ - private static readonly DecisionDefinition Definition = - DecisionDefinition.Create(definition => - { - definition.Choice(result => result.Category); - definition.BinaryProbability(result => result.RefundRequestProbability); - }); - - public static async Task> AnalyzeAsync( - IDecisionClient client, - SupportTicket ticket, - CancellationToken cancellationToken) - { - return await client.GetResponseAsync( - ticket, - Definition, - cancellationToken: cancellationToken); - } -} -``` +using Microsoft.Extensions.AI; -The typed response exposes both the application result and the complete original evidence: +var definition = DecisionDefinition.Create(builder => +{ + builder.Choice(result => result.Category); + builder.BinaryProbability(result => result.RefundRequestProbability); +}); -```csharp -DecisionResponse analyzed = await SupportTicketAnalyzer.AnalyzeAsync(client, ticket, cancellationToken); -TicketAnalysis result = analyzed.Result; -IReadOnlyDictionary categoryDistribution = - analyzed.GetDistribution(value => value.Category); -DecisionResponse evidence = analyzed.Evidence; +var ticket = new SupportTicket("I was charged twice. Please refund the duplicate payment."); +var response = await client.GetResponseAsync(ticket, definition); +TicketAnalysis analysis = response.Result; +DecisionResponse evidence = response.Evidence; +var categoryDistribution = + response.GetDistribution(value => value.Category); ``` The same definition can include an explicitly ordered score rubric, and heterogeneous declarations remain independent: ```csharp -DecisionDefinition definition = DecisionDefinition.Create(builder => +var definitionWithScore = DecisionDefinition.Create(builder => { builder.Choice(result => result.Category, "Classify the customer's main concern."); builder.BinaryProbability(result => result.RefundRequestProbability); @@ -86,18 +66,17 @@ DecisionDefinition definition = DecisionDefinition definition = +var metadataDefinition = DecisionDefinition.Create(TicketJsonContext.Default.TicketAnalysis, builder => { builder.Choice(result => result.Category); builder.BinaryProbability(result => result.RefundRequestProbability); }); -DecisionResponse analyzed = await client.GetResponseAsync( +var metadataResponse = await client.GetResponseAsync( ticket, TicketJsonContext.Default.SupportTicket, - definition, - cancellationToken: cancellationToken); + metadataDefinition); ``` When the application already has a JSON state snapshot, the result-only overload keeps the same local binding behavior without re-serializing the state: @@ -108,20 +87,18 @@ using System.Text.Json; JsonElement stateJson = JsonSerializer.SerializeToElement( ticket, TicketJsonContext.Default.SupportTicket); -DecisionResponse analyzedFromJson = await client.GetResponseAsync( +var analyzedFromJson = await client.GetResponseAsync( stateJson, - definition, - cancellationToken: cancellationToken); + metadataDefinition); ``` Applications that only need the neutral response can keep the no-binding path: ```csharp -DecisionResponse evidence = await client.GetResponseAsync( +var evidenceOnly = await client.GetResponseAsync( ticket, TicketJsonContext.Default.SupportTicket, - definition.Questions, - cancellationToken: cancellationToken); + metadataDefinition.Questions); ``` The JSON metadata setup is separate from the business call: From a067c99725f530d9e36285ddbde88feca11b0043 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Thu, 1 Oct 2026 11:40:58 -0400 Subject: [PATCH 08/11] Tidy decision usage sample spacing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b735e048-cd3e-4bfb-ad05-3f68ad46082e --- .../Decisions/README.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index fd21a8c83fb..3f78b7edec8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -43,12 +43,19 @@ var definition = DecisionDefinition.Create(builder => builder.BinaryProbability(result => result.RefundRequestProbability); }); -var ticket = new SupportTicket("I was charged twice. Please refund the duplicate payment."); +var ticket = new SupportTicket( + "I was charged twice. Please refund the duplicate payment."); + var response = await client.GetResponseAsync(ticket, definition); + TicketAnalysis analysis = response.Result; DecisionResponse evidence = response.Evidence; -var categoryDistribution = - response.GetDistribution(value => value.Category); +``` + +When the complete choice evidence is useful for reporting or review, the typed distribution remains available: + +```csharp +var categoryDistribution = response.GetDistribution(value => value.Category); ``` The same definition can include an explicitly ordered score rubric, and heterogeneous declarations remain independent: From d5eafd87ce473a9aedbbcdc54dd8c53c6e587579 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Thu, 1 Oct 2026 15:17:09 -0400 Subject: [PATCH 09/11] Tighten decision provider proof semantics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb4b11ad-53ed-4f67-8760-60c0e53c4083 --- .../Decisions/DecisionAnswers.cs | 41 +++- .../Decisions/DecisionClient.cs | 8 +- .../Decisions/DecisionDefinitions.cs | 49 +++- .../Decisions/DecisionFeatures.cs | 9 +- .../Decisions/README.md | 5 + .../Microsoft.Extensions.AI.Abstractions.json | 13 +- .../Decisions/DecisionTests.cs | 229 +++++++++++++++++- 7 files changed, 324 insertions(+), 30 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs index e5b1a683ff8..d7f9e347010 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs @@ -139,6 +139,21 @@ internal static void ValidateDistribution( 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) @@ -154,17 +169,20 @@ internal static double GetDistributionTolerance(DecisionPrecision precision, int internal static double GetScoreTolerance(DecisionPrecision precision, int count) { - double roundingUnit = precision switch + double roundingUnit = GetRoundingHalfUnit(precision); + + double probabilityError = (roundingUnit * count * (count - 1)) / 2; + return probabilityError + roundingUnit + DistributionTolerance; + } + + private static double GetRoundingHalfUnit(DecisionPrecision precision) => + precision switch { DecisionPrecision.HighPrecision => 0, DecisionPrecision.FourDecimalPlaces => 0.00005, DecisionPrecision.TwoDecimalPlaces => 0.005, _ => throw new ArgumentOutOfRangeException(nameof(precision)), }; - - double probabilityError = (roundingUnit * count * (count - 1)) / 2; - return probabilityError + roundingUnit + DistributionTolerance; - } } /// Represents the probability that a binary proposition is true. @@ -237,7 +255,7 @@ public sealed class ScoreDecisionAnswer : DecisionAnswer [JsonConstructor] public ScoreDecisionAnswer( string questionId, - double score, + double? score, IReadOnlyList probabilities, DecisionPrecision precision = DecisionPrecision.HighPrecision, object? rawRepresentation = null, @@ -253,13 +271,14 @@ public ScoreDecisionAnswer( } ValidateDistribution(copy, precision); - if (double.IsNaN(score) || double.IsInfinity(score) || score < 0 || score > copy.Length - 1) + 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 (Math.Abs(score - expectedScore) > GetScoreTolerance(precision, copy.Length)) + if (score is double nativeScore && Math.Abs(nativeScore - expectedScore) > GetScoreTolerance(precision, copy.Length)) { throw new DecisionProtocolException("The score does not agree with its probability distribution."); } @@ -269,10 +288,10 @@ public ScoreDecisionAnswer( Probabilities = Array.AsReadOnly(copy); } - /// Gets the provider-reported score, retaining any declared rounding. - public double Score { get; } + /// 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. + /// 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. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs index 11fc1ad7500..7777cb682f7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionClient.cs @@ -173,7 +173,7 @@ public static class DecisionClientExtensions /// Evaluates typed state using an explicit source-generated JSON contract. /// The application state type. /// A complete, correlated decision response. - public static Task GetResponseAsync( + public static async Task GetResponseAsync( this IDecisionClient client, TState state, JsonTypeInfo stateTypeInfo, @@ -183,7 +183,11 @@ public static Task GetResponseAsync( { _ = Throw.IfNull(client); _ = Throw.IfNull(stateTypeInfo); - return client.GetResponseAsync(DecisionRequest.Create(state, stateTypeInfo, questions), options, cancellationToken); + + 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. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs index 4d5e2765130..89f92802e02 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs @@ -113,7 +113,20 @@ internal DecisionResponse Bind(DecisionResponse response, DecisionReque break; case DecisionKind.Score: - writer.WriteNumber(question.PropertyName, ((ScoreDecisionAnswer)answer).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: @@ -468,6 +481,27 @@ public void Score( Expression> selector, IReadOnlyList levels, string? instructions = null) + { + AddScore(selector, levels, instructions, usesExpectedScore: false); + } + + /// Declares an ordinal score question projected from the complete observed distribution. + /// A direct result property selector. + /// The caller-defined ordered score levels. + /// Optional explicit question instructions. + public void ExpectedScore( + Expression> selector, + IReadOnlyList levels, + string? instructions = null) + { + AddScore(selector, levels, instructions, usesExpectedScore: true); + } + + private void AddScore( + Expression> selector, + IReadOnlyList levels, + string? instructions, + bool usesExpectedScore) { PropertyInfo property = GetDirectProperty(selector); EnsurePropertyCanBeDeclared(property, typeof(double)); @@ -480,7 +514,8 @@ public void Score( jsonProperty.Name, new ScoreDecisionQuestion(jsonProperty.Name, GetInstructions(jsonProperty, property, instructions), levels), enumDefinition: null, - DecisionKind.Score); + DecisionKind.Score, + usesExpectedScore); } internal DecisionDefinition Build() @@ -501,14 +536,15 @@ private void AddQuestion( string propertyName, DecisionQuestion question, object? enumDefinition, - DecisionKind kind) + DecisionKind kind, + bool usesExpectedScore = false) { if (_questions.Exists(existing => existing.Property == property || string.Equals(existing.PropertyName, propertyName, StringComparison.Ordinal))) { Throw.ArgumentException("selector", "A result property or JSON property name can only be declared once."); } - _questions.Add(new DecisionDefinitionQuestion(property, propertyName, question, enumDefinition, kind)); + _questions.Add(new DecisionDefinitionQuestion(property, propertyName, question, enumDefinition, kind, usesExpectedScore)); } } @@ -551,13 +587,15 @@ internal DecisionDefinitionQuestion( string propertyName, DecisionQuestion question, object? enumDefinition, - DecisionKind kind) + DecisionKind kind, + bool usesExpectedScore) { Property = property; PropertyName = propertyName; Question = question; EnumDefinition = enumDefinition; Kind = kind; + UsesExpectedScore = usesExpectedScore; } internal PropertyInfo Property { get; } @@ -565,4 +603,5 @@ internal DecisionDefinitionQuestion( internal DecisionQuestion Question { get; } internal object? EnumDefinition { get; } internal DecisionKind Kind { get; } + internal bool UsesExpectedScore { get; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFeatures.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFeatures.cs index 978ebcc0fbb..51a4944233f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFeatures.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionFeatures.cs @@ -32,6 +32,9 @@ public enum DecisionFeatureValueKind /// The provider-reported ordinal score. Score, + + /// The ordinal score calculated from the complete observed distribution. + ExpectedScore, } /// Names one stable semantic coordinate in a feature schema. @@ -199,7 +202,11 @@ public static DecisionFeatureVector Project( DecisionFeatureValueKind.ScoreProbability => GetProbability(GetScore(answer, coordinate).Probabilities, coordinate.MemberId!), DecisionFeatureValueKind.Score => - GetScore(answer, coordinate).Score, + GetScore(answer, coordinate).Score ?? + throw new DecisionProtocolException( + $"Question '{coordinate.QuestionId}' does not contain a provider-reported score."), + DecisionFeatureValueKind.ExpectedScore => + GetScore(answer, coordinate).ExpectedScore, _ => throw new ArgumentOutOfRangeException(nameof(schema)), }; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index 3f78b7edec8..f3c0741accd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -67,9 +67,12 @@ var definitionWithScore = DecisionDefinition.Create(builder => builder.BinaryProbability(result => result.RefundRequestProbability); // A score property would be declared with an explicit DecisionScoreLevel list: // builder.Score(result => result.Satisfaction, [new("low", "Low"), new("high", "High")]); + // builder.ExpectedScore(result => result.Satisfaction, [new("low", "Low"), new("high", "High")]); }); ``` +`Score` projects only a provider-reported native ordinal score and fails explicitly when that scalar is absent. `ExpectedScore` projects the unmodified ordinal expectation calculated from the complete observed distribution, so the two choices remain distinguishable; after probability rounding, that observed expectation can fall outside the ordinal bounds and is not repaired. Every probability is conditioned on the full request state and question set; declaring questions independently does not promise marginal invariance, independence, a joint distribution, or calibration. + At an explicit JSON boundary, callers can use source-generated metadata for both state and result contracts. The explicit generic arguments are optional for inference, but make the boundary visible: ```csharp @@ -141,4 +144,6 @@ Layer 1 intentionally reuses existing .NET and MEAI primitives rather than addin Automated arbitrary semantic inference is out. This layer already uses bounded opt-in conventions for JSON enum naming and standard member descriptions; future conventions for known enum/member mappings remain reasonable only when explicit, source-generation/AOT-compatible, and unable to hide provider-facing identities or application-owned result mapping. +Provider evidence for the package-level contract is bounded and documented in the [upstream provider-validation report](https://github.com/luisquintanilla/typesafe-meai/blob/13adb58c7d7479bc8c73bf31960c08349b7c3185/docs/upstream-provider-validation.md): Julia/Laya provide CPU binary/choice/score proof, Qwen provides choice-only proof, and the dedicated `tev1:0.8b` Ollama/OllamaSharp path provides live binary/choice/score proof. Other adapters use native HTTP fixtures rather than live executions; this is not a claim of universal provider accuracy. + This layer intentionally does not define tools, routing composition, MEDI processors, provider adapters, ML.NET or Arrow integrations, or automatic arbitrary POCO/union semantic inference. AIFunction/tool interoperability is a Layer 2 concern and is not implied by these abstractions. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index e4b5a9a8cfa..b422e38fe80 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -5333,6 +5333,11 @@ "Stage": "Experimental", "Value": "2" }, + { + "Member": "const Microsoft.Extensions.AI.DecisionFeatureValueKind Microsoft.Extensions.AI.DecisionFeatureValueKind.ExpectedScore", + "Stage": "Experimental", + "Value": "5" + }, { "Member": "const Microsoft.Extensions.AI.DecisionFeatureValueKind Microsoft.Extensions.AI.DecisionFeatureValueKind.Score", "Stage": "Experimental", @@ -5656,7 +5661,7 @@ "Stage": "Experimental", "Methods": [ { - "Member": "Microsoft.Extensions.AI.ScoreDecisionAnswer.ScoreDecisionAnswer(string questionId, double score, System.Collections.Generic.IReadOnlyList probabilities, Microsoft.Extensions.AI.DecisionPrecision precision = Microsoft.Extensions.AI.DecisionPrecision.HighPrecision, object? rawRepresentation = null, System.Collections.Generic.IReadOnlyDictionary? additionalProperties = null);", + "Member": "Microsoft.Extensions.AI.ScoreDecisionAnswer.ScoreDecisionAnswer(string questionId, double? score, System.Collections.Generic.IReadOnlyList probabilities, Microsoft.Extensions.AI.DecisionPrecision precision = Microsoft.Extensions.AI.DecisionPrecision.HighPrecision, object? rawRepresentation = null, System.Collections.Generic.IReadOnlyDictionary? additionalProperties = null);", "Stage": "Experimental" } ], @@ -5670,7 +5675,7 @@ "Stage": "Experimental" }, { - "Member": "double Microsoft.Extensions.AI.ScoreDecisionAnswer.Score { get; }", + "Member": "double? Microsoft.Extensions.AI.ScoreDecisionAnswer.Score { get; }", "Stage": "Experimental" } ] @@ -5731,6 +5736,10 @@ "Member": "void Microsoft.Extensions.AI.DecisionDefinition.Builder.Choice(System.Linq.Expressions.Expression> selector, string? instructions = null);", "Stage": "Experimental" }, + { + "Member": "void Microsoft.Extensions.AI.DecisionDefinition.Builder.ExpectedScore(System.Linq.Expressions.Expression> selector, System.Collections.Generic.IReadOnlyList levels, string? instructions = null);", + "Stage": "Experimental" + }, { "Member": "void Microsoft.Extensions.AI.DecisionDefinition.Builder.Score(System.Linq.Expressions.Expression> selector, System.Collections.Generic.IReadOnlyList levels, string? instructions = null);", "Stage": "Experimental" diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs index 129d7710f88..fb47ad66207 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs @@ -7,6 +7,7 @@ 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 Xunit; @@ -76,7 +77,7 @@ public void MixedAnswers_BindToOwnedResultAndRetainRoundedScoreEvidence() { Approved = answers.GetBinary("binary").TrueProbability > 0.5, Selected = answers.GetChoice("choice").SelectedCandidateId, - Score = score.Score, + Score = score.Score!.Value, ExpectedScore = score.ExpectedScore, }; })); @@ -117,6 +118,16 @@ public void ProbabilityValidation_RejectsNullEntriesAndAllowsDeclaredRounding() Assert.Equal(3, twoDecimal.Probabilities.Count); Assert.Equal(3, fourDecimal.Probabilities.Count); + Assert.Throws(() => new ChoiceDecisionAnswer( + "choice", + "a", + [ + new("a", 1), + new("b", 0), + new("c", 0), + new("d", 0.02), + ], + DecisionPrecision.TwoDecimalPlaces)); Assert.Throws(() => new ChoiceDecisionAnswer( "choice", "a", @@ -140,7 +151,7 @@ public void RoundedScoreValidation_UsesProbabilityAndScoreBounds() ], DecisionPrecision.TwoDecimalPlaces); - Assert.Equal(1.58, valid.Score); + Assert.Equal(1.58, valid.Score!.Value); Assert.Throws(() => new ScoreDecisionAnswer( "score", 2, @@ -152,6 +163,34 @@ public void RoundedScoreValidation_UsesProbabilityAndScoreBounds() DecisionPrecision.TwoDecimalPlaces)); } + [Fact] + public void ScoreAnswer_PreservesAbsentNativeScoreAndRoundedExpectedScore() + { + ScoreDecisionAnswer answer = new( + "score", + null, + [ + new("low", 0), + new("medium", 0), + new("high", 0.01), + new("highest", 1), + ], + DecisionPrecision.TwoDecimalPlaces); + + Assert.Null(answer.Score); + Assert.Equal(3.02, answer.ExpectedScore, precision: 12); + + string json = JsonSerializer.Serialize(answer, TestJsonSerializerContext.Default.ScoreDecisionAnswer); + ScoreDecisionAnswer roundtrip = JsonSerializer.Deserialize( + json, + TestJsonSerializerContext.Default.ScoreDecisionAnswer)!; + Assert.Null(roundtrip.Score); + Assert.Equal(3.02, roundtrip.ExpectedScore, precision: 12); + + ScoreDecisionAnswer zero = new("score", 0, [new("low", 1), new("high", 0)]); + Assert.Equal(0, zero.Score!.Value); + } + [Fact] public void DynamicIds_AreCorrelatedPerRequestAndRejectStaleCandidates() { @@ -184,20 +223,53 @@ public void FeatureProjection_UsesNamedNativeDoubleCoordinatesAndProvenance() new("choice.blue", "choice", DecisionFeatureValueKind.ChoiceProbability, "blue|candidate"), new("score.high", "score", DecisionFeatureValueKind.ScoreProbability, "high"), new("score.value", "score", DecisionFeatureValueKind.Score), + new("score.expected", "score", DecisionFeatureValueKind.ExpectedScore), ]); DecisionFeatureVector vector = DecisionFeatureProjection.Project(response, schema); Assert.Equal("routing-decision-v1", vector.Schema.Id); Assert.Equal(2, vector.Schema.Version); - Assert.Equal(["binary.true", "choice.blue", "score.high", "score.value"], vector.Values.Select(static value => value.Name)); + Assert.Equal(["binary.true", "choice.blue", "score.high", "score.value", "score.expected"], vector.Values.Select(static value => value.Name)); Assert.Equal(0.75, vector.Values[0].Value); Assert.Equal(0.5, vector.Values[1].Value); Assert.Equal(0.7, vector.Values[2].Value); Assert.Equal(1.58, vector.Values[3].Value); + Assert.Equal(1.6, vector.Values[4].Value, precision: 12); Assert.Equal("test-provider", vector.Provenance!.ProviderName); } + [Fact] + public void FeatureProjection_RejectsMissingReportedScoreButProjectsExpectedScore() + { + DecisionRequest request = CreateRequest( + new ScoreDecisionQuestion( + "score", + "Score the request.", + [new("low", "Low"), new("medium", "Medium"), new("high", "High"), new("highest", "Highest")])); + DecisionResponse response = new( + request, + [ + new ScoreDecisionAnswer( + "score", + null, + [new("low", 0), new("medium", 0), new("high", 0.01), new("highest", 1)], + DecisionPrecision.TwoDecimalPlaces), + ]); + + DecisionFeatureSchema expectedSchema = new( + "score-expected-v1", + 1, + [new("score.expected", "score", DecisionFeatureValueKind.ExpectedScore)]); + Assert.Equal(3.02, DecisionFeatureProjection.Project(response, expectedSchema).Values[0].Value, precision: 12); + + DecisionFeatureSchema reportedSchema = new( + "score-reported-v1", + 1, + [new("score.reported", "score", DecisionFeatureValueKind.Score)]); + Assert.Throws(() => DecisionFeatureProjection.Project(response, reportedSchema)); + } + [Fact] public void ReflectionDisabledJson_UsesExplicitSourceGeneratedContracts() { @@ -229,7 +301,7 @@ public void ReflectionDisabledJson_RoundTripsAnswerAndResponseSnapshots() Assert.Equal("choice", ((ChoiceDecisionAnswer)roundtrip.Answers[1]).AdditionalProperties!["kind"]!.ToString()); Assert.Equal("score", ((ScoreDecisionAnswer)roundtrip.Answers[2]).AdditionalProperties!["kind"]!.ToString()); Assert.Equal("response", roundtrip.AdditionalProperties!["kind"]!.ToString()); - Assert.Equal(1.58, ((ScoreDecisionAnswer)roundtrip.Answers[2]).Score); + Assert.Equal(1.58, ((ScoreDecisionAnswer)roundtrip.Answers[2]).Score!.Value); } [Fact] @@ -337,7 +409,7 @@ public void DecisionDefinition_UsesExplicitDeclarationsAndMetadataDescriptions() public async Task DecisionDefinition_BindsTypedResultOnceAndRetainsEvidence() { DecisionDefinition definition = CreateTypedDefinition(); - using RecordingDecisionClient client = new(CreateTypedDefinitionResponse); + using RecordingDecisionClient client = new(request => CreateTypedDefinitionResponse(request)); DecisionOptions options = new() { ModelId = "model" }; DecisionResponse response = await client.GetResponseAsync( @@ -366,7 +438,7 @@ public async Task DecisionDefinition_BindsTypedResultOnceAndRetainsEvidence() public async Task DecisionDefinition_InferredStateOverloadUsesOneProviderInvocation() { DecisionDefinition definition = CreateTypedDefinition(); - using RecordingDecisionClient client = new(CreateTypedDefinitionResponse); + using RecordingDecisionClient client = new(request => CreateTypedDefinitionResponse(request)); DecisionResponse response = await client.GetResponseAsync( new TypedDecisionState("Please reverse this payment."), @@ -377,6 +449,79 @@ public async Task DecisionDefinition_InferredStateOverloadUsesOneProviderInvocat Assert.Equal(1, client.CallCount); } + [Fact] + public async Task RawMetadataOverloadRejectsSelfValidResponseWithChangedQuestionDomain() + { + ChoiceDecisionQuestion question = new( + "route", + "Select the active route.", + [new("alpha", "Alpha"), new("beta", "Beta")]); + using RecordingDecisionClient client = new(request => + { + DecisionRequest staleRequest = new( + request.State.Clone(), + [ + new ChoiceDecisionQuestion( + "route", + "Select the active route.", + [new("beta", "Changed beta"), new("alpha", "Alpha")]), + ]); + return new DecisionResponse( + staleRequest, + [ + new ChoiceDecisionAnswer( + "route", + "beta", + [new("beta", 0.75), new("alpha", 0.25)]), + ]); + }); + + await Assert.ThrowsAsync(() => client.GetResponseAsync( + new DecisionState { Value = "request" }, + TestJsonSerializerContext.Default.DecisionState, + [question])); + } + + [Fact] + public async Task RawMetadataOverloadRejectsSelfValidResponseWithStaleState() + { + BinaryDecisionQuestion question = new("valid", "Is this valid?"); + using RecordingDecisionClient client = new(request => + { + using JsonDocument staleState = JsonDocument.Parse("""{"value":"stale"}"""); + DecisionRequest staleRequest = new(staleState.RootElement.Clone(), [question]); + return new DecisionResponse(staleRequest, [new BinaryDecisionAnswer("valid", 0.5)]); + }); + + await Assert.ThrowsAsync(() => client.GetResponseAsync( + new DecisionState { Value = "request" }, + TestJsonSerializerContext.Default.DecisionState, + [question])); + } + + [Fact] + public async Task DecisionDefinition_ReflectionMetadataPathRejectsSelfValidStaleResponse() + { + JsonSerializerOptions options = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + DecisionDefinition definition = DecisionDefinition.Create( + builder => builder.BinaryProbability(result => result.Probability), + options); + using RecordingDecisionClient client = new(request => + { + DecisionRequest staleRequest = new( + request.State.Clone(), + [new BinaryDecisionQuestion(request.Questions[0].Id, "A stale instruction.")]); + return new DecisionResponse(staleRequest, [new BinaryDecisionAnswer(request.Questions[0].Id, 0.5)]); + }); + + await Assert.ThrowsAsync(() => client.GetResponseAsync( + new ReflectionState { Value = "request" }, + definition)); + } + [Fact] public void DecisionDefinition_RejectsNestedDuplicateAndFlagsSelectors() { @@ -427,7 +572,7 @@ public async Task DecisionDefinition_PropagatesCancellationWithoutFabricatedType DecisionDefinition definition = CreateTypedDefinition(); using CancellationTokenSource cancellation = new(); cancellation.Cancel(); - using RecordingDecisionClient client = new(CreateTypedDefinitionResponse, cancel: true); + using RecordingDecisionClient client = new(request => CreateTypedDefinitionResponse(request), cancel: true); await Assert.ThrowsAnyAsync(() => client.GetResponseAsync( @@ -523,6 +668,44 @@ [new ChoiceDecisionAnswer( Assert.Equal(CustomCategory.Technical, response.Result.Category); } + [Fact] + public void DecisionDefinition_UsesConfiguredEnumNamingPolicy() + { + JsonSerializerOptions options = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + DecisionDefinition definition = DecisionDefinition.Create( + builder => builder.Choice(result => result.Category), + options); + + ChoiceDecisionQuestion question = Assert.IsType(definition.Questions[0]); + Assert.Equal(["billing", "technical", "account"], question.Candidates.Select(static candidate => candidate.Id)); + } + + [Fact] + public async Task DecisionDefinition_ExpectedScoreUsesObservedDistributionAndReportedScoreRequiresNativeValue() + { + DecisionDefinition expectedDefinition = CreateExpectedScoreDefinition(); + using RecordingDecisionClient expectedClient = new(request => CreateTypedDefinitionResponse(request, reportedScore: null)); + + DecisionResponse expectedResponse = await expectedClient.GetResponseAsync( + new TypedDecisionState("request"), + TestJsonSerializerContext.Default.TypedDecisionState, + expectedDefinition); + + Assert.Equal(1.6, expectedResponse.Result.Satisfaction, precision: 12); + Assert.Null(((ScoreDecisionAnswer)expectedResponse.Evidence.Answers[2]).Score); + + DecisionDefinition reportedDefinition = CreateTypedDefinition(); + using RecordingDecisionClient reportedClient = new(request => CreateTypedDefinitionResponse(request, reportedScore: null)); + await Assert.ThrowsAsync(() => reportedClient.GetResponseAsync( + new TypedDecisionState("request"), + TestJsonSerializerContext.Default.TypedDecisionState, + reportedDefinition)); + } + private static DecisionDefinition CreateTypedDefinition() => DecisionDefinition.Create( TestJsonSerializerContext.Default.TypedDecisionResult, @@ -539,7 +722,23 @@ private static DecisionDefinition CreateTypedDefinition() = ]); }); - private static DecisionResponse CreateTypedDefinitionResponse(DecisionRequest request) + private static DecisionDefinition CreateExpectedScoreDefinition() => + DecisionDefinition.Create( + TestJsonSerializerContext.Default.TypedDecisionResult, + builder => + { + builder.Choice(result => result.Category); + builder.BinaryProbability(result => result.RefundRequestProbability); + builder.ExpectedScore( + result => result.Satisfaction, + [ + new("low", "Low"), + new("medium", "Medium"), + new("high", "High"), + ]); + }); + + private static DecisionResponse CreateTypedDefinitionResponse(DecisionRequest request, double? reportedScore = 1.58) { return new DecisionResponse( request, @@ -555,7 +754,7 @@ private static DecisionResponse CreateTypedDefinitionResponse(DecisionRequest re new BinaryDecisionAnswer(request.Questions[1].Id, 0.25), new ScoreDecisionAnswer( request.Questions[2].Id, - 1.58, + reportedScore, [ new(((ScoreDecisionQuestion)request.Questions[2]).Levels[0].Id, 0.1), new(((ScoreDecisionQuestion)request.Questions[2]).Levels[1].Id, 0.2), @@ -814,11 +1013,23 @@ internal enum CustomCategory internal sealed record CustomEnumResult(CustomCategory Category); + internal sealed record NamingPolicyResult(TicketCategory Category); + internal sealed record PropertyConverterResult( [property: JsonConverter(typeof(CustomCategoryConverter))] CustomCategory Category); internal sealed record JsonElementState; + internal sealed class ReflectionState + { + public string? Value { get; set; } + } + + internal sealed class ReflectionResult + { + public double Probability { get; set; } + } + internal sealed class CustomCategoryConverter : JsonConverter { public override CustomCategory Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => From 230213bba4492a8a254e13e24d2748e1754e213d Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Thu, 1 Oct 2026 20:58:33 -0400 Subject: [PATCH 10/11] Harden decision proof validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb4b11ad-53ed-4f67-8760-60c0e53c4083 --- .../Decisions/DecisionAnswers.cs | 69 +++++++++- .../Decisions/DecisionDefinitions.cs | 91 ++++++++---- .../Decisions/DecisionTests.cs | 130 ++++++++++++++++-- 3 files changed, 244 insertions(+), 46 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs index d7f9e347010..af81aaac3f7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs @@ -167,15 +167,64 @@ internal static double GetDistributionTolerance(DecisionPrecision precision, int }; } - internal static double GetScoreTolerance(DecisionPrecision precision, int count) + internal static (double Minimum, double Maximum) GetFeasibleExpectedScoreBounds( + IReadOnlyList probabilities, + DecisionPrecision precision) { - double roundingUnit = GetRoundingHalfUnit(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; + } - double probabilityError = (roundingUnit * count * (count - 1)) / 2; - return probabilityError + roundingUnit + DistributionTolerance; + 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); } - private static double GetRoundingHalfUnit(DecisionPrecision precision) => + internal static double GetRoundingHalfUnit(DecisionPrecision precision) => precision switch { DecisionPrecision.HighPrecision => 0, @@ -278,9 +327,15 @@ public ScoreDecisionAnswer( } double expectedScore = copy.Select((probability, index) => probability.Value * index).Sum(); - if (score is double nativeScore && Math.Abs(nativeScore - expectedScore) > GetScoreTolerance(precision, copy.Length)) + if (score is double nativeScore) { - throw new DecisionProtocolException("The score does not agree with its probability distribution."); + (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; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs index 89f92802e02..17444176532 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs @@ -66,6 +66,7 @@ public static DecisionDefinition Create( { _ = Throw.IfNull(configure); serializerOptions = new JsonSerializerOptions(serializerOptions ?? AIJsonUtilities.DefaultOptions); + serializerOptions.TypeInfoResolver ??= AIJsonUtilities.DefaultOptions.TypeInfoResolver; serializerOptions.MakeReadOnly(); JsonTypeInfo resultTypeInfo = GetResultTypeInfo(serializerOptions); @@ -155,32 +156,29 @@ internal DecisionResponse Bind(DecisionResponse response, DecisionReque throw new DecisionProtocolException("The decision response produced a null result."); } - try + foreach (DecisionDefinitionQuestion question in _questions) { - JsonElement materializedJson = JsonSerializer.SerializeToElement(result, ResultTypeInfo); - using JsonDocument expectedJson = JsonDocument.Parse(resultJson); - - foreach (DecisionDefinitionQuestion question in _questions) + DecisionAnswer answer = response.GetAnswer(question.Question.Id); + object? actualValue = question.ValueAccessor(result); + bool preserved = question.Kind switch { - if (!expectedJson.RootElement.TryGetProperty(question.PropertyName, out JsonElement expectedValue) || - !materializedJson.TryGetProperty(question.PropertyName, out JsonElement materializedValue) || - !JsonElement.DeepEquals(expectedValue, materializedValue)) - { - throw new DecisionProtocolException( - $"The decision response value for result property '{question.Property.Name}' was not preserved by the configured JSON contract."); - } + 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."); } } - catch (DecisionProtocolException) - { - throw; - } - catch (Exception exception) when (exception is JsonException or NotSupportedException) - { - throw new DecisionProtocolException( - "The decision response could not be verified against the configured result contract.", - exception); - } return new DecisionResponse(result, response, this); } @@ -425,6 +423,23 @@ private static void EnsurePropertyCanBeMaterialized( } #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 CreateChoiceValueMatcher( + DecisionEnumDefinition enumDefinition) + where TEnum : struct, Enum => + (actualValue, candidateId) => + actualValue is TEnum actual && + EqualityComparer.Default.Equals(actual, enumDefinition.Parse(candidateId)); + internal Builder(JsonTypeInfo resultTypeInfo) { _resultTypeInfo = resultTypeInfo; @@ -450,7 +465,9 @@ internal Builder(JsonTypeInfo resultTypeInfo) jsonProperty.Name, new ChoiceDecisionQuestion(jsonProperty.Name, GetInstructions(jsonProperty, property, instructions), enumDefinition.Candidates), enumDefinition, - DecisionKind.Choice); + DecisionKind.Choice, + valueAccessor: CreateValueAccessor(selector), + choiceValueMatches: CreateChoiceValueMatcher(enumDefinition)); } /// Declares a binary probability question backed by a result property. @@ -470,7 +487,8 @@ public void BinaryProbability( jsonProperty.Name, new BinaryDecisionQuestion(jsonProperty.Name, GetInstructions(jsonProperty, property, instructions)), enumDefinition: null, - DecisionKind.Binary); + DecisionKind.Binary, + valueAccessor: CreateValueAccessor(selector)); } /// Declares an ordinal score question with an explicit ordered rubric. @@ -515,7 +533,8 @@ private void AddScore( new ScoreDecisionQuestion(jsonProperty.Name, GetInstructions(jsonProperty, property, instructions), levels), enumDefinition: null, DecisionKind.Score, - usesExpectedScore); + usesExpectedScore, + valueAccessor: CreateValueAccessor(selector)); } internal DecisionDefinition Build() @@ -537,14 +556,24 @@ private void AddQuestion( DecisionQuestion question, object? enumDefinition, DecisionKind kind, - bool usesExpectedScore = false) + bool usesExpectedScore = false, + Func? valueAccessor = null, + Func? choiceValueMatches = null) { if (_questions.Exists(existing => existing.Property == property || string.Equals(existing.PropertyName, propertyName, StringComparison.Ordinal))) { Throw.ArgumentException("selector", "A result property or JSON property name can only be declared once."); } - _questions.Add(new DecisionDefinitionQuestion(property, propertyName, question, enumDefinition, kind, usesExpectedScore)); + _questions.Add(new DecisionDefinitionQuestion( + property, + propertyName, + question, + enumDefinition, + kind, + usesExpectedScore, + valueAccessor ?? throw new InvalidOperationException("A direct property accessor is required."), + choiceValueMatches)); } } @@ -588,7 +617,9 @@ internal DecisionDefinitionQuestion( DecisionQuestion question, object? enumDefinition, DecisionKind kind, - bool usesExpectedScore) + bool usesExpectedScore, + Func valueAccessor, + Func? choiceValueMatches) { Property = property; PropertyName = propertyName; @@ -596,6 +627,8 @@ internal DecisionDefinitionQuestion( EnumDefinition = enumDefinition; Kind = kind; UsesExpectedScore = usesExpectedScore; + ValueAccessor = valueAccessor; + ChoiceValueMatches = choiceValueMatches; } internal PropertyInfo Property { get; } @@ -604,4 +637,6 @@ internal DecisionDefinitionQuestion( internal object? EnumDefinition { get; } internal DecisionKind Kind { get; } internal bool UsesExpectedScore { get; } + internal Func ValueAccessor { get; } + internal Func? ChoiceValueMatches { get; } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs index fb47ad66207..1a105e02c05 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs @@ -84,7 +84,7 @@ public void MixedAnswers_BindToOwnedResultAndRetainRoundedScoreEvidence() Assert.True(result.Approved); Assert.Equal("blue|candidate", result.Selected); - Assert.Equal(1.58, result.Score); + Assert.Equal(1.6, result.Score); Assert.Equal(1.6, result.ExpectedScore, precision: 12); Assert.Equal(3, ((ScoreDecisionAnswer)response.Answers[2]).Probabilities.Count); } @@ -143,7 +143,7 @@ public void RoundedScoreValidation_UsesProbabilityAndScoreBounds() { ScoreDecisionAnswer valid = new( "score", - 1.58, + 1.6, [ new("low", 0.1), new("medium", 0.2), @@ -151,7 +151,16 @@ public void RoundedScoreValidation_UsesProbabilityAndScoreBounds() ], DecisionPrecision.TwoDecimalPlaces); - Assert.Equal(1.58, valid.Score!.Value); + Assert.Equal(1.6, valid.Score!.Value); + Assert.Throws(() => new ScoreDecisionAnswer( + "score", + 0.02, + [ + new("low", 1), + new("medium", 0), + new("high", 0), + ], + DecisionPrecision.TwoDecimalPlaces)); Assert.Throws(() => new ScoreDecisionAnswer( "score", 2, @@ -163,6 +172,50 @@ public void RoundedScoreValidation_UsesProbabilityAndScoreBounds() DecisionPrecision.TwoDecimalPlaces)); } + [Fact] + public void RoundedScoreValidation_AllowsBothFeasibleExpectationExtremes() + { + ScoreDecisionAnswer minimum = new( + "score", + 1.59, + [ + new("low", 0.1), + new("medium", 0.2), + new("high", 0.7), + ], + DecisionPrecision.TwoDecimalPlaces); + ScoreDecisionAnswer maximum = new( + "score", + 1.61, + [ + new("low", 0.1), + new("medium", 0.2), + new("high", 0.7), + ], + DecisionPrecision.TwoDecimalPlaces); + + Assert.Equal(1.59, minimum.Score!.Value); + Assert.Equal(1.61, maximum.Score!.Value); + Assert.Throws(() => new ScoreDecisionAnswer( + "score", + 1.58, + [ + new("low", 0.1), + new("medium", 0.2), + new("high", 0.7), + ], + DecisionPrecision.TwoDecimalPlaces)); + Assert.Throws(() => new ScoreDecisionAnswer( + "score", + 1.62, + [ + new("low", 0.1), + new("medium", 0.2), + new("high", 0.7), + ], + DecisionPrecision.TwoDecimalPlaces)); + } + [Fact] public void ScoreAnswer_PreservesAbsentNativeScoreAndRoundedExpectedScore() { @@ -234,7 +287,7 @@ public void FeatureProjection_UsesNamedNativeDoubleCoordinatesAndProvenance() Assert.Equal(0.75, vector.Values[0].Value); Assert.Equal(0.5, vector.Values[1].Value); Assert.Equal(0.7, vector.Values[2].Value); - Assert.Equal(1.58, vector.Values[3].Value); + Assert.Equal(1.6, vector.Values[3].Value); Assert.Equal(1.6, vector.Values[4].Value, precision: 12); Assert.Equal("test-provider", vector.Provenance!.ProviderName); } @@ -301,7 +354,7 @@ public void ReflectionDisabledJson_RoundTripsAnswerAndResponseSnapshots() Assert.Equal("choice", ((ChoiceDecisionAnswer)roundtrip.Answers[1]).AdditionalProperties!["kind"]!.ToString()); Assert.Equal("score", ((ScoreDecisionAnswer)roundtrip.Answers[2]).AdditionalProperties!["kind"]!.ToString()); Assert.Equal("response", roundtrip.AdditionalProperties!["kind"]!.ToString()); - Assert.Equal(1.58, ((ScoreDecisionAnswer)roundtrip.Answers[2]).Score!.Value); + Assert.Equal(1.6, ((ScoreDecisionAnswer)roundtrip.Answers[2]).Score!.Value); } [Fact] @@ -422,7 +475,7 @@ public async Task DecisionDefinition_BindsTypedResultOnceAndRetainsEvidence() Assert.Same(options, client.Options); Assert.Equal(TicketCategory.Technical, response.Result.Category); Assert.Equal(0.25, response.Result.RefundRequestProbability); - Assert.Equal(1.58, response.Result.Satisfaction); + Assert.Equal(1.6, response.Result.Satisfaction); Assert.Equal("test-provider", response.Evidence.Provenance!.ProviderName); Assert.Equal( new Dictionary @@ -593,12 +646,27 @@ await Assert.ThrowsAnyAsync(() => public async Task DecisionDefinition_RejectsResponseNotMatchingSentRequest(string mutation) { DecisionDefinition definition = CreateTypedDefinition(); - using RecordingDecisionClient client = new(request => CreateMismatchedResponse(request, mutation)); + bool providerReturnedResponse = false; + using RecordingDecisionClient client = new(request => + { + DecisionResponse response = CreateMismatchedResponse(request, mutation); + _ = new DecisionResponse( + response.Request, + response.Answers, + response.Provenance, + response.Usage, + response.RawRepresentation, + response.AdditionalProperties); + providerReturnedResponse = true; + return response; + }); await Assert.ThrowsAsync(() => client.GetResponseAsync( new TypedDecisionState("request"), TestJsonSerializerContext.Default.TypedDecisionState, definition)); + Assert.True(providerReturnedResponse); + Assert.Equal(1, client.CallCount); } [Fact] @@ -684,6 +752,41 @@ public void DecisionDefinition_UsesConfiguredEnumNamingPolicy() Assert.Equal(["billing", "technical", "account"], question.Candidates.Select(static candidate => candidate.Id)); } + [Fact] + public void DecisionDefinition_ReflectionDisabledOptionsRequireExplicitResolver() + { + JsonSerializerOptions options = new(JsonSerializerDefaults.Web); + + Assert.Throws(() => DecisionDefinition.Create( + builder => builder.BinaryProbability(result => result.Probability), + options)); + } + + [Fact] + public async Task DecisionDefinition_RejectsMaterializationWhenSerializationGetterMasksActualValue() + { + DefaultJsonTypeInfoResolver resolver = new(); + resolver.Modifiers.Add(typeInfo => + { + if (typeInfo.Type == typeof(MaskedGetterResult)) + { + JsonPropertyInfo property = typeInfo.Properties.Single(static property => property.Name == nameof(MaskedGetterResult.Probability)); + property.Set = static (obj, value) => ((MaskedGetterResult)obj).Probability = 0; + property.Get = static _ => 0.75; + } + }); + JsonSerializerOptions options = new() { TypeInfoResolver = resolver }; + DecisionDefinition definition = DecisionDefinition.Create( + builder => builder.BinaryProbability(result => result.Probability), + options); + using RecordingDecisionClient client = new(request => new DecisionResponse( + request, + [new BinaryDecisionAnswer(request.Questions[0].Id, 0.75)])); + + using JsonDocument state = JsonDocument.Parse("{}"); + await Assert.ThrowsAsync(() => client.GetResponseAsync(state.RootElement.Clone(), definition)); + } + [Fact] public async Task DecisionDefinition_ExpectedScoreUsesObservedDistributionAndReportedScoreRequiresNativeValue() { @@ -738,7 +841,7 @@ private static DecisionDefinition CreateExpectedScoreDefini ]); }); - private static DecisionResponse CreateTypedDefinitionResponse(DecisionRequest request, double? reportedScore = 1.58) + private static DecisionResponse CreateTypedDefinitionResponse(DecisionRequest request, double? reportedScore = 1.6) { return new DecisionResponse( request, @@ -783,7 +886,7 @@ private static DecisionResponse CreateMismatchedResponse(DecisionRequest request new BinaryDecisionAnswer(wrongKindRequest.Questions[1].Id, 0.25), new ScoreDecisionAnswer( wrongKindRequest.Questions[2].Id, - 1.58, + 1.6, [new("low", 0.1), new("medium", 0.2), new("high", 0.7)], DecisionPrecision.TwoDecimalPlaces), ]); @@ -848,7 +951,7 @@ private static DecisionResponse CreateResponseForRequest(DecisionRequest request new BinaryDecisionAnswer(request.Questions[1].Id, 0.25), new ScoreDecisionAnswer( score.Id, - 1.58, + 1, score.Levels.Select(static (level, index) => new DecisionProbability(level.Id, index == 1 ? 1 : 0)).ToArray(), DecisionPrecision.TwoDecimalPlaces), ]); @@ -919,7 +1022,7 @@ private static DecisionResponse CreateMixedResponse() additionalProperties: new Dictionary { ["kind"] = "choice" }), new ScoreDecisionAnswer( "score", - 1.58, + 1.6, [ new("low", 0.1), new("medium", 0.2), @@ -1030,6 +1133,11 @@ internal sealed class ReflectionResult public double Probability { get; set; } } + internal sealed class MaskedGetterResult + { + public double Probability { get; set; } + } + internal sealed class CustomCategoryConverter : JsonConverter { public override CustomCategory Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => From 02e54f023bdf3c8c2729353b110c22f5f8593957 Mon Sep 17 00:00:00 2001 From: luisquintanilla Date: Thu, 1 Oct 2026 21:17:08 -0400 Subject: [PATCH 11/11] Expand decision regression coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb4b11ad-53ed-4f67-8760-60c0e53c4083 --- .../Decisions/DecisionAnswers.cs | 5 +- .../Decisions/DecisionDefinitions.cs | 5 +- .../Decisions/README.md | 2 + .../Decisions/DecisionTests.cs | 257 +++++++++++++++++- .../TestJsonSerializerContext.cs | 13 + 5 files changed, 276 insertions(+), 6 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs index af81aaac3f7..f4227f9b199 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionAnswers.cs @@ -296,7 +296,10 @@ public ChoiceDecisionAnswer( public IReadOnlyList Probabilities { get; } } -/// Represents an ordinal score and its complete level distribution. +/// +/// 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 { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs index 17444176532..926f0fa8812 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/DecisionDefinitions.cs @@ -58,7 +58,10 @@ private DecisionDefinition( /// 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. + /// + /// 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, diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md index f3c0741accd..ca0e7d73916 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Decisions/README.md @@ -73,6 +73,8 @@ var definitionWithScore = DecisionDefinition.Create(builder => `Score` projects only a provider-reported native ordinal score and fails explicitly when that scalar is absent. `ExpectedScore` projects the unmodified ordinal expectation calculated from the complete observed distribution, so the two choices remain distinguishable; after probability rounding, that observed expectation can fall outside the ordinal bounds and is not repaired. Every probability is conditioned on the full request state and question set; declaring questions independently does not promise marginal invariance, independence, a joint distribution, or calibration. +Reported scalar consistency uses normalized latent feasibility within clipped rounding intervals; the observed probabilities are never normalized or rewritten. Typed binding verifies the actual CLR property values after materialization, independently of write-only JSON formatting. When options omit a resolver, the definition supplies the MEAI default resolver, while explicit caller resolvers and converters remain unchanged. + At an explicit JSON boundary, callers can use source-generated metadata for both state and result contracts. The explicit generic arguments are optional for inference, but make the boundary visible: ```csharp diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs index 1a105e02c05..35bf36a81cd 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Decisions/DecisionTests.cs @@ -216,6 +216,78 @@ public void RoundedScoreValidation_AllowsBothFeasibleExpectationExtremes() DecisionPrecision.TwoDecimalPlaces)); } + [Fact] + public void RoundedScoreValidation_FourDecimalPrecisionRequiresNormalizedLatentMass() + { + ScoreDecisionAnswer valid = new( + "score", + 1, + [ + new("low", 0.3333), + new("medium", 0.3333), + new("high", 0.3333), + ], + DecisionPrecision.FourDecimalPlaces); + + Assert.Equal(1, valid.Score!.Value); + Assert.Equal(0.9999, valid.ExpectedScore, precision: 12); + Assert.Throws(() => new ScoreDecisionAnswer( + "score", + 0.9998, + [ + new("low", 0.3333), + new("medium", 0.3333), + new("high", 0.3333), + ], + DecisionPrecision.FourDecimalPlaces)); + } + + [Fact] + public void RoundedScoreValidation_UsesClippedTwoDecimalFeasibleExtremes() + { + ScoreDecisionAnswer valid = new( + "score", + 0.02, + [ + new("low", 0.99), + new("medium", 0.01), + new("high", 0), + ], + DecisionPrecision.TwoDecimalPlaces); + + Assert.Equal(0.02, valid.Score!.Value); + Assert.Equal(0.01, valid.ExpectedScore, precision: 12); + Assert.Throws(() => new ScoreDecisionAnswer( + "score", + 0.03, + [ + new("low", 0.99), + new("medium", 0.01), + new("high", 0), + ], + DecisionPrecision.TwoDecimalPlaces)); + } + + [Fact] + public void RoundedScoreValidation_PreservesHighPrecisionSlackWithoutRepair() + { + ScoreDecisionAnswer above = new( + "score", + 0.5, + [new("low", 0.5000004), new("high", 0.5)]); + ScoreDecisionAnswer below = new( + "score", + 0.5, + [new("low", 0.4999996), new("high", 0.5)]); + + Assert.Equal(0.5, above.ExpectedScore, precision: 12); + Assert.Equal(0.5, below.ExpectedScore, precision: 12); + Assert.Throws(() => new ScoreDecisionAnswer( + "score", + 0.500002, + [new("low", 0.5000004), new("high", 0.5)])); + } + [Fact] public void ScoreAnswer_PreservesAbsentNativeScoreAndRoundedExpectedScore() { @@ -555,10 +627,8 @@ await Assert.ThrowsAsync(() => client.GetResponseAsyn [Fact] public async Task DecisionDefinition_ReflectionMetadataPathRejectsSelfValidStaleResponse() { - JsonSerializerOptions options = new(JsonSerializerDefaults.Web) - { - TypeInfoResolver = new DefaultJsonTypeInfoResolver(), - }; + DefaultJsonTypeInfoResolver resolver = new(); + JsonSerializerOptions options = new(JsonSerializerDefaults.Web) { TypeInfoResolver = resolver }; DecisionDefinition definition = DecisionDefinition.Create( builder => builder.BinaryProbability(result => result.Probability), options); @@ -573,6 +643,8 @@ public async Task DecisionDefinition_ReflectionMetadataPathRejectsSelfValidStale await Assert.ThrowsAsync(() => client.GetResponseAsync( new ReflectionState { Value = "request" }, definition)); + Assert.Same(resolver, options.TypeInfoResolver); + Assert.False(options.IsReadOnly); } [Fact] @@ -760,6 +832,8 @@ public void DecisionDefinition_ReflectionDisabledOptionsRequireExplicitResolver( Assert.Throws(() => DecisionDefinition.Create( builder => builder.BinaryProbability(result => result.Probability), options)); + Assert.Null(options.TypeInfoResolver); + Assert.False(options.IsReadOnly); } [Fact] @@ -787,6 +861,110 @@ public async Task DecisionDefinition_RejectsMaterializationWhenSerializationGett await Assert.ThrowsAsync(() => client.GetResponseAsync(state.RootElement.Clone(), definition)); } + [Fact] + public async Task DecisionDefinition_PreservesGlobalWriteAsStringAndDefaultIgnoreSettings() + { + string serialized = JsonSerializer.Serialize( + new GlobalConfiguredResult { Probability = 0.25, NativeScore = 1.6, ExpectedScore = 1.6 }, + GlobalConfiguredJsonSerializerContext.Default.GlobalConfiguredResult); + Assert.Contains("\"probability\":\"0.25\"", serialized); + + DecisionResponse response = await BindConfiguredResult( + GlobalConfiguredJsonSerializerContext.Default.GlobalConfiguredResult, + builder => + { + builder.BinaryProbability(result => result.Probability); + builder.Score(result => result.NativeScore, CreateLevels()); + builder.ExpectedScore(result => result.ExpectedScore, CreateLevels()); + }, + zero: false); + + Assert.Equal(0.25, response.Result.Probability); + Assert.Equal(1.6, response.Result.NativeScore, precision: 12); + Assert.Equal(1.6, response.Result.ExpectedScore, precision: 12); + Assert.Equal(3, response.Evidence.Answers.Count); + Assert.Equal(3, response.Evidence.Request.Questions.Count); + } + + [Fact] + public async Task DecisionDefinition_PreservesGlobalDefaultIgnoredNativeZeroAndExpectedScore() + { + string serialized = JsonSerializer.Serialize( + new GlobalConfiguredResult(), + GlobalConfiguredJsonSerializerContext.Default.GlobalConfiguredResult); + Assert.DoesNotContain("probability", serialized); + Assert.DoesNotContain("nativeScore", serialized); + Assert.DoesNotContain("expectedScore", serialized); + + DecisionResponse response = await BindConfiguredResult( + GlobalConfiguredJsonSerializerContext.Default.GlobalConfiguredResult, + builder => + { + builder.BinaryProbability(result => result.Probability); + builder.Score(result => result.NativeScore, CreateLevels()); + builder.ExpectedScore(result => result.ExpectedScore, CreateLevels()); + }, + zero: true); + + Assert.Equal(0, response.Result.Probability); + Assert.Equal(0, response.Result.NativeScore); + Assert.Equal(0, response.Result.ExpectedScore); + Assert.Equal(0, ((ScoreDecisionAnswer)response.Evidence.Answers[1]).Score!.Value); + Assert.Null(((ScoreDecisionAnswer)response.Evidence.Answers[2]).Score); + Assert.Equal(3, response.Evidence.Answers.Count); + } + + [Fact] + public async Task DecisionDefinition_PreservesPropertyWriteAsStringAndDefaultIgnoreSettings() + { + string serialized = JsonSerializer.Serialize( + new PropertyConfiguredResult { Probability = 0.25, NativeScore = 1.6, ExpectedScore = 1.6 }, + PropertyConfiguredJsonSerializerContext.Default.PropertyConfiguredResult); + Assert.Contains("\"probability\":\"0.25\"", serialized); + + DecisionResponse response = await BindConfiguredResult( + PropertyConfiguredJsonSerializerContext.Default.PropertyConfiguredResult, + builder => + { + builder.BinaryProbability(result => result.Probability); + builder.Score(result => result.NativeScore, CreateLevels()); + builder.ExpectedScore(result => result.ExpectedScore, CreateLevels()); + }, + zero: false); + + Assert.Equal(0.25, response.Result.Probability); + Assert.Equal(1.6, response.Result.NativeScore, precision: 12); + Assert.Equal(1.6, response.Result.ExpectedScore, precision: 12); + Assert.Equal(3, response.Evidence.Answers.Count); + } + + [Fact] + public async Task DecisionDefinition_PreservesPropertyDefaultIgnoredNativeZeroAndExpectedScore() + { + string serialized = JsonSerializer.Serialize( + new PropertyConfiguredResult(), + PropertyConfiguredJsonSerializerContext.Default.PropertyConfiguredResult); + Assert.DoesNotContain("probability", serialized); + Assert.DoesNotContain("nativeScore", serialized); + Assert.DoesNotContain("expectedScore", serialized); + + DecisionResponse response = await BindConfiguredResult( + PropertyConfiguredJsonSerializerContext.Default.PropertyConfiguredResult, + builder => + { + builder.BinaryProbability(result => result.Probability); + builder.Score(result => result.NativeScore, CreateLevels()); + builder.ExpectedScore(result => result.ExpectedScore, CreateLevels()); + }, + zero: true); + + Assert.Equal(0, response.Result.Probability); + Assert.Equal(0, response.Result.NativeScore); + Assert.Equal(0, response.Result.ExpectedScore); + Assert.Equal(0, ((ScoreDecisionAnswer)response.Evidence.Answers[1]).Score!.Value); + Assert.Null(((ScoreDecisionAnswer)response.Evidence.Answers[2]).Score); + } + [Fact] public async Task DecisionDefinition_ExpectedScoreUsesObservedDistributionAndReportedScoreRequiresNativeValue() { @@ -809,6 +987,55 @@ await Assert.ThrowsAsync(() => reportedClient.GetResp reportedDefinition)); } + private static DecisionScoreLevel[] CreateLevels() => + [ + new("low", "Low"), + new("medium", "Medium"), + new("high", "High"), + ]; + + private static async Task> BindConfiguredResult( + JsonTypeInfo resultTypeInfo, + Action.Builder> configure, + bool zero) + { + DecisionDefinition definition = DecisionDefinition.Create(resultTypeInfo, configure); + using RecordingDecisionClient client = new(request => CreateConfiguredSerializationResponse(request, zero)); + DecisionResponse response = await client.GetResponseAsync( + new TypedDecisionState("request"), + TestJsonSerializerContext.Default.TypedDecisionState, + definition); + Assert.Equal(1, client.CallCount); + return response; + } + + private static DecisionResponse CreateConfiguredSerializationResponse(DecisionRequest request, bool zero) + { + double binary = zero ? 0 : 0.25; + double nativeScore = zero ? 0 : 1.6; + double[] distribution = zero ? [1, 0, 0] : [0.1, 0.2, 0.7]; + + return new DecisionResponse( + request, + [ + new BinaryDecisionAnswer(request.Questions[0].Id, binary), + new ScoreDecisionAnswer( + request.Questions[1].Id, + nativeScore, + distribution.Select((value, index) => new DecisionProbability( + ((ScoreDecisionQuestion)request.Questions[1]).Levels[index].Id, + value)).ToArray(), + DecisionPrecision.TwoDecimalPlaces), + new ScoreDecisionAnswer( + request.Questions[2].Id, + null, + distribution.Select((value, index) => new DecisionProbability( + ((ScoreDecisionQuestion)request.Questions[2]).Levels[index].Id, + value)).ToArray(), + DecisionPrecision.TwoDecimalPlaces), + ]); + } + private static DecisionDefinition CreateTypedDefinition() => DecisionDefinition.Create( TestJsonSerializerContext.Default.TypedDecisionResult, @@ -1133,6 +1360,28 @@ internal sealed class ReflectionResult public double Probability { get; set; } } + internal sealed class GlobalConfiguredResult + { + public double Probability { get; set; } + public double NativeScore { get; set; } + public double ExpectedScore { get; set; } + } + + internal sealed class PropertyConfiguredResult + { + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public double Probability { get; set; } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public double NativeScore { get; set; } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public double ExpectedScore { get; set; } + } + internal sealed class MaskedGetterResult { public double Probability { get; set; } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs index cac7ea1e919..f8315bdf9ce 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs @@ -1,6 +1,8 @@ // 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 // Test JSON contexts are co-located for the shared test contracts. + using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -86,3 +88,14 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(DecisionTests.PropertyConverterResult))] [JsonSerializable(typeof(DecisionTests.JsonElementState))] internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + NumberHandling = JsonNumberHandling.WriteAsString)] +[JsonSerializable(typeof(DecisionTests.GlobalConfiguredResult))] +internal sealed partial class GlobalConfiguredJsonSerializerContext : JsonSerializerContext; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(DecisionTests.PropertyConfiguredResult))] +internal sealed partial class PropertyConfiguredJsonSerializerContext : JsonSerializerContext;