Initial commit: Add project files
ci / backend (push) Failing after 12s
ci / frontend (push) Failing after 19s
ci / static (push) Failing after 45s

This commit is contained in:
2026-08-02 05:15:36 +09:00
commit dcd1322d41
636 changed files with 122352 additions and 0 deletions
@@ -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;
}
}
@@ -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);
}
@@ -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);
}
}