refactor(dotnet): normalize decision learning records
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 17s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 1m43s

This commit is contained in:
2026-07-13 01:19:55 +09:00
parent dd08e36a2b
commit 5b9f870ad6
2 changed files with 144 additions and 17 deletions
@@ -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))
};
}
}
@@ -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<INormalizedLearningStore>(MockBehavior.Strict);
Guid decisionId = Guid.NewGuid();
Guid factorObservationId = Guid.NewGuid();
Guid sourceObservationId = Guid.NewGuid();
store.Setup(s => s.AppendDecisionAsync(It.Is<DecisionEventRecord>(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<SourceObservationRecord>(r =>
r.InstrumentId == "005930" &&
r.SourceName == "kis" &&
r.SourceVersion == "v1" &&
r.PayloadJson == "{}" &&
r.ProvenanceJson == "{}"))).ReturnsAsync(sourceObservationId);
store.Setup(s => s.AppendFactorObservationAsync(It.Is<FactorObservationRecord>(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<INormalizedLearningStore>().Object);
await Assert.ThrowsAsync<ArgumentNullException>(() => service.RecordDecisionAsync(
"decision-1",
DateTimeOffset.UtcNow,
"005930",
"BUY",
"PASS",
null,
"v1",
null!));
}
}