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> Allowed = new Dictionary> { [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() }; private readonly List 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 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 Set(params ModelFeedbackCycleState[] states) => new HashSet(states); }