Initial commit: Add project files
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
using KArtSell.BuildingBlocks.Time;
|
||||
using KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
namespace KArtSell.Modules.ModelOperations.Application;
|
||||
|
||||
public sealed class ModelOperationRequestService(
|
||||
IApprovedModelContextReader contextReader,
|
||||
IModelOperationRequestRepository repository,
|
||||
IClock clock) : IModelOperationRequestService
|
||||
{
|
||||
public async Task<ModelOperationRequest?> RequestAsync(
|
||||
Guid scheduleId,
|
||||
string operationCode,
|
||||
string scopeKey,
|
||||
string automationMode,
|
||||
string idempotencyKey,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var definition = ModelOperationRegistry.GetRequired(operationCode);
|
||||
ModelOperationExecutionBoundary.EnsureAllowed(definition);
|
||||
if (!definition.AutomationMode.ToContractValue().Equals(automationMode, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("Schedule automation mode does not match the approved operation registry.");
|
||||
|
||||
var now = clock.UtcNow;
|
||||
var context = await contextReader.ReadAsync(scopeKey, now, cancellationToken);
|
||||
if (context is null)
|
||||
return null;
|
||||
|
||||
context.VersionSet.EnsureValid();
|
||||
var request = new ModelOperationRequest(
|
||||
Guid.NewGuid(),
|
||||
scheduleId,
|
||||
definition.OperationCode,
|
||||
scopeKey,
|
||||
definition.AutomationMode.ToContractValue(),
|
||||
idempotencyKey,
|
||||
context,
|
||||
correlationId,
|
||||
now);
|
||||
|
||||
return await repository.AddAsync(request, cancellationToken) ? request : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using KArtSell.BuildingBlocks.Versioning;
|
||||
|
||||
namespace KArtSell.Modules.ModelOperations.Application;
|
||||
|
||||
public sealed record ApprovedModelContext(
|
||||
string ScopeKey,
|
||||
VersionSet VersionSet,
|
||||
string LifecycleState,
|
||||
DateTimeOffset EffectiveAt);
|
||||
|
||||
public sealed record DueModelOperation(
|
||||
Guid ScheduleId,
|
||||
string OperationCode,
|
||||
string ScopeKey,
|
||||
string Cadence,
|
||||
string AutomationMode,
|
||||
string Queue,
|
||||
string IdempotencyKey,
|
||||
int ScheduleVersion,
|
||||
DateTimeOffset ScheduledFor,
|
||||
string CatchUpPolicy,
|
||||
int MaxCatchUp);
|
||||
|
||||
public sealed record ModelOperationRequest(
|
||||
Guid RequestId,
|
||||
Guid ScheduleId,
|
||||
string OperationCode,
|
||||
string ScopeKey,
|
||||
string AutomationMode,
|
||||
string IdempotencyKey,
|
||||
ApprovedModelContext Context,
|
||||
string CorrelationId,
|
||||
DateTimeOffset RequestedAt);
|
||||
|
||||
public interface IApprovedModelContextReader
|
||||
{
|
||||
Task<ApprovedModelContext?> ReadAsync(string scopeKey, DateTimeOffset asOf, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IModelScheduleRepository
|
||||
{
|
||||
Task<IReadOnlyList<DueModelOperation>> AcquireDueAsync(
|
||||
DateTimeOffset now,
|
||||
string leaseOwner,
|
||||
TimeSpan leaseDuration,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task MarkDispatchedAsync(
|
||||
Guid scheduleId,
|
||||
string leaseOwner,
|
||||
string backgroundJobId,
|
||||
DateTimeOffset dispatchedAt,
|
||||
DateTimeOffset nextDueAt,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ReleaseAsync(
|
||||
Guid scheduleId,
|
||||
string leaseOwner,
|
||||
string reasonCode,
|
||||
DateTimeOffset releasedAt,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IModelOperationRequestRepository
|
||||
{
|
||||
Task<bool> AddAsync(ModelOperationRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IModelOperationRequestService
|
||||
{
|
||||
Task<ModelOperationRequest?> RequestAsync(
|
||||
Guid scheduleId,
|
||||
string operationCode,
|
||||
string scopeKey,
|
||||
string automationMode,
|
||||
string idempotencyKey,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Domain;
|
||||
public enum EvaluationReconciliationAction { None, PlanMissing, QuarantineDuplicate, BusinessHoldLate, RecalculateRevision }
|
||||
public sealed record EvaluationWindowFact(int TradingDays, bool Planned, bool Duplicate, bool Matured, bool Evaluated, bool SourceRevisionChanged);
|
||||
public static class EvaluationReconciliationPlanner
|
||||
{
|
||||
private static readonly int[] RequiredWindows = [1,5,20,63,126,252];
|
||||
public static IReadOnlyDictionary<int, EvaluationReconciliationAction> Plan(IEnumerable<EvaluationWindowFact> facts)
|
||||
{
|
||||
var byWindow=facts.GroupBy(x=>x.TradingDays).ToDictionary(x=>x.Key,x=>x.ToArray());
|
||||
var result=new Dictionary<int,EvaluationReconciliationAction>();
|
||||
foreach (var window in RequiredWindows)
|
||||
{
|
||||
if (!byWindow.TryGetValue(window,out var rows) || rows.Length==0) { result[window]=EvaluationReconciliationAction.PlanMissing; continue; }
|
||||
if (rows.Length>1 || rows.Any(x=>x.Duplicate)) { result[window]=EvaluationReconciliationAction.QuarantineDuplicate; continue; }
|
||||
var fact=rows[0];
|
||||
if (fact.SourceRevisionChanged) { result[window]=EvaluationReconciliationAction.RecalculateRevision; continue; }
|
||||
if (fact.Matured && !fact.Evaluated) { result[window]=EvaluationReconciliationAction.BusinessHoldLate; continue; }
|
||||
result[window]=EvaluationReconciliationAction.None;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
public interface ITradingSessionCalendar
|
||||
{
|
||||
DateOnly AddTradingSessions(string calendarId, DateOnly startSession, int sessionCount);
|
||||
}
|
||||
|
||||
public sealed record EvaluationWindowDue(int WindowTradingDays, DateOnly DueSession, string WindowCode);
|
||||
|
||||
public sealed class EvaluationWindowPlanner
|
||||
{
|
||||
public static readonly int[] ApprovedWindows = [1, 5, 20, 63, 126, 252];
|
||||
|
||||
public IReadOnlyList<EvaluationWindowDue> Plan(
|
||||
string calendarId,
|
||||
DateOnly predictionSession,
|
||||
ITradingSessionCalendar calendar)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(calendarId);
|
||||
ArgumentNullException.ThrowIfNull(calendar);
|
||||
return ApprovedWindows
|
||||
.Select(window => new EvaluationWindowDue(
|
||||
window,
|
||||
calendar.AddTradingSessions(calendarId, predictionSession, window),
|
||||
$"W{window:D3}"))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
public sealed record MetricDefinitionVersion(
|
||||
string MetricCode,
|
||||
int DefinitionVersion,
|
||||
string CohortDefinitionHash,
|
||||
string WindowDefinition,
|
||||
string AggregationMethod,
|
||||
string ThresholdContractHash,
|
||||
DateTimeOffset EffectiveFrom)
|
||||
{
|
||||
public void EnsureValid()
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(MetricCode);
|
||||
if (DefinitionVersion <= 0) throw new InvalidOperationException("Metric definition version must be positive.");
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(CohortDefinitionHash);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(WindowDefinition);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(AggregationMethod);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ThresholdContractHash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
public enum ModelLifecycleState
|
||||
{
|
||||
Research,
|
||||
Challenger,
|
||||
Shadow,
|
||||
Candidate,
|
||||
Approved,
|
||||
Retired,
|
||||
RolledBack
|
||||
}
|
||||
|
||||
public enum GateDecision
|
||||
{
|
||||
Pass,
|
||||
Warn,
|
||||
Hold,
|
||||
Fail
|
||||
}
|
||||
|
||||
public sealed record ModelEvaluationSnapshot(
|
||||
string ScopeKey,
|
||||
string ModelVersion,
|
||||
int ShadowTradingDays,
|
||||
int DistinctRegimes,
|
||||
decimal NetExpectedUtility,
|
||||
decimal FalseExitAnnualRate,
|
||||
decimal ReentryCaptureRate,
|
||||
decimal Pbo,
|
||||
decimal Dsr,
|
||||
bool PositiveUnderDoubleCost,
|
||||
int OperationalIntegrityErrors,
|
||||
bool CalibrationStable,
|
||||
DateTimeOffset AsOf);
|
||||
|
||||
public sealed record PromotionGateThresholds(
|
||||
int MinimumShadowTradingDays,
|
||||
int MinimumDistinctRegimes,
|
||||
decimal MaximumFalseExitAnnualRate,
|
||||
decimal MinimumReentryCaptureRate,
|
||||
decimal MaximumPbo,
|
||||
decimal MinimumDsr)
|
||||
{
|
||||
public static PromotionGateThresholds ResearchBaseline { get; } = new(
|
||||
MinimumShadowTradingDays: 252,
|
||||
MinimumDistinctRegimes: 2,
|
||||
MaximumFalseExitAnnualRate: 0.02m,
|
||||
MinimumReentryCaptureRate: 0.65m,
|
||||
MaximumPbo: 0.20m,
|
||||
MinimumDsr: 0.95m);
|
||||
}
|
||||
|
||||
public sealed record PromotionGateResult(
|
||||
GateDecision Decision,
|
||||
IReadOnlyList<string> BlockingReasons,
|
||||
IReadOnlyList<string> Warnings,
|
||||
bool RequiresIndependentValidation,
|
||||
bool RequiresHumanApproval,
|
||||
string Boundary);
|
||||
@@ -0,0 +1,117 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
public enum ModelOperationCadence
|
||||
{
|
||||
Daily,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Quarterly,
|
||||
EventDriven
|
||||
}
|
||||
|
||||
public enum AutomationMode
|
||||
{
|
||||
EvaluationOnly,
|
||||
ProposalOnly,
|
||||
DrillOnly
|
||||
}
|
||||
|
||||
public static class ModelOperationContractValues
|
||||
{
|
||||
public static string ToContractValue(this AutomationMode mode) => mode switch
|
||||
{
|
||||
AutomationMode.EvaluationOnly => "EVALUATION_ONLY",
|
||||
AutomationMode.ProposalOnly => "PROPOSAL_ONLY",
|
||||
AutomationMode.DrillOnly => "DRILL_ONLY",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null)
|
||||
};
|
||||
|
||||
public static string ToContractValue(this ModelOperationCadence cadence) => cadence switch
|
||||
{
|
||||
ModelOperationCadence.Daily => "DAILY",
|
||||
ModelOperationCadence.Weekly => "WEEKLY",
|
||||
ModelOperationCadence.Monthly => "MONTHLY",
|
||||
ModelOperationCadence.Quarterly => "QUARTERLY",
|
||||
ModelOperationCadence.EventDriven => "EVENT_DRIVEN",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(cadence), cadence, null)
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record ModelOperationDefinition(
|
||||
string OperationCode,
|
||||
string Name,
|
||||
ModelOperationCadence Cadence,
|
||||
AutomationMode AutomationMode,
|
||||
string Queue,
|
||||
string PrimaryOwner,
|
||||
string SecondaryOwner,
|
||||
string RequiredEvidence,
|
||||
string Output,
|
||||
string Gate);
|
||||
|
||||
public static class ModelOperationRegistry
|
||||
{
|
||||
public static IReadOnlyList<ModelOperationDefinition> All { get; } =
|
||||
[
|
||||
new("J10", "OutcomeEvaluationRun", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
|
||||
"q-evaluation", "Quant/Ops", "Data/QA", "due 1/5/20/63/126/252 windows", "versioned outcome observations", "G3"),
|
||||
new("J11", "DailyScorecardBuild", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
|
||||
"q-evaluation", "Quant/Ops", "Risk/SRE", "completed outcomes and operational metrics", "daily cohort scorecard", "G3"),
|
||||
new("J17", "DriftDetectionRun", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
|
||||
"q-evaluation", "Quant/Risk", "Data/SRE", "feature, prediction and data-quality baselines", "drift observations and hold alerts", "G4"),
|
||||
new("J18", "ChampionChallengerEvaluation", ModelOperationCadence.Weekly, AutomationMode.EvaluationOnly,
|
||||
"q-evaluation", "Quant/Risk", "QA/InvestmentCommittee", "same frozen data, cost and cohort definitions", "paired champion/challenger comparison", "G4"),
|
||||
new("J19", "FrozenOosBacktest", ModelOperationCadence.Monthly, AutomationMode.EvaluationOnly,
|
||||
"q-research", "Quant/Data", "QA/Risk", "dataset, code, config, seed and environment manifest", "walk-forward OOS evidence bundle", "G4"),
|
||||
new("J20", "RobustnessPboDsrRun", ModelOperationCadence.Quarterly, AutomationMode.EvaluationOnly,
|
||||
"q-research", "Quant/Risk", "IndependentValidation", "experiment registry, CSCV and block-bootstrap inputs", "PBO/DSR/stability/cost-stress report", "G4"),
|
||||
new("J21", "ModelImprovementProposalBuild", ModelOperationCadence.Monthly, AutomationMode.ProposalOnly,
|
||||
"q-research", "Quant Lead", "Risk/Architect", "scorecard, drift, attribution and debt evidence", "review-required improvement proposal", "G4"),
|
||||
new("J22", "PromotionEvidenceReviewBuild", ModelOperationCadence.Monthly, AutomationMode.ProposalOnly,
|
||||
"q-control", "Risk/InvestmentCommittee", "Compliance/QA", "all promotion-gate evidence and independent validation", "maker-checker review packet", "G4"),
|
||||
new("J23", "ModelRollbackDrill", ModelOperationCadence.Quarterly, AutomationMode.DrillOnly,
|
||||
"q-control", "SRE/Risk", "Module Owner/QA", "approved champion manifest and rollback runbook", "rollback drill evidence", "G5"),
|
||||
new("J24", "DataRevisionRevalidation", ModelOperationCadence.Weekly, AutomationMode.EvaluationOnly,
|
||||
"q-backfill", "Data/Quant", "DBA/QA", "revision lineage and affected-decision index", "replay impact and correction proposal", "G3"),
|
||||
new("J25", "SourceContractDriftCheck", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
|
||||
"q-evaluation", "Data Governance", "Adapter Owner/QA", "approved source schema, license and SLA snapshot", "source contract drift evidence and hold", "G1"),
|
||||
new("J26", "MarketCalendarCompletenessCheck", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
|
||||
"q-market-data", "Data/Ops", "Quant/QA", "approved KRX/NYSE/NASDAQ calendar and source completeness", "tradable-session readiness evidence", "G1"),
|
||||
new("J27", "EvidenceChainAudit", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
|
||||
"q-control", "Compliance/QA", "Data/BE", "decision-to-source hash chain and immutable audit records", "missing-link and mutation evidence", "G3"),
|
||||
new("J28", "ProjectionFreshnessCheck", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
|
||||
"q-evaluation", "BE/Data", "SRE/QA", "projection version, watermark and rebuild contract", "staleness and rebuild-diff evidence", "G3"),
|
||||
new("J29", "CapacitySlaTrend", ModelOperationCadence.Weekly, AutomationMode.EvaluationOnly,
|
||||
"q-control", "SRE/PM", "DBA/Module Owner", "approved volume assumptions and SLO definitions", "capacity trend and scaling decision packet", "G5"),
|
||||
new("J30", "ReleaseEvidenceAssemble", ModelOperationCadence.EventDriven, AutomationMode.ProposalOnly,
|
||||
"q-control", "QA/Release Manager", "Compliance/SRE", "approved build, test, migration, security and rollback artifacts", "review-required release evidence bundle", "G6"),
|
||||
new("J31", "PredictionFreezeRun", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
|
||||
"q-evaluation", "Quant/Data", "QA/Risk", "approved PIT context before market cutoff", "immutable prediction freeze and VersionSet hash", "G3"),
|
||||
new("J32", "OutcomeMaturitySweep", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
|
||||
"q-evaluation", "Quant/Ops", "Data/QA", "trading-session calendar and due evaluation windows", "matured 1/5/20/63/126/252 evaluation requests", "G3"),
|
||||
new("J33", "SellReentryAttributionReview", ModelOperationCadence.Weekly, AutomationMode.EvaluationOnly,
|
||||
"q-evaluation", "Quant/Risk", "Advisor/QA", "matured sell and reentry outcomes with cost and benchmark", "false-exit, gain-capture, capture-rate and delay-cost evidence", "G4"),
|
||||
new("J34", "CalibrationRegimeReview", ModelOperationCadence.Weekly, AutomationMode.EvaluationOnly,
|
||||
"q-evaluation", "Quant/Risk", "Data/IndependentValidation", "versioned cohort, regime and prediction distribution definitions", "calibration, coverage, drift and regime stability evidence", "G4"),
|
||||
new("J35", "ImprovementHypothesisBuild", ModelOperationCadence.Monthly, AutomationMode.ProposalOnly,
|
||||
"q-research", "Quant Lead", "Risk/Architect", "scorecard, attribution, drift, incidents and debt evidence", "review-required improvement hypothesis with falsification test", "G4"),
|
||||
new("J36", "ChallengerExperimentPlan", ModelOperationCadence.Monthly, AutomationMode.ProposalOnly,
|
||||
"q-research", "Quant Lead", "IndependentValidation/QA", "approved hypothesis and frozen experiment registry", "review-required challenger experiment plan; no activation", "G4"),
|
||||
new("J37", "IndependentValidationPack", ModelOperationCadence.Quarterly, AutomationMode.ProposalOnly,
|
||||
"q-control", "IndependentValidation", "Risk/Compliance", "OOS, PBO, DSR, cost stress, reproducibility and operations evidence", "independent validation decision packet", "G4"),
|
||||
new("J38", "ModelPolicyDebtReview", ModelOperationCadence.Monthly, AutomationMode.ProposalOnly,
|
||||
"q-control", "Risk/Architect", "Quant/PM", "model, policy, data and technical debt register", "prioritized remediation and expiration decisions", "G5"),
|
||||
new("J39", "FeedbackCycleIntegrityAudit", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
|
||||
"q-control", "Compliance/QA", "Quant/SRE", "feedback-cycle state, transition sequence, evidence hashes and aging policy", "stuck-cycle, illegal-transition and evidence-gap report", "G4"),
|
||||
new("J40", "EvaluationWindowIntegrityAudit", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
|
||||
"q-control", "Quant/QA", "Data/SRE", "prediction evidence, approved trading calendar and 1/5/20/63/126/252 window plan", "missing, duplicate, late and calendar-drift window report", "G4"),
|
||||
new("J41", "SchedulerLeaseIntegrityAudit", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
|
||||
"q-control", "SRE/QA", "BE/DBA", "schedule leases, fencing tokens, heartbeat and dispatch revisions", "expired, overlapping and stale-token lease report", "G3"),
|
||||
new("J42", "ModelEvaluationReconciliation", ModelOperationCadence.Daily, AutomationMode.EvaluationOnly,
|
||||
"q-evaluation", "Quant/QA", "Data/Risk", "prediction windows, outcomes, source revisions and metric definitions", "missing, duplicate, late and revision-recalculation plan", "G4")
|
||||
];
|
||||
|
||||
public static ModelOperationDefinition GetRequired(string operationCode)
|
||||
=> All.SingleOrDefault(x => x.OperationCode.Equals(operationCode, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new ArgumentOutOfRangeException(nameof(operationCode), operationCode, "Unknown model operation code.");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
public enum ModelOperationExecutionState
|
||||
{
|
||||
Requested,
|
||||
Running,
|
||||
Succeeded,
|
||||
BusinessHold,
|
||||
Failed,
|
||||
Quarantined
|
||||
}
|
||||
|
||||
public sealed record ModelOperationExecutionTransition(
|
||||
ModelOperationExecutionState From,
|
||||
ModelOperationExecutionState To,
|
||||
string ReasonCode,
|
||||
string EvidenceHash,
|
||||
DateTimeOffset OccurredAt);
|
||||
|
||||
public sealed class ModelOperationExecution
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<ModelOperationExecutionState, IReadOnlySet<ModelOperationExecutionState>> Allowed =
|
||||
new Dictionary<ModelOperationExecutionState, IReadOnlySet<ModelOperationExecutionState>>
|
||||
{
|
||||
[ModelOperationExecutionState.Requested] = Set(ModelOperationExecutionState.Running, ModelOperationExecutionState.BusinessHold, ModelOperationExecutionState.Quarantined),
|
||||
[ModelOperationExecutionState.Running] = Set(ModelOperationExecutionState.Succeeded, ModelOperationExecutionState.BusinessHold, ModelOperationExecutionState.Failed, ModelOperationExecutionState.Quarantined),
|
||||
[ModelOperationExecutionState.BusinessHold] = Set(ModelOperationExecutionState.Running, ModelOperationExecutionState.Quarantined),
|
||||
[ModelOperationExecutionState.Failed] = Set(ModelOperationExecutionState.Running, ModelOperationExecutionState.Quarantined),
|
||||
[ModelOperationExecutionState.Succeeded] = new HashSet<ModelOperationExecutionState>(),
|
||||
[ModelOperationExecutionState.Quarantined] = new HashSet<ModelOperationExecutionState>()
|
||||
};
|
||||
|
||||
private readonly List<ModelOperationExecutionTransition> transitions = [];
|
||||
|
||||
public ModelOperationExecution(Guid requestId, string idempotencyKey, DateTimeOffset requestedAt)
|
||||
{
|
||||
if (requestId == Guid.Empty) throw new ArgumentException("Request ID is required.", nameof(requestId));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey);
|
||||
RequestId = requestId;
|
||||
IdempotencyKey = idempotencyKey;
|
||||
RequestedAt = requestedAt;
|
||||
LastOccurredAt = requestedAt;
|
||||
State = ModelOperationExecutionState.Requested;
|
||||
}
|
||||
|
||||
public Guid RequestId { get; }
|
||||
public string IdempotencyKey { get; }
|
||||
public DateTimeOffset RequestedAt { get; }
|
||||
public DateTimeOffset LastOccurredAt { get; private set; }
|
||||
public ModelOperationExecutionState State { get; private set; }
|
||||
public IReadOnlyList<ModelOperationExecutionTransition> Transitions => transitions;
|
||||
|
||||
public ModelOperationExecutionTransition MoveTo(ModelOperationExecutionState next, string reasonCode, string evidenceHash, DateTimeOffset occurredAt)
|
||||
{
|
||||
if (!Allowed[State].Contains(next)) throw new InvalidOperationException($"Execution transition {State} -> {next} is not allowed.");
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reasonCode);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceHash);
|
||||
if (occurredAt < LastOccurredAt) throw new InvalidOperationException("Execution transition time cannot move backwards.");
|
||||
var transition = new ModelOperationExecutionTransition(State, next, reasonCode, evidenceHash, occurredAt);
|
||||
transitions.Add(transition);
|
||||
State = next;
|
||||
LastOccurredAt = occurredAt;
|
||||
return transition;
|
||||
}
|
||||
|
||||
private static IReadOnlySet<ModelOperationExecutionState> Set(params ModelOperationExecutionState[] states) => new HashSet<ModelOperationExecutionState>(states);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Central fail-closed boundary for scheduled model operations. The scheduler may request evidence,
|
||||
/// proposals or drills only; it may never mutate model/code/configuration, publish client advice,
|
||||
/// submit broker orders or change a model lifecycle state.
|
||||
/// </summary>
|
||||
public static class ModelOperationExecutionBoundary
|
||||
{
|
||||
public const string BoundaryCode = "EVIDENCE_ONLY_NO_AUTO_MODEL_OR_ORDER_MUTATION";
|
||||
|
||||
public static void EnsureAllowed(ModelOperationDefinition definition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definition);
|
||||
|
||||
if (definition.AutomationMode is not (
|
||||
AutomationMode.EvaluationOnly or AutomationMode.ProposalOnly or AutomationMode.DrillOnly))
|
||||
{
|
||||
throw new InvalidOperationException($"Operation {definition.OperationCode} violates {BoundaryCode}.");
|
||||
}
|
||||
|
||||
if (!definition.Queue.StartsWith("q-", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"Operation {definition.OperationCode} has an unapproved queue.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Domain;
|
||||
public readonly record struct LeaseFencingToken(long Value)
|
||||
{
|
||||
public static LeaseFencingToken Next(LeaseFencingToken current) => new(checked(current.Value + 1));
|
||||
}
|
||||
public sealed class ModelOperationLease
|
||||
{
|
||||
public string OperationCode { get; }
|
||||
public string ScopeKey { get; }
|
||||
public LeaseFencingToken Token { get; private set; }
|
||||
public DateTimeOffset ExpiresAt { get; private set; }
|
||||
public string Owner { get; private set; }
|
||||
public ModelOperationLease(string operationCode, string scopeKey, LeaseFencingToken token, string owner, DateTimeOffset expiresAt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(operationCode) || string.IsNullOrWhiteSpace(scopeKey) || string.IsNullOrWhiteSpace(owner)) throw new ArgumentException("Lease identity is required.");
|
||||
OperationCode=operationCode; ScopeKey=scopeKey; Token=token; Owner=owner; ExpiresAt=expiresAt;
|
||||
}
|
||||
public void Renew(LeaseFencingToken expectedToken, string owner, DateTimeOffset newExpiresAt)
|
||||
{
|
||||
if (expectedToken != Token || owner != Owner) throw new InvalidOperationException("Stale lease fencing token or owner.");
|
||||
if (newExpiresAt <= ExpiresAt) throw new InvalidOperationException("Lease renewal must extend expiry.");
|
||||
ExpiresAt=newExpiresAt;
|
||||
}
|
||||
public LeaseFencingToken Transfer(DateTimeOffset now, string newOwner, DateTimeOffset newExpiresAt)
|
||||
{
|
||||
if (now < ExpiresAt) throw new InvalidOperationException("Active lease cannot be transferred.");
|
||||
if (newExpiresAt <= now) throw new InvalidOperationException("New lease must expire in the future.");
|
||||
Token=LeaseFencingToken.Next(Token); Owner=newOwner; ExpiresAt=newExpiresAt; return Token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence gate only. It never activates, promotes, rolls back or mutates a model.
|
||||
/// </summary>
|
||||
public sealed class PromotionGateEvaluator
|
||||
{
|
||||
public PromotionGateResult Evaluate(ModelEvaluationSnapshot snapshot, PromotionGateThresholds thresholds)
|
||||
{
|
||||
var blockers = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
if (snapshot.OperationalIntegrityErrors != 0)
|
||||
blockers.Add("Operational integrity errors must be zero.");
|
||||
if (snapshot.ShadowTradingDays < thresholds.MinimumShadowTradingDays)
|
||||
blockers.Add($"Shadow evidence requires at least {thresholds.MinimumShadowTradingDays} trading days.");
|
||||
if (snapshot.DistinctRegimes < thresholds.MinimumDistinctRegimes)
|
||||
blockers.Add($"At least {thresholds.MinimumDistinctRegimes} distinct market regimes are required.");
|
||||
if (snapshot.NetExpectedUtility <= 0)
|
||||
blockers.Add("Net expected utility after costs must be positive.");
|
||||
if (!snapshot.CalibrationStable)
|
||||
blockers.Add("Calibration is not stable against the approved baseline.");
|
||||
if (snapshot.FalseExitAnnualRate > thresholds.MaximumFalseExitAnnualRate)
|
||||
blockers.Add("False-exit annual rate exceeds the approved threshold.");
|
||||
if (snapshot.ReentryCaptureRate < thresholds.MinimumReentryCaptureRate)
|
||||
blockers.Add("Reentry capture rate is below the approved threshold.");
|
||||
if (snapshot.Pbo > thresholds.MaximumPbo)
|
||||
blockers.Add("Probability of backtest overfitting exceeds the approved threshold.");
|
||||
if (snapshot.Dsr < thresholds.MinimumDsr)
|
||||
blockers.Add("Deflated Sharpe Ratio is below the approved threshold.");
|
||||
if (!snapshot.PositiveUnderDoubleCost)
|
||||
blockers.Add("Expected utility is not positive under the 2x cost stress.");
|
||||
|
||||
if (snapshot.ShadowTradingDays < 504)
|
||||
warnings.Add("Evidence is below the preferred 24-month horizon.");
|
||||
|
||||
return new PromotionGateResult(
|
||||
blockers.Count == 0 ? GateDecision.Pass : GateDecision.Hold,
|
||||
blockers,
|
||||
warnings,
|
||||
RequiresIndependentValidation: true,
|
||||
RequiresHumanApproval: true,
|
||||
Boundary: "EVIDENCE_ONLY_NO_AUTO_PROMOTION");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
public sealed class ScheduleOccurrencePlanner
|
||||
{
|
||||
public DateTimeOffset GetNextDueAt(
|
||||
DateTimeOffset scheduledFor,
|
||||
string cadence,
|
||||
string catchUpPolicy,
|
||||
int maxCatchUp,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
if (maxCatchUp is < 0 or > 31) throw new ArgumentOutOfRangeException(nameof(maxCatchUp));
|
||||
var next = Advance(scheduledFor, cadence);
|
||||
if (catchUpPolicy.Equals("ALL_WITH_LIMIT", StringComparison.OrdinalIgnoreCase))
|
||||
return next;
|
||||
|
||||
if (!catchUpPolicy.Equals("LATEST_ONLY", StringComparison.OrdinalIgnoreCase)
|
||||
&& !catchUpPolicy.Equals("SKIP_MISSED", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"Unsupported catch-up policy '{catchUpPolicy}'.");
|
||||
|
||||
var guard = 0;
|
||||
while (next <= now)
|
||||
{
|
||||
next = Advance(next, cadence);
|
||||
if (++guard > 5000) throw new InvalidOperationException("Schedule catch-up exceeded safety guard.");
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private static DateTimeOffset Advance(DateTimeOffset value, string cadence) => cadence.ToUpperInvariant() switch
|
||||
{
|
||||
"DAILY" => value.AddDays(1),
|
||||
"WEEKLY" => value.AddDays(7),
|
||||
"MONTHLY" => value.AddMonths(1),
|
||||
"QUARTERLY" => value.AddMonths(3),
|
||||
"EVENT_DRIVEN" => value.AddYears(100),
|
||||
_ => throw new InvalidOperationException($"Unsupported cadence '{cadence}'.")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using FastEndpoints;
|
||||
using KArtSell.Modules.ModelOperations.FeedbackLoop;
|
||||
|
||||
namespace KArtSell.Modules.ModelOperations.Features.GetModelFeedbackLoop;
|
||||
|
||||
public sealed class Endpoint : EndpointWithoutRequest<Response>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/model-operations/feedback-loop");
|
||||
Roles("Quant", "Risk", "Compliance", "Operations", "Administrator");
|
||||
Summary(x =>
|
||||
{
|
||||
x.Summary = "Returns the governed continuous model feedback plan.";
|
||||
x.Description = "This endpoint exposes evaluation/proposal stages only. It cannot activate a model or submit an order.";
|
||||
});
|
||||
}
|
||||
|
||||
public override Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var stages = ModelFeedbackPlan.Stages.Select(x => new ModelFeedbackStageResponse(
|
||||
x.Order, x.StageCode, x.OperationCode, x.Name, x.AutomationBoundary,
|
||||
x.RequiredEvidence, x.HumanDecision, x.FailureAction)).ToArray();
|
||||
return Send.OkAsync(new Response(ModelFeedbackPlan.Boundary, stages), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Features.GetModelFeedbackLoop;
|
||||
|
||||
public sealed record ModelFeedbackStageResponse(
|
||||
int Order,
|
||||
string StageCode,
|
||||
string OperationCode,
|
||||
string Name,
|
||||
string AutomationBoundary,
|
||||
string RequiredEvidence,
|
||||
string HumanDecision,
|
||||
string FailureAction);
|
||||
|
||||
public sealed record Response(string Boundary, IReadOnlyList<ModelFeedbackStageResponse> Stages);
|
||||
@@ -0,0 +1,35 @@
|
||||
using FastEndpoints;
|
||||
using KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
namespace KArtSell.Modules.ModelOperations.Features.GetModelOperationsPlan;
|
||||
|
||||
public sealed class Endpoint : EndpointWithoutRequest<Response>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/internal/v1/model-operations/plan");
|
||||
Roles("Quant", "Risk", "System", "Auditor");
|
||||
Description(x => x.WithTags("ModelOperations"));
|
||||
}
|
||||
|
||||
public override Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var items = ModelOperationRegistry.All.Select(x => new OperationItem(
|
||||
x.OperationCode,
|
||||
x.Name,
|
||||
x.Cadence.ToContractValue(),
|
||||
x.AutomationMode.ToContractValue(),
|
||||
x.Queue,
|
||||
x.PrimaryOwner,
|
||||
x.SecondaryOwner,
|
||||
x.RequiredEvidence,
|
||||
x.Output,
|
||||
x.Gate)).ToArray();
|
||||
|
||||
return Send.OkAsync(new Response(
|
||||
"RESEARCH_CANDIDATE_NOT_PRODUCTION",
|
||||
"AUTOMATIC_ORDER_AND_KIS_SUBMISSION_OFF",
|
||||
"EVALUATION_AND_PROPOSAL_ONLY_HUMAN_APPROVAL_REQUIRED",
|
||||
items), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Features.GetModelOperationsPlan;
|
||||
|
||||
public sealed record OperationItem(
|
||||
string OperationCode,
|
||||
string Name,
|
||||
string Cadence,
|
||||
string AutomationMode,
|
||||
string Queue,
|
||||
string PrimaryOwner,
|
||||
string SecondaryOwner,
|
||||
string RequiredEvidence,
|
||||
string Output,
|
||||
string Gate);
|
||||
|
||||
public sealed record Response(
|
||||
string AlgorithmStatus,
|
||||
string OrderCapability,
|
||||
string ModelMutationBoundary,
|
||||
IReadOnlyList<OperationItem> Operations);
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace KArtSell.Modules.ModelOperations.FeedbackLoop;
|
||||
|
||||
public enum ModelFeedbackCycleState
|
||||
{
|
||||
Planned,
|
||||
PredictionFrozen,
|
||||
OutcomesMaturing,
|
||||
Evaluated,
|
||||
ImprovementProposed,
|
||||
ChallengerPlanned,
|
||||
IndependentlyValidated,
|
||||
PromotionReviewPending,
|
||||
BusinessHold,
|
||||
Closed
|
||||
}
|
||||
|
||||
public sealed record ModelFeedbackTransition(
|
||||
ModelFeedbackCycleState From,
|
||||
ModelFeedbackCycleState To,
|
||||
string ReasonCode,
|
||||
string EvidenceHash,
|
||||
string ActorId,
|
||||
DateTimeOffset OccurredAt);
|
||||
|
||||
public sealed class ModelFeedbackCycle
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<ModelFeedbackCycleState, IReadOnlySet<ModelFeedbackCycleState>> Allowed =
|
||||
new Dictionary<ModelFeedbackCycleState, IReadOnlySet<ModelFeedbackCycleState>>
|
||||
{
|
||||
[ModelFeedbackCycleState.Planned] = Set(ModelFeedbackCycleState.PredictionFrozen, ModelFeedbackCycleState.BusinessHold),
|
||||
[ModelFeedbackCycleState.PredictionFrozen] = Set(ModelFeedbackCycleState.OutcomesMaturing, ModelFeedbackCycleState.BusinessHold),
|
||||
[ModelFeedbackCycleState.OutcomesMaturing] = Set(ModelFeedbackCycleState.Evaluated, ModelFeedbackCycleState.BusinessHold),
|
||||
[ModelFeedbackCycleState.Evaluated] = Set(ModelFeedbackCycleState.ImprovementProposed, ModelFeedbackCycleState.Closed, ModelFeedbackCycleState.BusinessHold),
|
||||
[ModelFeedbackCycleState.ImprovementProposed] = Set(ModelFeedbackCycleState.ChallengerPlanned, ModelFeedbackCycleState.Closed, ModelFeedbackCycleState.BusinessHold),
|
||||
[ModelFeedbackCycleState.ChallengerPlanned] = Set(ModelFeedbackCycleState.IndependentlyValidated, ModelFeedbackCycleState.BusinessHold),
|
||||
[ModelFeedbackCycleState.IndependentlyValidated] = Set(ModelFeedbackCycleState.PromotionReviewPending, ModelFeedbackCycleState.Closed, ModelFeedbackCycleState.BusinessHold),
|
||||
[ModelFeedbackCycleState.PromotionReviewPending] = Set(ModelFeedbackCycleState.Closed, ModelFeedbackCycleState.BusinessHold),
|
||||
[ModelFeedbackCycleState.BusinessHold] = Set(ModelFeedbackCycleState.Planned, ModelFeedbackCycleState.Closed),
|
||||
[ModelFeedbackCycleState.Closed] = new HashSet<ModelFeedbackCycleState>()
|
||||
};
|
||||
|
||||
private readonly List<ModelFeedbackTransition> transitions = [];
|
||||
|
||||
public ModelFeedbackCycle(Guid cycleId, string scopeKey, string baseModelVersion, DateTimeOffset startedAt)
|
||||
{
|
||||
if (cycleId == Guid.Empty) throw new ArgumentException("Cycle ID is required.", nameof(cycleId));
|
||||
if (string.IsNullOrWhiteSpace(scopeKey)) throw new ArgumentException("Scope key is required.", nameof(scopeKey));
|
||||
if (string.IsNullOrWhiteSpace(baseModelVersion)) throw new ArgumentException("Base model version is required.", nameof(baseModelVersion));
|
||||
|
||||
CycleId = cycleId;
|
||||
ScopeKey = scopeKey;
|
||||
BaseModelVersion = baseModelVersion;
|
||||
StartedAt = startedAt;
|
||||
State = ModelFeedbackCycleState.Planned;
|
||||
Revision = 1;
|
||||
LastOccurredAt = startedAt;
|
||||
}
|
||||
|
||||
public Guid CycleId { get; }
|
||||
public string ScopeKey { get; }
|
||||
public string BaseModelVersion { get; }
|
||||
public DateTimeOffset StartedAt { get; }
|
||||
public ModelFeedbackCycleState State { get; private set; }
|
||||
public int Revision { get; private set; }
|
||||
public DateTimeOffset LastOccurredAt { get; private set; }
|
||||
public DateTimeOffset? ClosedAt { get; private set; }
|
||||
public IReadOnlyList<ModelFeedbackTransition> Transitions => transitions;
|
||||
|
||||
public ModelFeedbackTransition MoveTo(
|
||||
ModelFeedbackCycleState next,
|
||||
string reasonCode,
|
||||
string evidenceHash,
|
||||
string actorId,
|
||||
DateTimeOffset occurredAt)
|
||||
{
|
||||
if (!Allowed[State].Contains(next))
|
||||
throw new InvalidOperationException($"Feedback cycle transition {State} -> {next} is not allowed.");
|
||||
if (string.IsNullOrWhiteSpace(reasonCode)) throw new ArgumentException("Reason code is required.", nameof(reasonCode));
|
||||
if (string.IsNullOrWhiteSpace(evidenceHash)) throw new ArgumentException("Evidence hash is required.", nameof(evidenceHash));
|
||||
if (string.IsNullOrWhiteSpace(actorId)) throw new ArgumentException("Actor ID is required.", nameof(actorId));
|
||||
if (occurredAt < LastOccurredAt) throw new InvalidOperationException("Feedback transition time cannot move backwards.");
|
||||
|
||||
var transition = new ModelFeedbackTransition(State, next, reasonCode, evidenceHash, actorId, occurredAt);
|
||||
transitions.Add(transition);
|
||||
State = next;
|
||||
Revision++;
|
||||
LastOccurredAt = occurredAt;
|
||||
if (next == ModelFeedbackCycleState.Closed) ClosedAt = occurredAt;
|
||||
return transition;
|
||||
}
|
||||
|
||||
private static IReadOnlySet<ModelFeedbackCycleState> Set(params ModelFeedbackCycleState[] states)
|
||||
=> new HashSet<ModelFeedbackCycleState>(states);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace KArtSell.Modules.ModelOperations.FeedbackLoop;
|
||||
|
||||
public sealed record ModelFeedbackStage(
|
||||
int Order,
|
||||
string StageCode,
|
||||
string OperationCode,
|
||||
string Name,
|
||||
string AutomationBoundary,
|
||||
string RequiredEvidence,
|
||||
string HumanDecision,
|
||||
string FailureAction);
|
||||
|
||||
public static class ModelFeedbackPlan
|
||||
{
|
||||
public const string Boundary = "EVALUATION_AND_PROPOSAL_ONLY; NO_AUTOMATIC_MODEL_ACTIVATION; NO_AUTOMATIC_ORDER_SUBMISSION";
|
||||
|
||||
public static IReadOnlyList<ModelFeedbackStage> Stages { get; } =
|
||||
[
|
||||
new(1, "FREEZE", "J31", "PredictionFreezeRun", "EVALUATION_ONLY", "PIT cutoff, Dataset/Model/Config/Code SHA", "None", "BUSINESS_HOLD"),
|
||||
new(2, "MATURE", "J32", "OutcomeMaturitySweep", "EVALUATION_ONLY", "1/5/20/63/126/252 trading-session windows", "None", "BUSINESS_HOLD"),
|
||||
new(3, "SCORE", "J10/J11", "OutcomeEvaluationAndScorecard", "EVALUATION_ONLY", "versioned outcomes, metric definition and cohort", "None", "BUSINESS_HOLD"),
|
||||
new(4, "DIAGNOSE", "J17/J33/J34", "DriftAttributionCalibrationReview", "EVALUATION_ONLY", "drift, false-exit, gain-capture, reentry and calibration", "None", "BUSINESS_HOLD"),
|
||||
new(5, "HYPOTHESIS", "J35", "ImprovementHypothesisBuild", "PROPOSAL_ONLY", "supporting and counter evidence, falsification test", "Quant/Risk review", "CLOSE_OR_HOLD"),
|
||||
new(6, "CHALLENGER", "J36", "ChallengerExperimentPlan", "PROPOSAL_ONLY", "frozen registry, OOS plan, cost stress and stop criteria", "Independent validation approval", "CLOSE_OR_HOLD"),
|
||||
new(7, "VALIDATE", "J19/J20/J37", "FrozenOosAndIndependentValidation", "EVALUATION_AND_PROPOSAL_ONLY", "reproducibility, PBO, DSR, stability, double-cost", "Independent validator decision", "REJECT_OR_HOLD"),
|
||||
new(8, "REVIEW", "J22", "PromotionEvidenceReviewBuild", "PROPOSAL_ONLY", "complete gate pack, rollback and operational evidence", "Maker-checker investment/risk/compliance approval", "REJECT_OR_EXPIRE"),
|
||||
new(9, "ACTIVATE", "MANUAL_ONLY", "ControlledModelActivation", "AUTOMATION_FORBIDDEN", "approved effective_at, capability gate and rollback", "Separate human change approval", "KEEP_CURRENT_CHAMPION")
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace KArtSell.Modules.ModelOperations.FeedbackLoop;
|
||||
|
||||
public enum EvidenceClassification
|
||||
{
|
||||
Source,
|
||||
Assumption,
|
||||
Unknown,
|
||||
DecisionRequired
|
||||
}
|
||||
|
||||
public sealed record HypothesisEvidence(
|
||||
EvidenceClassification Classification,
|
||||
string Reference,
|
||||
string Statement,
|
||||
string ContentHash);
|
||||
|
||||
public sealed record ModelImprovementHypothesis(
|
||||
Guid HypothesisId,
|
||||
string ScopeKey,
|
||||
string BaseModelVersion,
|
||||
string ProblemStatement,
|
||||
string ProposedChange,
|
||||
string FalsificationCriterion,
|
||||
IReadOnlyList<HypothesisEvidence> SupportingEvidence,
|
||||
IReadOnlyList<HypothesisEvidence> CounterEvidence,
|
||||
string Owner,
|
||||
DateTimeOffset ExpiresAt)
|
||||
{
|
||||
public IReadOnlyList<string> Validate(DateTimeOffset now)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (HypothesisId == Guid.Empty) errors.Add("HYPOTHESIS_ID_REQUIRED");
|
||||
if (string.IsNullOrWhiteSpace(ScopeKey)) errors.Add("SCOPE_REQUIRED");
|
||||
if (string.IsNullOrWhiteSpace(BaseModelVersion)) errors.Add("BASE_MODEL_REQUIRED");
|
||||
if (string.IsNullOrWhiteSpace(ProblemStatement)) errors.Add("PROBLEM_REQUIRED");
|
||||
if (string.IsNullOrWhiteSpace(ProposedChange)) errors.Add("CHANGE_REQUIRED");
|
||||
if (string.IsNullOrWhiteSpace(FalsificationCriterion)) errors.Add("FALSIFICATION_TEST_REQUIRED");
|
||||
if (SupportingEvidence.Count == 0) errors.Add("SUPPORTING_EVIDENCE_REQUIRED");
|
||||
if (CounterEvidence.Count == 0) errors.Add("COUNTER_EVIDENCE_REQUIRED");
|
||||
if (SupportingEvidence.Concat(CounterEvidence).Any(x => x.Classification == EvidenceClassification.Unknown))
|
||||
errors.Add("UNKNOWN_EVIDENCE_MUST_BE_RESOLVED_OR_DOWNGRADED");
|
||||
if (SupportingEvidence.Concat(CounterEvidence).Any(x => x.Classification == EvidenceClassification.DecisionRequired))
|
||||
errors.Add("DECISION_REQUIRED_BLOCKS_EXPERIMENT");
|
||||
if (ExpiresAt <= now) errors.Add("HYPOTHESIS_EXPIRED");
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
using Dapper;
|
||||
using KArtSell.BuildingBlocks.Data;
|
||||
using KArtSell.BuildingBlocks.Versioning;
|
||||
using KArtSell.Modules.ModelOperations.Application;
|
||||
|
||||
namespace KArtSell.Modules.ModelOperations.Infrastructure;
|
||||
|
||||
public sealed class DapperApprovedModelContextReader(IDbConnectionFactory connectionFactory) : IApprovedModelContextReader
|
||||
{
|
||||
private const string Sql = """
|
||||
select mv.scope_key as ScopeKey,
|
||||
mv.model_version as ModelVersion,
|
||||
mv.config_version as ConfigVersion,
|
||||
mv.code_sha as CodeSha,
|
||||
mv.contract_version as ContractVersion,
|
||||
mv.lifecycle_state as LifecycleState,
|
||||
mv.effective_at as EffectiveAt,
|
||||
dm.dataset_id as DatasetId,
|
||||
dm.content_hash as DataHash
|
||||
from governance.model_version_registry mv
|
||||
join lateral (
|
||||
select dataset_id, content_hash
|
||||
from evaluation.dataset_manifest
|
||||
where scope_key = mv.scope_key
|
||||
and status = 'APPROVED'
|
||||
and frozen_at <= @AsOf
|
||||
order by frozen_at desc
|
||||
limit 1
|
||||
) dm on true
|
||||
where mv.scope_key = @ScopeKey
|
||||
and mv.lifecycle_state in ('RESEARCH', 'CHALLENGER', 'SHADOW', 'CANDIDATE', 'APPROVED')
|
||||
and mv.effective_at <= @AsOf
|
||||
order by mv.effective_at desc
|
||||
limit 1;
|
||||
""";
|
||||
|
||||
public async Task<ApprovedModelContext?> ReadAsync(
|
||||
string scopeKey,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
var row = await connection.QuerySingleOrDefaultAsync<Row>(new CommandDefinition(
|
||||
Sql,
|
||||
new { ScopeKey = scopeKey, AsOf = asOf },
|
||||
cancellationToken: cancellationToken));
|
||||
|
||||
if (row is null)
|
||||
return null;
|
||||
|
||||
return new ApprovedModelContext(
|
||||
row.ScopeKey,
|
||||
new VersionSet(row.DatasetId, row.DataHash, row.ModelVersion, row.ConfigVersion, row.CodeSha, row.ContractVersion),
|
||||
row.LifecycleState,
|
||||
row.EffectiveAt);
|
||||
}
|
||||
|
||||
private sealed record Row(
|
||||
string ScopeKey,
|
||||
string DatasetId,
|
||||
string DataHash,
|
||||
string ModelVersion,
|
||||
string ConfigVersion,
|
||||
string CodeSha,
|
||||
string ContractVersion,
|
||||
string LifecycleState,
|
||||
DateTimeOffset EffectiveAt);
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using KArtSell.BuildingBlocks.Data;
|
||||
using KArtSell.BuildingBlocks.Hashing;
|
||||
using KArtSell.BuildingBlocks.Reliability;
|
||||
using KArtSell.Modules.ModelOperations.Application;
|
||||
|
||||
namespace KArtSell.Modules.ModelOperations.Infrastructure;
|
||||
|
||||
public sealed class DapperModelOperationRequestRepository(
|
||||
IDbConnectionFactory connectionFactory,
|
||||
IOutboxWriter outboxWriter) : IModelOperationRequestRepository
|
||||
{
|
||||
private const string InsertSql = """
|
||||
insert into evaluation.model_operation_request
|
||||
(request_id, schedule_id, operation_code, scope_key, automation_mode, idempotency_key,
|
||||
dataset_id, data_hash, model_version, config_version, code_sha, contract_version,
|
||||
lifecycle_state, correlation_id, status, requested_at)
|
||||
values
|
||||
(@RequestId, @ScheduleId, @OperationCode, @ScopeKey, @AutomationMode, @IdempotencyKey,
|
||||
@DatasetId, @DataHash, @ModelVersion, @ConfigVersion, @CodeSha, @ContractVersion,
|
||||
@LifecycleState, @CorrelationId, 'REQUESTED', @RequestedAt)
|
||||
on conflict (idempotency_key) do nothing;
|
||||
""";
|
||||
|
||||
private const string InsertStatusEventSql = """
|
||||
insert into evaluation.model_operation_status_event
|
||||
(event_id, request_id, from_status, to_status, reason_code, payload_hash,
|
||||
actor_type, correlation_id, occurred_at)
|
||||
values
|
||||
(@EventId, @RequestId, null, 'REQUESTED', null, @PayloadHash,
|
||||
'SYSTEM', @CorrelationId, @OccurredAt);
|
||||
""";
|
||||
|
||||
public async Task<bool> AddAsync(ModelOperationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
var affected = await connection.ExecuteAsync(new CommandDefinition(
|
||||
InsertSql,
|
||||
new
|
||||
{
|
||||
request.RequestId,
|
||||
request.ScheduleId,
|
||||
request.OperationCode,
|
||||
request.ScopeKey,
|
||||
request.AutomationMode,
|
||||
request.IdempotencyKey,
|
||||
request.Context.VersionSet.DatasetId,
|
||||
request.Context.VersionSet.DataHash,
|
||||
request.Context.VersionSet.ModelVersion,
|
||||
request.Context.VersionSet.ConfigVersion,
|
||||
request.Context.VersionSet.CodeSha,
|
||||
request.Context.VersionSet.ContractVersion,
|
||||
request.Context.LifecycleState,
|
||||
request.CorrelationId,
|
||||
request.RequestedAt
|
||||
},
|
||||
transaction,
|
||||
cancellationToken: cancellationToken));
|
||||
|
||||
if (affected == 1)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
requestId = request.RequestId,
|
||||
request.OperationCode,
|
||||
request.ScopeKey,
|
||||
request.AutomationMode,
|
||||
versionSet = request.Context.VersionSet,
|
||||
boundary = "NO_AUTO_MODEL_MUTATION"
|
||||
});
|
||||
var payloadHash = ContentHasher.Sha256(payload);
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition(
|
||||
InsertStatusEventSql,
|
||||
new
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
request.RequestId,
|
||||
PayloadHash = payloadHash,
|
||||
request.CorrelationId,
|
||||
OccurredAt = request.RequestedAt
|
||||
},
|
||||
transaction,
|
||||
cancellationToken: cancellationToken));
|
||||
|
||||
var message = new OutboxMessage(
|
||||
request.RequestId,
|
||||
"ModelOperationRequested",
|
||||
1,
|
||||
payload,
|
||||
request.CorrelationId,
|
||||
request.RequestedAt,
|
||||
payloadHash);
|
||||
await outboxWriter.AddAsync(connection, transaction, message, cancellationToken);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return affected == 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Dapper;
|
||||
using KArtSell.BuildingBlocks.Data;
|
||||
using KArtSell.Modules.ModelOperations.Application;
|
||||
|
||||
namespace KArtSell.Modules.ModelOperations.Infrastructure;
|
||||
|
||||
public sealed class DapperModelScheduleRepository(IDbConnectionFactory connectionFactory) : IModelScheduleRepository
|
||||
{
|
||||
private const string AcquireSql = """
|
||||
with due as (
|
||||
select schedule_id
|
||||
from evaluation.model_operation_schedule
|
||||
where enabled = true
|
||||
and next_due_at <= @Now
|
||||
and (lease_until is null or lease_until < @Now)
|
||||
order by next_due_at, operation_code, scope_key
|
||||
for update skip locked
|
||||
limit @Limit
|
||||
)
|
||||
update evaluation.model_operation_schedule s
|
||||
set lease_owner = @LeaseOwner,
|
||||
lease_until = @LeaseUntil,
|
||||
dispatch_revision = dispatch_revision + 1,
|
||||
updated_at = @Now
|
||||
from due
|
||||
where s.schedule_id = due.schedule_id
|
||||
returning s.schedule_id as ScheduleId,
|
||||
s.operation_code as OperationCode,
|
||||
s.scope_key as ScopeKey,
|
||||
s.cadence as Cadence,
|
||||
s.automation_mode as AutomationMode,
|
||||
s.queue_name as Queue,
|
||||
concat(s.operation_code, ':', s.scope_key, ':', s.schedule_version, ':', to_char(s.next_due_at at time zone 'UTC', 'YYYYMMDDHH24MISS')) as IdempotencyKey,
|
||||
s.schedule_version as ScheduleVersion,
|
||||
s.next_due_at as ScheduledFor,
|
||||
s.catch_up_policy as CatchUpPolicy,
|
||||
s.max_catch_up as MaxCatchUp;
|
||||
""";
|
||||
|
||||
private const string DispatchedSql = """
|
||||
update evaluation.model_operation_schedule
|
||||
set last_dispatched_at = @DispatchedAt,
|
||||
last_background_job_id = @BackgroundJobId,
|
||||
next_due_at = @NextDueAt,
|
||||
lease_owner = null,
|
||||
lease_until = null,
|
||||
last_error_code = null,
|
||||
updated_at = @DispatchedAt
|
||||
where schedule_id = @ScheduleId and lease_owner = @LeaseOwner;
|
||||
""";
|
||||
|
||||
private const string ReleaseSql = """
|
||||
update evaluation.model_operation_schedule
|
||||
set lease_owner = null,
|
||||
lease_until = null,
|
||||
last_error_code = @ReasonCode,
|
||||
next_due_at = greatest(next_due_at, @ReleasedAt) + interval '1 hour',
|
||||
updated_at = @ReleasedAt
|
||||
where schedule_id = @ScheduleId and lease_owner = @LeaseOwner;
|
||||
""";
|
||||
|
||||
public async Task<IReadOnlyList<DueModelOperation>> AcquireDueAsync(DateTimeOffset now, string leaseOwner, TimeSpan leaseDuration, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
var items = await connection.QueryAsync<DueModelOperation>(new CommandDefinition(AcquireSql, new { Now = now, LeaseOwner = leaseOwner, LeaseUntil = now.Add(leaseDuration), Limit = limit }, cancellationToken: cancellationToken));
|
||||
return items.AsList();
|
||||
}
|
||||
|
||||
public async Task MarkDispatchedAsync(Guid scheduleId, string leaseOwner, string backgroundJobId, DateTimeOffset dispatchedAt, DateTimeOffset nextDueAt, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
var affected = await connection.ExecuteAsync(new CommandDefinition(DispatchedSql, new { ScheduleId = scheduleId, LeaseOwner = leaseOwner, BackgroundJobId = backgroundJobId, DispatchedAt = dispatchedAt, NextDueAt = nextDueAt }, cancellationToken: cancellationToken));
|
||||
if (affected != 1) throw new InvalidOperationException("Schedule lease was lost before dispatch completion.");
|
||||
}
|
||||
|
||||
public async Task ReleaseAsync(Guid scheduleId, string leaseOwner, string reasonCode, DateTimeOffset releasedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
await connection.ExecuteAsync(new CommandDefinition(ReleaseSql, new { ScheduleId = scheduleId, LeaseOwner = leaseOwner, ReasonCode = reasonCode, ReleasedAt = releasedAt }, cancellationToken: cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../KArtSell.BuildingBlocks/KArtSell.BuildingBlocks.csproj" />
|
||||
<PackageReference Include="FastEndpoints" />
|
||||
<PackageReference Include="Dapper" />
|
||||
<PackageReference Include="Hangfire.Core" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,21 @@
|
||||
using KArtSell.Modules.ModelOperations.Application;
|
||||
using KArtSell.Modules.ModelOperations.Domain;
|
||||
using KArtSell.Modules.ModelOperations.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace KArtSell.Modules.ModelOperations;
|
||||
|
||||
public static class ModelOperationsModule
|
||||
{
|
||||
public static IServiceCollection AddModelOperationsModule(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<PromotionGateEvaluator>();
|
||||
services.AddSingleton<ScheduleOccurrencePlanner>();
|
||||
services.AddSingleton<EvaluationWindowPlanner>();
|
||||
services.AddScoped<IApprovedModelContextReader, DapperApprovedModelContextReader>();
|
||||
services.AddScoped<IModelScheduleRepository, DapperModelScheduleRepository>();
|
||||
services.AddScoped<IModelOperationRequestRepository, DapperModelOperationRequestRepository>();
|
||||
services.AddScoped<IModelOperationRequestService, ModelOperationRequestService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Hangfire;
|
||||
using Hangfire.Common;
|
||||
using Hangfire.States;
|
||||
using KArtSell.BuildingBlocks.Time;
|
||||
using KArtSell.Modules.ModelOperations.Application;
|
||||
using KArtSell.Modules.ModelOperations.Domain;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace KArtSell.Modules.ModelOperations.Scheduling;
|
||||
|
||||
public sealed class ModelOperationsDispatcherJob(
|
||||
IModelScheduleRepository schedules,
|
||||
IBackgroundJobClient jobs,
|
||||
IClock clock,
|
||||
ScheduleOccurrencePlanner occurrencePlanner,
|
||||
ILogger<ModelOperationsDispatcherJob> logger)
|
||||
{
|
||||
[Queue("q-control")]
|
||||
[DisableConcurrentExecution(timeoutInSeconds: 840)]
|
||||
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
var now = clock.UtcNow;
|
||||
var leaseOwner = $"dispatcher:{Environment.MachineName}:{Guid.NewGuid():N}";
|
||||
var due = await schedules.AcquireDueAsync(now, leaseOwner, TimeSpan.FromMinutes(14), 50, CancellationToken.None);
|
||||
|
||||
foreach (var item in due)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = Job.FromExpression<ScheduledModelOperationJob>(handler => handler.ExecuteAsync(
|
||||
item.ScheduleId,
|
||||
item.OperationCode,
|
||||
item.ScopeKey,
|
||||
item.AutomationMode,
|
||||
item.IdempotencyKey));
|
||||
var backgroundJobId = jobs.Create(job, new EnqueuedState(item.Queue));
|
||||
|
||||
var nextDueAt = occurrencePlanner.GetNextDueAt(item.ScheduledFor, item.Cadence, item.CatchUpPolicy, item.MaxCatchUp, now);
|
||||
await schedules.MarkDispatchedAsync(
|
||||
item.ScheduleId,
|
||||
leaseOwner,
|
||||
backgroundJobId,
|
||||
now,
|
||||
nextDueAt,
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to dispatch model operation {OperationCode} for {ScopeKey}.", item.OperationCode, item.ScopeKey);
|
||||
await schedules.ReleaseAsync(
|
||||
item.ScheduleId,
|
||||
leaseOwner,
|
||||
"DISPATCH_FAILED",
|
||||
now,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Hangfire;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace KArtSell.Modules.ModelOperations.Scheduling;
|
||||
|
||||
public static class ModelOperationsScheduler
|
||||
{
|
||||
public const string DispatcherJobId = "model-operations-dispatcher-v1";
|
||||
|
||||
public static void RegisterModelOperationsSchedules(
|
||||
this IServiceProvider services,
|
||||
bool dispatcherEnabled,
|
||||
string dispatcherCron)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dispatcherCron);
|
||||
|
||||
var manager = services.GetRequiredService<IRecurringJobManager>();
|
||||
if (!dispatcherEnabled)
|
||||
{
|
||||
manager.RemoveIfExists(DispatcherJobId);
|
||||
return;
|
||||
}
|
||||
|
||||
manager.AddOrUpdate<ModelOperationsDispatcherJob>(
|
||||
DispatcherJobId,
|
||||
job => job.ExecuteAsync(),
|
||||
dispatcherCron,
|
||||
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Hangfire;
|
||||
using KArtSell.Modules.ModelOperations.Application;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace KArtSell.Modules.ModelOperations.Scheduling;
|
||||
|
||||
public sealed class ScheduledModelOperationJob(
|
||||
IModelOperationRequestService service,
|
||||
ILogger<ScheduledModelOperationJob> logger)
|
||||
{
|
||||
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Fail)]
|
||||
public async Task ExecuteAsync(
|
||||
Guid scheduleId,
|
||||
string operationCode,
|
||||
string scopeKey,
|
||||
string automationMode,
|
||||
string idempotencyKey)
|
||||
{
|
||||
var correlationId = $"model-operation:{operationCode}:{Guid.NewGuid():N}";
|
||||
var request = await service.RequestAsync(
|
||||
scheduleId,
|
||||
operationCode,
|
||||
scopeKey,
|
||||
automationMode,
|
||||
idempotencyKey,
|
||||
correlationId,
|
||||
CancellationToken.None);
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Model operation {OperationCode} for {ScopeKey} was not created because the approved frozen context is unavailable or the idempotency key already exists.",
|
||||
operationCode,
|
||||
scopeKey);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Requested model operation {OperationCode} for {ScopeKey} with model {ModelVersion} and dataset {DatasetId}. No model mutation is performed by this job.",
|
||||
request.OperationCode,
|
||||
request.ScopeKey,
|
||||
request.Context.VersionSet.ModelVersion,
|
||||
request.Context.VersionSet.DatasetId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user