diff --git a/src/dotnet/QuantEngine.Application/Services/DecisionLearningService.cs b/src/dotnet/QuantEngine.Application/Services/DecisionLearningService.cs index 926e0526..7fe9fb3b 100644 --- a/src/dotnet/QuantEngine.Application/Services/DecisionLearningService.cs +++ b/src/dotnet/QuantEngine.Application/Services/DecisionLearningService.cs @@ -25,6 +25,12 @@ public sealed class DecisionLearningService object? trace = null, object? provenance = null) { + decisionKey = RequireValue(decisionKey, nameof(decisionKey)); + instrumentId = RequireValue(instrumentId, nameof(instrumentId)); + action = RequireValue(action, nameof(action)); + gate = RequireValue(gate, nameof(gate)); + sourceVersion = RequireValue(sourceVersion, nameof(sourceVersion)); + var decisionId = await _store.AppendDecisionAsync(new DecisionEventRecord( decisionKey, decidedAt, @@ -33,29 +39,30 @@ public sealed class DecisionLearningService gate, score, sourceVersion, - JsonSerializer.Serialize(trace ?? new { }), - JsonSerializer.Serialize(provenance ?? new { }))); + SerializeJson(trace), + SerializeJson(provenance))); - foreach (var factor in factors) + foreach (var factor in factors ?? throw new ArgumentNullException(nameof(factors))) { + var normalizedFactor = NormalizeFactor(factor); var observationId = await _store.AppendSourceObservationAsync(new SourceObservationRecord( - factor.ObservedAt, + normalizedFactor.ObservedAt, instrumentId, - factor.SourceName, + normalizedFactor.SourceName, sourceVersion, - factor.PayloadJson, - factor.ProvenanceJson)); + normalizedFactor.PayloadJson, + normalizedFactor.ProvenanceJson)); var factorObservationId = await _store.AppendFactorObservationAsync(new FactorObservationRecord( observationId, - factor.FactorObservationId, - factor.FactorId, - factor.FactorVersion, - factor.ObservedAt, - factor.NumericValue, - factor.TextValue, - factor.Gate, - factor.ProvenanceJson)); - await _store.AppendDecisionFactorEvidenceAsync(decisionId, factorObservationId, factor.Role); + normalizedFactor.FactorObservationId, + normalizedFactor.FactorId, + normalizedFactor.FactorVersion, + normalizedFactor.ObservedAt, + normalizedFactor.NumericValue, + normalizedFactor.TextValue, + normalizedFactor.Gate, + normalizedFactor.ProvenanceJson)); + await _store.AppendDecisionFactorEvidenceAsync(decisionId, factorObservationId, normalizedFactor.Role); } return decisionId; @@ -83,7 +90,36 @@ public sealed class DecisionLearningService excessReturn, outcomeClass, evaluationGate, - JsonSerializer.Serialize(provenance ?? new { }))); + SerializeJson(provenance))); + } + + private static string SerializeJson(object? value) + => JsonSerializer.Serialize(value ?? new { }); + + private static string RequireValue(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Value is required.", parameterName); + } + + return value.Trim(); + } + + private static FactorEvidenceInput NormalizeFactor(FactorEvidenceInput factor) + { + ArgumentNullException.ThrowIfNull(factor); + + return factor with + { + FactorId = RequireValue(factor.FactorId, nameof(factor.FactorId)), + FactorVersion = RequireValue(factor.FactorVersion, nameof(factor.FactorVersion)), + SourceName = RequireValue(factor.SourceName, nameof(factor.SourceName)), + PayloadJson = RequireValue(factor.PayloadJson, nameof(factor.PayloadJson)), + ProvenanceJson = RequireValue(factor.ProvenanceJson, nameof(factor.ProvenanceJson)), + Gate = RequireValue(factor.Gate, nameof(factor.Gate)), + Role = RequireValue(factor.Role, nameof(factor.Role)) + }; } } diff --git a/src/dotnet/QuantEngine.Core.Tests/DecisionLearningServiceTests.cs b/src/dotnet/QuantEngine.Core.Tests/DecisionLearningServiceTests.cs new file mode 100644 index 00000000..cfda2588 --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/DecisionLearningServiceTests.cs @@ -0,0 +1,91 @@ +using Moq; +using QuantEngine.Application.Services; +using QuantEngine.Core.Interfaces; + +namespace QuantEngine.Core.Tests; + +public class DecisionLearningServiceTests +{ + [Fact] + public async Task RecordDecisionAsync_TrimsAndPersistsNormalizedPayloads() + { + var store = new Mock(MockBehavior.Strict); + Guid decisionId = Guid.NewGuid(); + Guid factorObservationId = Guid.NewGuid(); + Guid sourceObservationId = Guid.NewGuid(); + + store.Setup(s => s.AppendDecisionAsync(It.Is(r => + r.DecisionKey == "decision-1" && + r.InstrumentId == "005930" && + r.Action == "BUY" && + r.Gate == "PASS" && + r.SourceVersion == "v1" && + r.TraceJson.Contains("\"mode\":\"test\"") && + r.ProvenanceJson.Contains("\"origin\":\"unit\"")))).ReturnsAsync(decisionId); + + store.Setup(s => s.AppendSourceObservationAsync(It.Is(r => + r.InstrumentId == "005930" && + r.SourceName == "kis" && + r.SourceVersion == "v1" && + r.PayloadJson == "{}" && + r.ProvenanceJson == "{}"))).ReturnsAsync(sourceObservationId); + + store.Setup(s => s.AppendFactorObservationAsync(It.Is(r => + r.ObservationId == sourceObservationId && + r.FactorObservationId != Guid.Empty && + r.FactorId == "factor_a" && + r.FactorVersion == "1.0" && + r.Gate == "PASS" && + r.ProvenanceJson == "{}"))).ReturnsAsync(factorObservationId); + + store.Setup(s => s.AppendDecisionFactorEvidenceAsync(decisionId, factorObservationId, "primary")) + .Returns(Task.CompletedTask); + + var service = new DecisionLearningService(store.Object); + + var result = await service.RecordDecisionAsync( + " decision-1 ", + DateTimeOffset.Parse("2026-07-13T00:00:00Z"), + " 005930 ", + " BUY ", + " PASS ", + 12.5m, + " v1 ", + new[] + { + new FactorEvidenceInput( + Guid.NewGuid(), + " factor_a ", + " 1.0 ", + DateTimeOffset.Parse("2026-07-13T00:00:00Z"), + 3.14m, + null, + " PASS ", + " primary ", + " kis ", + "{}", + "{}") + }, + new { mode = "test" }, + new { origin = "unit" }); + + Assert.Equal(decisionId, result); + store.VerifyAll(); + } + + [Fact] + public async Task RecordDecisionAsync_RejectsMissingFactors() + { + var service = new DecisionLearningService(new Mock().Object); + + await Assert.ThrowsAsync(() => service.RecordDecisionAsync( + "decision-1", + DateTimeOffset.UtcNow, + "005930", + "BUY", + "PASS", + null, + "v1", + null!)); + } +}