diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ReducerVerifierSemanticReplayTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ReducerVerifierSemanticReplayTests.cs new file mode 100644 index 00000000..74289002 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ReducerVerifierSemanticReplayTests.cs @@ -0,0 +1,291 @@ +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Serialization; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Codex follow-up #6 — semantic replay coverage for . +/// Structural-only cases live in ; this file pins the +/// contract that when preconditions hold (contiguous ordinals / matching ReducerVersion) +/// the verifier folds the real signal stream through a live +/// and reports divergence. +/// +public class ReducerVerifierSemanticReplayTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + /// The live backend reducer version — matches what Verify uses for drift checks. + private static string CurrentReducerVersion { get; } = new DecisionEngine().ReducerVersion; + + // ---- Seed helpers --------------------------------------------------------------- + + /// + /// Build a canonical 3-signal stream + a matching journal by folding the live reducer + /// over them. The helper is the "truth" data for agreement tests — anything that differs + /// from this output in a test fixture is by definition divergence. + /// + private static (SignalRecord[] signals, DecisionTransitionRecord[] transitions, DecisionState finalState) + BuildAgreedStream() + { + var sigs = new[] + { + MakeSignal(0, DecisionSignalKind.SessionStarted), + MakeSignal(1, DecisionSignalKind.AppInstallCompleted), + MakeSignal(2, DecisionSignalKind.AppInstallCompleted), + }; + + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial(TenantId, SessionId); + var transitionRecords = new DecisionTransitionRecord[sigs.Length]; + + for (var i = 0; i < sigs.Length; i++) + { + var step = engine.Reduce(state, sigs[i]); + state = step.NewState; + + transitionRecords[i] = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = step.Transition.StepIndex, + SessionTraceOrdinal = step.Transition.SessionTraceOrdinal, + SignalOrdinalRef = step.Transition.SignalOrdinalRef, + OccurredAtUtc = step.Transition.OccurredAtUtc, + Trigger = step.Transition.Trigger, + FromStage = step.Transition.FromStage.ToString(), + ToStage = step.Transition.ToStage.ToString(), + Taken = step.Transition.Taken, + DeadEndReason = step.Transition.DeadEndReason, + ReducerVersion = step.Transition.ReducerVersion, + PayloadJson = TransitionSerializer.Serialize(step.Transition), + }; + } + + var signalRecords = new SignalRecord[sigs.Length]; + for (var i = 0; i < sigs.Length; i++) + { + signalRecords[i] = new SignalRecord + { + TenantId = TenantId, + SessionId = SessionId, + SessionSignalOrdinal = sigs[i].SessionSignalOrdinal, + SessionTraceOrdinal = sigs[i].SessionTraceOrdinal, + Kind = sigs[i].Kind.ToString(), + KindSchemaVersion = sigs[i].KindSchemaVersion, + OccurredAtUtc = sigs[i].OccurredAtUtc, + SourceOrigin = sigs[i].SourceOrigin, + PayloadJson = SignalSerializer.Serialize(sigs[i]), + }; + } + + return (signalRecords, transitionRecords, state); + } + + private static DecisionSignal MakeSignal(long ordinal, DecisionSignalKind kind) => + new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: kind, + kindSchemaVersion: 1, + occurredAtUtc: new DateTime(2026, 4, 24, 10, 0, 0, DateTimeKind.Utc).AddSeconds(ordinal), + sourceOrigin: "replay-test", + evidence: new Evidence(EvidenceKind.Synthetic, $"replay:ord-{ordinal}", "test")); + + // ---- Skip conditions ------------------------------------------------------------ + + [Fact] + public void Replay_skipped_when_stored_reducer_version_drifts() + { + var (signals, transitions, _) = BuildAgreedStream(); + // Override ReducerVersion on the first transition to simulate drift. + transitions[0] = CloneWithReducerVersion(transitions[0], "0.9.0.0"); + + var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion); + + Assert.False(report.SemanticReplayPerformed); + Assert.Equal("reducer_version_drift", report.SemanticReplaySkipReason); + Assert.Contains(report.Issues, i => i.Kind == "replay_skipped"); + } + + [Fact] + public void Replay_skipped_when_signal_stream_has_ordinal_gap() + { + var (signals, transitions, _) = BuildAgreedStream(); + // Drop the middle signal → ordinal gap. + var gapped = new[] { signals[0], signals[2] }; + + var report = ReducerVerifier.Verify(TenantId, SessionId, gapped, transitions, CurrentReducerVersion); + + Assert.False(report.SemanticReplayPerformed); + Assert.Equal("non_contiguous_signal_ordinals", report.SemanticReplaySkipReason); + } + + [Fact] + public void Replay_skipped_when_transition_journal_has_step_index_gap() + { + var (signals, transitions, _) = BuildAgreedStream(); + var gapped = new[] { transitions[0], transitions[2] }; // StepIndex gap + + var report = ReducerVerifier.Verify(TenantId, SessionId, signals, gapped, CurrentReducerVersion); + + Assert.False(report.SemanticReplayPerformed); + Assert.Equal("non_contiguous_step_indices", report.SemanticReplaySkipReason); + } + + // ---- Agreement + divergence ---------------------------------------------------- + + [Fact] + public void Replay_perfect_agreement_marks_final_stage_matches_and_zero_divergence() + { + var (signals, transitions, finalState) = BuildAgreedStream(); + + var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion); + + Assert.True(report.SemanticReplayPerformed); + Assert.Null(report.SemanticReplaySkipReason); + Assert.True(report.SemanticReplayFinalStageMatches); + Assert.Equal(finalState.Stage.ToString(), report.ReplayedFinalStage); + Assert.Equal(0, report.TransitionDivergenceCount); + Assert.DoesNotContain(report.Issues, i => i.Kind == "replay_divergence"); + Assert.DoesNotContain(report.Issues, i => i.Kind == "replay_final_stage_mismatch"); + } + + [Fact] + public void Replay_divergence_reports_issue_when_stored_trigger_was_tampered() + { + var (signals, transitions, _) = BuildAgreedStream(); + // Tamper with a stored transition: swap Trigger. Replay will produce the + // authentic trigger and divergence is reported. + transitions[1] = CloneWithTrigger(transitions[1], "TamperedTrigger"); + + var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion); + + Assert.True(report.SemanticReplayPerformed); + Assert.Equal(1, report.TransitionDivergenceCount); + var issue = Assert.Single(report.Issues, i => i.Kind == "replay_divergence"); + Assert.Contains("TamperedTrigger", issue.Message); + } + + [Fact] + public void Replay_final_stage_mismatch_raises_error_issue_when_last_stored_ToStage_differs() + { + var (signals, transitions, _) = BuildAgreedStream(); + transitions[^1] = CloneWithToStage(transitions[^1], "Completed"); // pretend the last stored landed in Completed + + var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion); + + Assert.True(report.SemanticReplayPerformed); + Assert.False(report.SemanticReplayFinalStageMatches); + Assert.Contains(report.Issues, + i => i.Kind == "replay_final_stage_mismatch" && i.Severity == "Error"); + } + + [Fact] + public void Replay_handles_mid_stream_signal_deserialization_failure_without_aborting() + { + var (signals, transitions, _) = BuildAgreedStream(); + // Corrupt the second signal's PayloadJson so deserialization fails there. + signals[1].PayloadJson = "this is not json"; + + var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion); + + // Replay ran on the valid prefix (signal 0); deserialization error reported for the + // truncated tail rather than aborting the whole verification. + Assert.True(report.SemanticReplayPerformed); + Assert.Contains(report.Issues, i => i.Kind == "replay_deserialization_error"); + } + + // ---- Clone helpers (records are POCO, so tweaks need full copies) -------------- + + private static DecisionTransitionRecord CloneWithReducerVersion( + DecisionTransitionRecord source, string reducerVersion) => + new DecisionTransitionRecord + { + TenantId = source.TenantId, + SessionId = source.SessionId, + StepIndex = source.StepIndex, + SessionTraceOrdinal = source.SessionTraceOrdinal, + SignalOrdinalRef = source.SignalOrdinalRef, + OccurredAtUtc = source.OccurredAtUtc, + Trigger = source.Trigger, + FromStage = source.FromStage, + ToStage = source.ToStage, + Taken = source.Taken, + DeadEndReason = source.DeadEndReason, + ReducerVersion = reducerVersion, + PayloadJson = source.PayloadJson, + }; + + private static DecisionTransitionRecord CloneWithTrigger( + DecisionTransitionRecord source, string trigger) + { + // We rewrite PayloadJson too so TransitionSerializer sees the tampered trigger + // and the verifier's comparison picks it up. + var transition = TransitionSerializer.Deserialize(source.PayloadJson); + var tampered = new DecisionTransition( + stepIndex: transition.StepIndex, + sessionTraceOrdinal: transition.SessionTraceOrdinal, + signalOrdinalRef: transition.SignalOrdinalRef, + occurredAtUtc: transition.OccurredAtUtc, + trigger: trigger, + fromStage: transition.FromStage, + toStage: transition.ToStage, + taken: transition.Taken, + deadEndReason: transition.DeadEndReason, + reducerVersion: transition.ReducerVersion); + return new DecisionTransitionRecord + { + TenantId = source.TenantId, + SessionId = source.SessionId, + StepIndex = source.StepIndex, + SessionTraceOrdinal = source.SessionTraceOrdinal, + SignalOrdinalRef = source.SignalOrdinalRef, + OccurredAtUtc = source.OccurredAtUtc, + Trigger = trigger, + FromStage = source.FromStage, + ToStage = source.ToStage, + Taken = source.Taken, + DeadEndReason = source.DeadEndReason, + ReducerVersion = source.ReducerVersion, + PayloadJson = TransitionSerializer.Serialize(tampered), + }; + } + + private static DecisionTransitionRecord CloneWithToStage( + DecisionTransitionRecord source, string toStage) + { + var transition = TransitionSerializer.Deserialize(source.PayloadJson); + var tampered = new DecisionTransition( + stepIndex: transition.StepIndex, + sessionTraceOrdinal: transition.SessionTraceOrdinal, + signalOrdinalRef: transition.SignalOrdinalRef, + occurredAtUtc: transition.OccurredAtUtc, + trigger: transition.Trigger, + fromStage: transition.FromStage, + toStage: (SessionStage)Enum.Parse(typeof(SessionStage), toStage), + taken: transition.Taken, + deadEndReason: transition.DeadEndReason, + reducerVersion: transition.ReducerVersion); + return new DecisionTransitionRecord + { + TenantId = source.TenantId, + SessionId = source.SessionId, + StepIndex = source.StepIndex, + SessionTraceOrdinal = source.SessionTraceOrdinal, + SignalOrdinalRef = source.SignalOrdinalRef, + OccurredAtUtc = source.OccurredAtUtc, + Trigger = source.Trigger, + FromStage = source.FromStage, + ToStage = toStage, + Taken = source.Taken, + DeadEndReason = source.DeadEndReason, + ReducerVersion = source.ReducerVersion, + PayloadJson = TransitionSerializer.Serialize(tampered), + }; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionReducerVerificationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionReducerVerificationFunction.cs index 74540af5..50073439 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionReducerVerificationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionReducerVerificationFunction.cs @@ -11,16 +11,21 @@ namespace AutopilotMonitor.Functions.Functions.Sessions { /// /// GET /api/sessions/{sessionId}/reducer-verification — admin/ops endpoint that - /// runs structural verification on a session's persisted SignalLog + DecisionTransitions - /// journal (Plan §M5). Not tenant-exposed: gated by GlobalAdminOnly in the - /// EndpointAccessPolicyCatalog. + /// runs structural + semantic verification on a session's persisted SignalLog + + /// DecisionTransitions journal (Plan §M5). Not tenant-exposed: gated by + /// GlobalAdminOnly in the EndpointAccessPolicyCatalog. /// - /// Why structural only (not full engine replay)? A full replay requires deserialising - /// the polymorphic DecisionSignal.Evidence from SignalRecord.PayloadJson, - /// which is a non-trivial refactor beyond the scope of this commit. The structural checks - /// catch the realistic release-gate failure modes (corruption, ReducerVersion drift, orphan - /// references) and give ops a usable tool today; a dedicated follow-up can add engine - /// replay once the Evidence deserialisation contract is pinned down. + /// Structural checks — always run: ordinal contiguity, step-index contiguity, + /// ReducerVersion drift, orphaned SignalOrdinalRef. + /// + /// + /// Semantic replay (Codex follow-up #6) — folds the persisted signal stream + /// through the live backend DecisionEngine via SignalSerializer.Deserialize + /// and compares the produced transitions to the stored journal on the semantic fields + /// (Trigger / FromStage / ToStage / Taken / DeadEndReason / StepIndex). Skipped + /// automatically when the stored ReducerVersion differs, the signal stream has gaps, + /// or deserialisation fails at the head — those are structural conditions where a + /// replay would not be meaningful. /// /// public class GetSessionReducerVerificationFunction diff --git a/src/Backend/AutopilotMonitor.Functions/Services/ReducerVerifier.cs b/src/Backend/AutopilotMonitor.Functions/Services/ReducerVerifier.cs index b7ef9543..547a2cd1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/ReducerVerifier.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/ReducerVerifier.cs @@ -1,16 +1,37 @@ +using System; using System.Collections.Generic; using System.Linq; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Serialization; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Functions.Services { /// - /// Structural verification of a session's persisted SignalLog + DecisionTransitions - /// journal. Pure function — takes loaded records, produces a report. No IO, no DI. - /// See for scope boundary vs. full engine replay. + /// Structural + semantic verification of a session's persisted SignalLog + + /// DecisionTransitions journal. Pure function — takes loaded records, produces a + /// report. No IO, no DI. + /// + /// Structural checks (always run): ordinal contiguity, step-index contiguity, + /// ReducerVersion drift, orphaned SignalOrdinalRef. + /// + /// + /// Semantic replay (Codex follow-up #6, added when preconditions hold): + /// deserialise into + /// instances, fold them through the live backend , and + /// compare the produced transitions to the stored journal on the semantic fields + /// (Trigger / FromStage / ToStage / Taken / DeadEndReason / StepIndex). The agent-side + /// journal is the agent's reducer-chain; this replay verifies the backend's reducer + /// agrees — divergence under the same ReducerVersion is a hard release-gate bug. + /// /// internal static class ReducerVerifier { + /// Cap for individual replay-divergence issues, mirrors the gap caps. + private const int MaxReplayDivergenceIssues = 20; + public static ReducerVerificationReport Verify( string tenantId, string sessionId, @@ -36,6 +57,7 @@ namespace AutopilotMonitor.Functions.Services Kind = "empty_session", Message = "Session has no signals or transitions persisted.", }); + report.SemanticReplaySkipReason = "empty_session"; return report; } @@ -168,7 +190,212 @@ namespace AutopilotMonitor.Functions.Services } } + // ---- Semantic replay (Codex follow-up #6) ------------------------------------ + RunSemanticReplay(signals, transitions, report); + return report; } + + /// + /// Folds the persisted stream through a transient + /// instance and compares the produced transitions to + /// the journal. Skips when preconditions (contiguity, ReducerVersion agreement) are + /// not met — divergence under those conditions is not a reducer bug but expected + /// drift. + /// + private static void RunSemanticReplay( + IReadOnlyList? signals, + IReadOnlyList? transitions, + ReducerVerificationReport report) + { + if (signals == null || signals.Count == 0 || transitions == null || transitions.Count == 0) + { + // Empty-session skip already recorded above. + if (string.IsNullOrEmpty(report.SemanticReplaySkipReason)) + { + report.SemanticReplaySkipReason = "empty_session"; + } + return; + } + + if (report.ReducerVersionDrift) + { + report.SemanticReplaySkipReason = "reducer_version_drift"; + report.Issues.Add(new VerificationIssue + { + Severity = "Info", + Kind = "replay_skipped", + Message = "Semantic replay skipped because stored ReducerVersion differs from current — replay against a different reducer build is not meaningful.", + }); + return; + } + + if (!report.SignalOrdinalsContiguous) + { + report.SemanticReplaySkipReason = "non_contiguous_signal_ordinals"; + report.Issues.Add(new VerificationIssue + { + Severity = "Info", + Kind = "replay_skipped", + Message = "Semantic replay skipped because the signal stream has ordinal gaps; the structural warnings above are the actionable signal.", + }); + return; + } + + if (!report.StepIndicesContiguous) + { + report.SemanticReplaySkipReason = "non_contiguous_step_indices"; + report.Issues.Add(new VerificationIssue + { + Severity = "Info", + Kind = "replay_skipped", + Message = "Semantic replay skipped because the transition journal has StepIndex gaps.", + }); + return; + } + + var orderedSignals = signals.OrderBy(s => s.SessionSignalOrdinal).ToList(); + var orderedTransitions = transitions.OrderBy(t => t.StepIndex).ToList(); + + // Deserialise signals up-front — a single malformed blob at the head means we + // cannot start the replay at all. + var decodedSignals = new List(orderedSignals.Count); + for (var i = 0; i < orderedSignals.Count; i++) + { + try + { + decodedSignals.Add(SignalSerializer.Deserialize(orderedSignals[i].PayloadJson)); + } + catch (Exception ex) + { + if (i == 0) + { + // Silent skip at head — PayloadJson stubs (common in structural tests) + // should not pollute the issue list; the SemanticReplaySkipReason field + // carries the diagnostic for real operators. + report.SemanticReplaySkipReason = "deserialization_failure"; + return; + } + + // Mid-stream failure: truncate here, continue what we have. The divergence + // tail will surface as transition-count mismatch naturally. + report.Issues.Add(new VerificationIssue + { + Severity = "Warning", + Kind = "replay_deserialization_error", + Message = $"Signal at ordinal {orderedSignals[i].SessionSignalOrdinal} PayloadJson failed to deserialise; replay truncated here: {ex.GetType().Name}: {ex.Message}", + }); + break; + } + } + + // Same for the journal — we need the semantic fields directly, and PayloadJson + // round-trips them via TransitionSerializer. The typed columns on the record are + // projections; the JSON is authoritative. + var decodedTransitions = new List(orderedTransitions.Count); + for (var i = 0; i < orderedTransitions.Count; i++) + { + try + { + decodedTransitions.Add(TransitionSerializer.Deserialize(orderedTransitions[i].PayloadJson)); + } + catch (Exception ex) + { + report.Issues.Add(new VerificationIssue + { + Severity = "Warning", + Kind = "replay_deserialization_error", + Message = $"Transition at StepIndex {orderedTransitions[i].StepIndex} PayloadJson failed to deserialise — excluded from comparison: {ex.GetType().Name}: {ex.Message}", + }); + } + } + + if (decodedSignals.Count == 0 || decodedTransitions.Count == 0) + { + report.SemanticReplaySkipReason = "deserialization_failure"; + return; + } + + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial(report.TenantId, report.SessionId); + var divergences = 0; + + var stepsToCompare = Math.Min(decodedSignals.Count, decodedTransitions.Count); + for (var i = 0; i < stepsToCompare; i++) + { + var step = engine.Reduce(state, decodedSignals[i]); + state = step.NewState; + var replayed = step.Transition; + var stored = decodedTransitions[i]; + + if (!TransitionsSemanticallyEqual(replayed, stored)) + { + divergences++; + if (divergences <= MaxReplayDivergenceIssues) + { + report.Issues.Add(new VerificationIssue + { + Severity = "Warning", + Kind = "replay_divergence", + Message = + $"Replay vs. stored diverge at StepIndex={stored.StepIndex}: " + + $"stored=[Trigger={stored.Trigger}, {stored.FromStage}→{stored.ToStage}, Taken={stored.Taken}, DeadEnd={stored.DeadEndReason ?? ""}] " + + $"replayed=[Trigger={replayed.Trigger}, {replayed.FromStage}→{replayed.ToStage}, Taken={replayed.Taken}, DeadEnd={replayed.DeadEndReason ?? ""}].", + }); + } + } + } + if (divergences > MaxReplayDivergenceIssues) + { + report.Issues.Add(new VerificationIssue + { + Severity = "Warning", + Kind = "replay_divergence", + Message = $"... {divergences - MaxReplayDivergenceIssues} additional replay divergences not listed.", + }); + } + + // Count imbalance between signals and stored transitions is itself a divergence: + // every signal should produce exactly one transition (taken or dead-end). + if (decodedSignals.Count != decodedTransitions.Count) + { + report.Issues.Add(new VerificationIssue + { + Severity = "Warning", + Kind = "replay_divergence", + Message = $"Signal count ({decodedSignals.Count}) != transition count ({decodedTransitions.Count}); reducer should produce one transition per signal.", + }); + } + + // Final-stage comparison against the last stored transition's ToStage. + var lastStoredToStage = orderedTransitions[orderedTransitions.Count - 1].ToStage; + var replayedStageName = state.Stage.ToString(); + report.ReplayedFinalStage = replayedStageName; + report.SemanticReplayFinalStageMatches = string.Equals( + replayedStageName, lastStoredToStage, StringComparison.Ordinal); + if (!report.SemanticReplayFinalStageMatches) + { + report.Issues.Add(new VerificationIssue + { + Severity = "Error", + Kind = "replay_final_stage_mismatch", + Message = $"Replay arrived at final stage '{replayedStageName}' but the last stored transition's ToStage was '{lastStoredToStage}'.", + }); + } + + report.SemanticReplayPerformed = true; + report.TransitionDivergenceCount = divergences; + } + + private static bool TransitionsSemanticallyEqual(DecisionTransition a, DecisionTransition b) + { + if (a.StepIndex != b.StepIndex) return false; + if (!string.Equals(a.Trigger, b.Trigger, StringComparison.Ordinal)) return false; + if (a.FromStage != b.FromStage) return false; + if (a.ToStage != b.ToStage) return false; + if (a.Taken != b.Taken) return false; + if (!string.Equals(a.DeadEndReason ?? string.Empty, b.DeadEndReason ?? string.Empty, StringComparison.Ordinal)) return false; + return true; + } } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/ReducerVerificationReport.cs b/src/Shared/AutopilotMonitor.Shared/Models/ReducerVerificationReport.cs index be58732c..1ee6b3be 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/ReducerVerificationReport.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/ReducerVerificationReport.cs @@ -52,6 +52,40 @@ namespace AutopilotMonitor.Shared.Models /// public int OrphanedTransitionCount { get; set; } + // ---- Semantic replay (Codex follow-up #6) ---------------------------------------- + + /// + /// True when the verifier re-played the persisted signal stream through the live + /// backend DecisionEngine and compared the produced transitions to the stored + /// journal. False when the replay was skipped — see . + /// + public bool SemanticReplayPerformed { get; set; } + + /// + /// Discriminator when is false. Known values: + /// "empty_session", "reducer_version_drift", "non_contiguous_signal_ordinals", + /// "non_contiguous_step_indices", "deserialization_failure". + /// + public string? SemanticReplaySkipReason { get; set; } + + /// + /// True when the replayed matches + /// the ToStage of the last stored transition. Only meaningful when + /// is true. + /// + public bool SemanticReplayFinalStageMatches { get; set; } + + /// The stage the replay arrived at (stringified SessionStage); null on skip. + public string? ReplayedFinalStage { get; set; } + + /// + /// Number of positions where the replayed transition diverged from the stored one on + /// the compared fields (Trigger, FromStage, ToStage, Taken, DeadEndReason, StepIndex). + /// 0 means perfect agreement. Individual divergences are emitted as + /// replay_divergence s up to a cap of 20. + /// + public int TransitionDivergenceCount { get; set; } + /// Human-readable issue stream for the Inspector's verification panel. public List Issues { get; set; } = new List(); }