diff --git a/docs/G2_6_SHADOW_VERIFICATION.md b/docs/G2_6_SHADOW_VERIFICATION.md new file mode 100644 index 0000000..cf613ea --- /dev/null +++ b/docs/G2_6_SHADOW_VERIFICATION.md @@ -0,0 +1,94 @@ +# G2.6 — Dynamic Report Shadow Verification + +## Purpose + +G2.6 shadow verification is the final evidence layer between an `InformationReportProven` device and any later `ProductionEligible` decision. + +It does **not** enable production dynamic reporting. It does **not** mutate an RCB, DataSet, or qualification profile. The engine only evaluates observations collected by a commissioning consumer. + +The shadow contract is: + +`dynamic InformationReport candidate -> independent MMS reference read -> exact identity/value/quality/timestamp/order/reconnect comparison -> typed acceptance evidence` + +The report path may be the intended production acquisition path, but during shadow verification independent MMS reads remain the authority used to detect missing, stale, duplicated, mis-mapped, or divergent report traffic. + +## Exact identity boundary + +Every observation is bound to the already-qualified DataSet member sequence by both: + +- exact DataSet index; and +- normalized exact MMS member reference. + +No fuzzy match, vendor alias, neighboring DataObject guess, or alternate RCB substitution is permitted. + +Multiple included members from one InformationReport may share the same report sequence number. This is valid. A decreasing sequence number is an ordering failure, while an exact duplicate `(sequence number, DataSet index)` is rejected separately. + +## Evidence collected by the consumer + +`MmsDynamicReportShadowVerificationEvidence` contains: + +- immutable evidence ID and capture timestamp; +- exact qualified member sequence; +- report observations: index, member, value, quality, optional device timestamp, local receive time, optional sequence number; +- independent polling observations: index, member, value, quality, optional device timestamp, read time; +- reconnect attempts and successful reconnects; +- report resubscription evidence after reconnect; +- polling-reference recovery evidence after reconnect; +- total dynamic activation attempts. + +The engine performs no network I/O while evaluating this object. + +## Required shadow gates + +`MmsDynamicReportShadowVerificationPolicy.Evaluate(...)` fails closed unless the configured gates close: + +1. **Exact member identity** — every report/poll sample maps to the same qualified DataSet index/member identity. +2. **Value parity** — each accepted report observation has a bounded later independent MMS read with the same process value. +3. **Quality parity** — when quality evidence is present, report and polling quality must agree. Commissioning may require quality evidence explicitly. +4. **Timestamp parity** — when device timestamps are present, both sides must be present and within the configured tolerance. Commissioning may require timestamp evidence explicitly. +5. **Report order** — receive time may not regress and report sequence number may not decrease. +6. **No duplicate report edges** — the same sequence number and DataSet index may not appear more than once. +7. **No missing report edges** — every value transition independently observed by polling must have a matching report observation inside the bounded correlation window. +8. **Polling authority guard** — polling must remain available as an independent reference for every accepted report observation during the shadow. +9. **Reconnect regression** — when required, every deliberate reconnect must recover both report subscription and the independent polling reference. +10. **No repeated mutation loop** — dynamic activation attempts are bounded per association; reconnect recovery must not cause uncontrolled RCB/DataSet rewrite loops. + +Any failed gate produces a typed failure result and cannot be converted into production acceptance evidence. + +## Production acceptance bridge + +A successful shadow result can be converted through: + +`MmsDynamicReportShadowVerificationPolicy.BuildProductionAcceptance(...)` + +This creates the existing `MmsDynamicReportProductionAcceptance` contract. It deliberately still requires the caller to provide two independent regression decisions: + +- Smart Control regression; and +- static reporting regression. + +The shadow result supplies evidence for: + +- dynamic InformationReport regression; +- polling-authority guard; +- reconnect regression; +- quality/timestamp regression; and +- no repeated mutation loop. + +Creating this acceptance record still does **not** change profile state. The caller must separately invoke `MmsDynamicReportQualificationProfilePolicy.MarkProductionEligible(...)`, and that policy continues to require an identity-compatible `InformationReportProven` profile plus every production gate passing. + +## Intended ARSAS commissioning flow + +The consumer integration should remain explicit and fail closed: + +1. load the exact identity-compatible `InformationReportProven` profile; +2. use only the exact proven RCB/member sequence; +3. arm the dynamic report path with the established safety/cleanup contract; +4. run an isolated read-only MMS reference association against the same exact members; +5. collect report and polling observations during controlled normal process transitions; +6. perform one deliberate reconnect cycle and prove both paths recover; +7. stop and prove RCB/DataSet cleanup where the commissioning transaction requires it; +8. evaluate the typed shadow evidence; +9. retain `InformationReportProven` on any failure; +10. consider `ProductionEligible` only after shadow PASS plus independent control/static-reporting acceptance. + +Production automatic dynamic reporting must remain OFF until the persisted profile is explicitly and validly advanced to `ProductionEligible`. diff --git a/src/AR.Iec61850/Mms/MmsDynamicReportShadowVerification.cs b/src/AR.Iec61850/Mms/MmsDynamicReportShadowVerification.cs new file mode 100644 index 0000000..a458b62 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsDynamicReportShadowVerification.cs @@ -0,0 +1,386 @@ +namespace AR.Iec61850.Mms; + +/// +/// One report-authoritative observation captured during G2.6 shadow verification. +/// The DataSet index/member identity must be the exact identity already proven by +/// the qualification profile; this model intentionally contains no fuzzy mapping. +/// +public sealed record MmsDynamicReportShadowReportObservation +{ + public int DataSetIndex { get; init; } + public string MemberReference { get; init; } = string.Empty; + public string Value { get; init; } = string.Empty; + public string Quality { get; init; } = string.Empty; + public DateTimeOffset? DeviceTimestampUtc { get; init; } + public DateTimeOffset ReceivedAtUtc { get; init; } + public ulong? SequenceNumber { get; init; } +} + +/// +/// One independent MMS read used only as the reference side of the G2.6 shadow. +/// +public sealed record MmsDynamicReportShadowPollObservation +{ + public int DataSetIndex { get; init; } + public string MemberReference { get; init; } = string.Empty; + public string Value { get; init; } = string.Empty; + public string Quality { get; init; } = string.Empty; + public DateTimeOffset? DeviceTimestampUtc { get; init; } + public DateTimeOffset ReadAtUtc { get; init; } +} + +/// +/// Bounded physical shadow evidence. Reconnect and mutation counters are explicit +/// because production acceptance must prove that report recovery does not create a +/// repeated RCB/DataSet mutation loop. +/// +public sealed record MmsDynamicReportShadowVerificationEvidence +{ + public string EvidenceId { get; init; } = string.Empty; + public DateTimeOffset ObservedAtUtc { get; init; } + public IReadOnlyList MemberReferences { get; init; } = Array.Empty(); + public IReadOnlyList ReportObservations { get; init; } = Array.Empty(); + public IReadOnlyList PollObservations { get; init; } = Array.Empty(); + public int ReconnectAttempts { get; init; } + public int SuccessfulReconnects { get; init; } + public int ReportResubscriptionsAfterReconnect { get; init; } + public int PollReferenceRecoveriesAfterReconnect { get; init; } + public int DynamicActivationAttempts { get; init; } +} + +public sealed record MmsDynamicReportShadowVerificationOptions +{ + public static MmsDynamicReportShadowVerificationOptions ProductionDefaults { get; } = new(); + + public int MinimumReportEdges { get; init; } = 1; + public TimeSpan MaximumReportToPollLag { get; init; } = TimeSpan.FromSeconds(3); + public TimeSpan MaximumPollTransitionToReportLag { get; init; } = TimeSpan.FromSeconds(3); + public TimeSpan MaximumDeviceTimestampDelta { get; init; } = TimeSpan.FromMilliseconds(250); + public bool RequireQualityEvidence { get; init; } + public bool RequireDeviceTimestampEvidence { get; init; } + public bool RequireReconnectCycle { get; init; } = true; + public int MaximumDynamicActivationAttemptsPerAssociation { get; init; } = 1; +} + +public sealed record MmsDynamicReportShadowVerificationResult +{ + public bool IsSuccess { get; init; } + public bool ExactMemberIdentityPassed { get; init; } + public bool ValueParityPassed { get; init; } + public bool QualityParityPassed { get; init; } + public bool TimestampParityPassed { get; init; } + public bool ReportOrderPassed { get; init; } + public bool NoMissingReportEdgesPassed { get; init; } + public bool NoDuplicateReportEdgesPassed { get; init; } + public bool PollingAuthorityGuardPassed { get; init; } + public bool ReconnectRegressionPassed { get; init; } + public bool NoRepeatedMutationLoopPassed { get; init; } + public int ReportObservationCount { get; init; } + public int PollObservationCount { get; init; } + public int PollTransitionCount { get; init; } + public int MatchedReportToPollCount { get; init; } + public int MatchedPollTransitionToReportCount { get; init; } + public IReadOnlyList Failures { get; init; } = Array.Empty(); + public string Summary { get; init; } = string.Empty; +} + +/// +/// Pure G2.6 acceptance evaluator. It does not perform network I/O and does not +/// mutate an RCB, DataSet, or qualification profile. Consumers collect physical +/// observations and pass them here for deterministic fail-closed evaluation. +/// +public static class MmsDynamicReportShadowVerificationPolicy +{ + public static MmsDynamicReportShadowVerificationResult Evaluate( + MmsDynamicReportShadowVerificationEvidence evidence, + MmsDynamicReportShadowVerificationOptions? options = null) + { + ArgumentNullException.ThrowIfNull(evidence); + options ??= MmsDynamicReportShadowVerificationOptions.ProductionDefaults; + ValidateOptions(options); + ArgumentException.ThrowIfNullOrWhiteSpace(evidence.EvidenceId); + + var failures = new List(); + var members = evidence.MemberReferences + .Select(NormalizeReference) + .ToArray(); + + var memberSetValid = members.Length > 0 && + members.All(reference => reference.Length > 0) && + members.Distinct(StringComparer.OrdinalIgnoreCase).Count() == members.Length; + if (!memberSetValid) + failures.Add("Shadow verification requires one non-empty, duplicate-free exact member sequence."); + + var reportIdentityPassed = memberSetValid && evidence.ReportObservations.All(observation => + IsExactMember(observation.DataSetIndex, observation.MemberReference, members)); + var pollIdentityPassed = memberSetValid && evidence.PollObservations.All(observation => + IsExactMember(observation.DataSetIndex, observation.MemberReference, members)); + var exactMemberIdentityPassed = reportIdentityPassed && pollIdentityPassed; + if (!exactMemberIdentityPassed) + failures.Add("At least one report/poll observation does not match the exact DataSet index/member identity."); + + var originalReportOrder = evidence.ReportObservations.ToArray(); + var reportOrderPassed = true; + for (var index = 1; index < originalReportOrder.Length; index++) + { + if (originalReportOrder[index].ReceivedAtUtc < originalReportOrder[index - 1].ReceivedAtUtc) + { + reportOrderPassed = false; + break; + } + + var previousSequence = originalReportOrder[index - 1].SequenceNumber; + var currentSequence = originalReportOrder[index].SequenceNumber; + // Multiple included DataSet members from one InformationReport legitimately + // share the same sequence number. Only sequence regression is invalid here; + // same-sequence/same-index duplication is rejected separately below. + if (previousSequence.HasValue && currentSequence.HasValue && currentSequence.Value < previousSequence.Value) + { + reportOrderPassed = false; + break; + } + } + if (!reportOrderPassed) + failures.Add("Report receive/sequence order regressed inside the bounded shadow window."); + + var duplicateKeys = evidence.ReportObservations + .Where(observation => observation.SequenceNumber.HasValue) + .GroupBy(observation => $"{observation.SequenceNumber!.Value}:{observation.DataSetIndex}", StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToArray(); + var noDuplicateReportEdgesPassed = duplicateKeys.Length == 0; + if (!noDuplicateReportEdgesPassed) + failures.Add("Duplicate report edge(s) were observed for the same sequence number and DataSet index: " + string.Join(", ", duplicateKeys)); + + var reports = evidence.ReportObservations + .OrderBy(observation => observation.ReceivedAtUtc) + .ToArray(); + var polls = evidence.PollObservations + .OrderBy(observation => observation.ReadAtUtc) + .ToArray(); + + var matchedReportToPoll = 0; + var valueParityPassed = true; + var qualityParityPassed = true; + var timestampParityPassed = true; + var sawQualityPair = false; + var sawTimestampPair = false; + + foreach (var report in reports) + { + var poll = polls.FirstOrDefault(candidate => + candidate.DataSetIndex == report.DataSetIndex && + candidate.ReadAtUtc >= report.ReceivedAtUtc && + candidate.ReadAtUtc - report.ReceivedAtUtc <= options.MaximumReportToPollLag); + if (poll is null) + continue; + + matchedReportToPoll++; + if (!SameValue(report.Value, poll.Value)) + valueParityPassed = false; + + var reportQuality = NormalizeText(report.Quality); + var pollQuality = NormalizeText(poll.Quality); + if (reportQuality.Length > 0 || pollQuality.Length > 0) + { + sawQualityPair = true; + if (!reportQuality.Equals(pollQuality, StringComparison.OrdinalIgnoreCase)) + qualityParityPassed = false; + } + + if (report.DeviceTimestampUtc.HasValue || poll.DeviceTimestampUtc.HasValue) + { + if (!report.DeviceTimestampUtc.HasValue || !poll.DeviceTimestampUtc.HasValue) + { + timestampParityPassed = false; + } + else + { + sawTimestampPair = true; + var delta = (report.DeviceTimestampUtc.Value - poll.DeviceTimestampUtc.Value).Duration(); + if (delta > options.MaximumDeviceTimestampDelta) + timestampParityPassed = false; + } + } + } + + if (options.RequireQualityEvidence && !sawQualityPair) + qualityParityPassed = false; + if (options.RequireDeviceTimestampEvidence && !sawTimestampPair) + timestampParityPassed = false; + + if (!valueParityPassed) + failures.Add("At least one report value disagrees with the independent MMS reference read."); + if (!qualityParityPassed) + failures.Add(options.RequireQualityEvidence && !sawQualityPair + ? "Required quality evidence was not captured on both report and polling sides." + : "At least one report quality disagrees with the independent MMS reference read."); + if (!timestampParityPassed) + failures.Add(options.RequireDeviceTimestampEvidence && !sawTimestampPair + ? "Required device timestamp evidence was not captured on both report and polling sides." + : "At least one report/poll device timestamp pair exceeds the configured tolerance or is one-sided."); + + var pollTransitions = BuildPollTransitions(polls); + var matchedPollTransitions = 0; + foreach (var transition in pollTransitions) + { + var matchingReport = reports.FirstOrDefault(report => + report.DataSetIndex == transition.Current.DataSetIndex && + SameValue(report.Value, transition.Current.Value) && + report.ReceivedAtUtc >= transition.Previous.ReadAtUtc && + report.ReceivedAtUtc <= transition.Current.ReadAtUtc + options.MaximumPollTransitionToReportLag); + if (matchingReport is not null) + matchedPollTransitions++; + } + + var noMissingReportEdgesPassed = pollTransitions.Count == matchedPollTransitions; + if (!noMissingReportEdgesPassed) + failures.Add($"Independent MMS polling observed {pollTransitions.Count} value transition(s), but only {matchedPollTransitions} had a matching report edge in the bounded window."); + + var enoughReportEdges = reports.Length >= options.MinimumReportEdges; + if (!enoughReportEdges) + failures.Add($"Shadow verification captured {reports.Length} report edge(s); at least {options.MinimumReportEdges} are required."); + + var pollingAuthorityGuardPassed = polls.Length > 0 && + matchedReportToPoll == reports.Length && + noMissingReportEdgesPassed; + if (!pollingAuthorityGuardPassed) + failures.Add("Independent MMS polling did not remain an authoritative reference for every report edge."); + + var reconnectRegressionPassed = options.RequireReconnectCycle + ? evidence.ReconnectAttempts > 0 && + evidence.SuccessfulReconnects == evidence.ReconnectAttempts && + evidence.ReportResubscriptionsAfterReconnect >= evidence.SuccessfulReconnects && + evidence.PollReferenceRecoveriesAfterReconnect >= evidence.SuccessfulReconnects + : evidence.SuccessfulReconnects <= evidence.ReconnectAttempts; + if (!reconnectRegressionPassed) + failures.Add("Reconnect regression did not prove both report resubscription and independent polling-reference recovery."); + + var associationCount = 1 + Math.Max(0, evidence.SuccessfulReconnects); + var allowedDynamicAttempts = checked(associationCount * options.MaximumDynamicActivationAttemptsPerAssociation); + var noRepeatedMutationLoopPassed = evidence.DynamicActivationAttempts >= 0 && + evidence.DynamicActivationAttempts <= allowedDynamicAttempts; + if (!noRepeatedMutationLoopPassed) + failures.Add($"Dynamic activation attempts ({evidence.DynamicActivationAttempts}) exceed the bounded allowance ({allowedDynamicAttempts}); repeated mutation loop cannot be excluded."); + + var success = exactMemberIdentityPassed && + enoughReportEdges && + valueParityPassed && + qualityParityPassed && + timestampParityPassed && + reportOrderPassed && + noMissingReportEdgesPassed && + noDuplicateReportEdgesPassed && + pollingAuthorityGuardPassed && + reconnectRegressionPassed && + noRepeatedMutationLoopPassed; + + return new MmsDynamicReportShadowVerificationResult + { + IsSuccess = success, + ExactMemberIdentityPassed = exactMemberIdentityPassed, + ValueParityPassed = valueParityPassed, + QualityParityPassed = qualityParityPassed, + TimestampParityPassed = timestampParityPassed, + ReportOrderPassed = reportOrderPassed, + NoMissingReportEdgesPassed = noMissingReportEdgesPassed, + NoDuplicateReportEdgesPassed = noDuplicateReportEdgesPassed, + PollingAuthorityGuardPassed = pollingAuthorityGuardPassed, + ReconnectRegressionPassed = reconnectRegressionPassed, + NoRepeatedMutationLoopPassed = noRepeatedMutationLoopPassed, + ReportObservationCount = reports.Length, + PollObservationCount = polls.Length, + PollTransitionCount = pollTransitions.Count, + MatchedReportToPollCount = matchedReportToPoll, + MatchedPollTransitionToReportCount = matchedPollTransitions, + Failures = failures.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + Summary = success + ? $"G2.6 shadow PASS: reports={reports.Length}, polls={polls.Length}, pollTransitions={pollTransitions.Count}, reconnects={evidence.SuccessfulReconnects}, dynamicAttempts={evidence.DynamicActivationAttempts}." + : $"G2.6 shadow FAIL: {string.Join(" | ", failures.Distinct(StringComparer.OrdinalIgnoreCase))}" + }; + } + + /// + /// Converts a successful typed shadow result into the existing production-acceptance + /// contract. Control/static-report regressions remain explicit independent inputs. + /// This helper does not modify a qualification profile. + /// + public static MmsDynamicReportProductionAcceptance BuildProductionAcceptance( + MmsDynamicReportShadowVerificationEvidence evidence, + MmsDynamicReportShadowVerificationResult shadow, + bool controlRegressionPassed, + bool staticReportingRegressionPassed) + { + ArgumentNullException.ThrowIfNull(evidence); + ArgumentNullException.ThrowIfNull(shadow); + ArgumentException.ThrowIfNullOrWhiteSpace(evidence.EvidenceId); + if (!shadow.IsSuccess) + throw new InvalidOperationException("A failed G2.6 shadow cannot be converted into production acceptance evidence."); + + return new MmsDynamicReportProductionAcceptance + { + FieldEvidenceId = evidence.EvidenceId.Trim(), + ObservedAtUtc = evidence.ObservedAtUtc, + ControlRegressionPassed = controlRegressionPassed, + StaticReportingRegressionPassed = staticReportingRegressionPassed, + DynamicInformationReportRegressionPassed = shadow.ExactMemberIdentityPassed && + shadow.ValueParityPassed && + shadow.ReportOrderPassed && + shadow.NoMissingReportEdgesPassed && + shadow.NoDuplicateReportEdgesPassed, + PollingAuthorityGuardPassed = shadow.PollingAuthorityGuardPassed, + ReconnectRegressionPassed = shadow.ReconnectRegressionPassed, + QualityRegressionPassed = shadow.QualityParityPassed && shadow.TimestampParityPassed, + NoRepeatedMutationLoopPassed = shadow.NoRepeatedMutationLoopPassed + }; + } + + private static IReadOnlyList BuildPollTransitions( + IReadOnlyList polls) + { + var transitions = new List(); + foreach (var group in polls.GroupBy(observation => observation.DataSetIndex)) + { + var ordered = group.OrderBy(observation => observation.ReadAtUtc).ToArray(); + for (var index = 1; index < ordered.Length; index++) + { + if (!SameValue(ordered[index - 1].Value, ordered[index].Value)) + transitions.Add(new PollTransition(ordered[index - 1], ordered[index])); + } + } + return transitions; + } + + private static bool IsExactMember(int index, string reference, IReadOnlyList members) + => index >= 0 && + index < members.Count && + NormalizeReference(reference).Equals(members[index], StringComparison.OrdinalIgnoreCase); + + private static bool SameValue(string left, string right) + => NormalizeText(left).Equals(NormalizeText(right), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeReference(string? reference) + => NormalizeText(reference).Replace('$', '.'); + + private static string NormalizeText(string? text) + => string.IsNullOrWhiteSpace(text) ? string.Empty : text.Trim(); + + private static void ValidateOptions(MmsDynamicReportShadowVerificationOptions options) + { + if (options.MinimumReportEdges < 1) + throw new ArgumentOutOfRangeException(nameof(options.MinimumReportEdges)); + if (options.MaximumReportToPollLag <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(options.MaximumReportToPollLag)); + if (options.MaximumPollTransitionToReportLag < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(options.MaximumPollTransitionToReportLag)); + if (options.MaximumDeviceTimestampDelta < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(options.MaximumDeviceTimestampDelta)); + if (options.MaximumDynamicActivationAttemptsPerAssociation < 1) + throw new ArgumentOutOfRangeException(nameof(options.MaximumDynamicActivationAttemptsPerAssociation)); + } + + private sealed record PollTransition( + MmsDynamicReportShadowPollObservation Previous, + MmsDynamicReportShadowPollObservation Current); +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsG26ShadowVerificationTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsG26ShadowVerificationTests.cs new file mode 100644 index 0000000..362dfba --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsG26ShadowVerificationTests.cs @@ -0,0 +1,232 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsG26ShadowVerificationTests +{ + [Fact] + public void MatchingReportAndPollEdges_WithReconnect_PassProductionShadow() + { + var evidence = PassingEvidence(); + var result = MmsDynamicReportShadowVerificationPolicy.Evaluate( + evidence, + StrictOptions()); + + Assert.True(result.IsSuccess, result.Summary); + Assert.True(result.ExactMemberIdentityPassed); + Assert.True(result.ValueParityPassed); + Assert.True(result.QualityParityPassed); + Assert.True(result.TimestampParityPassed); + Assert.True(result.ReportOrderPassed); + Assert.True(result.NoMissingReportEdgesPassed); + Assert.True(result.NoDuplicateReportEdgesPassed); + Assert.True(result.PollingAuthorityGuardPassed); + Assert.True(result.ReconnectRegressionPassed); + Assert.True(result.NoRepeatedMutationLoopPassed); + Assert.Equal(2, result.PollTransitionCount); + Assert.Equal(2, result.MatchedPollTransitionToReportCount); + } + + [Fact] + public void OneInformationReportFrame_CanCarryMultipleIndexesWithSameSequenceNumber() + { + var evidence = PassingEvidence() with + { + ReportObservations = + [ + Report(0, "Open", 10, Time(110), Time(100)), + Report(1, "Open", 10, Time(110), Time(100)) + ] + }; + + var result = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions()); + + Assert.True(result.ReportOrderPassed, result.Summary); + Assert.True(result.NoDuplicateReportEdgesPassed, result.Summary); + } + + [Fact] + public void PollTransitionWithoutMatchingReport_FailsMissingEdgeAndPollingAuthority() + { + var evidence = PassingEvidence() with + { + ReportObservations = [Report(0, "Open", 10, Time(110), Time(100))] + }; + + var result = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions()); + + Assert.False(result.IsSuccess); + Assert.False(result.NoMissingReportEdgesPassed); + Assert.False(result.PollingAuthorityGuardPassed); + Assert.Contains(result.Failures, failure => failure.Contains("only 1", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void DuplicateSameSequenceAndIndex_FailsClosed() + { + var reports = PassingEvidence().ReportObservations.ToList(); + reports.Add(Report(0, "Open", 10, Time(111), Time(100))); + var evidence = PassingEvidence() with { ReportObservations = reports }; + + var result = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions()); + + Assert.False(result.IsSuccess); + Assert.False(result.NoDuplicateReportEdgesPassed); + } + + [Fact] + public void ValueQualityOrTimestampMismatch_FailsParity() + { + var polls = PassingEvidence().PollObservations.ToArray(); + polls[2] = polls[2] with + { + Value = "Closed", + Quality = "questionable", + DeviceTimestampUtc = Time(500) + }; + var evidence = PassingEvidence() with { PollObservations = polls }; + + var result = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions()); + + Assert.False(result.IsSuccess); + Assert.False(result.ValueParityPassed); + Assert.False(result.QualityParityPassed); + Assert.False(result.TimestampParityPassed); + } + + [Fact] + public void ReconnectWithoutReportAndPollRecovery_FailsClosed() + { + var evidence = PassingEvidence() with + { + ReportResubscriptionsAfterReconnect = 0, + PollReferenceRecoveriesAfterReconnect = 0 + }; + + var result = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions()); + + Assert.False(result.IsSuccess); + Assert.False(result.ReconnectRegressionPassed); + } + + [Fact] + public void RepeatedDynamicActivationLoop_FailsClosed() + { + var evidence = PassingEvidence() with { DynamicActivationAttempts = 3 }; + + var result = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions()); + + Assert.False(result.IsSuccess); + Assert.False(result.NoRepeatedMutationLoopPassed); + Assert.Contains(result.Failures, failure => failure.Contains("repeated mutation", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void SuccessfulTypedShadow_BuildsExistingProductionAcceptanceContract() + { + var evidence = PassingEvidence(); + var shadow = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions()); + + var acceptance = MmsDynamicReportShadowVerificationPolicy.BuildProductionAcceptance( + evidence, + shadow, + controlRegressionPassed: true, + staticReportingRegressionPassed: true); + + Assert.True(acceptance.AllPassed); + Assert.Equal(evidence.EvidenceId, acceptance.FieldEvidenceId); + Assert.True(acceptance.DynamicInformationReportRegressionPassed); + Assert.True(acceptance.PollingAuthorityGuardPassed); + Assert.True(acceptance.ReconnectRegressionPassed); + Assert.True(acceptance.QualityRegressionPassed); + Assert.True(acceptance.NoRepeatedMutationLoopPassed); + } + + [Fact] + public void FailedShadow_CannotBuildProductionAcceptance() + { + var evidence = PassingEvidence() with { ReportObservations = [] }; + var shadow = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions()); + + Assert.False(shadow.IsSuccess); + Assert.Throws(() => + MmsDynamicReportShadowVerificationPolicy.BuildProductionAcceptance( + evidence, + shadow, + controlRegressionPassed: true, + staticReportingRegressionPassed: true)); + } + + private static MmsDynamicReportShadowVerificationEvidence PassingEvidence() + => new() + { + EvidenceId = "g2.6-shadow-synthetic", + ObservedAtUtc = Time(1000), + MemberReferences = [Member(0), Member(1)], + ReportObservations = + [ + Report(0, "Open", 10, Time(110), Time(100)), + Report(1, "Open", 10, Time(110), Time(100)) + ], + PollObservations = + [ + Poll(0, "Closed", Time(90), Time(90)), + Poll(1, "Closed", Time(90), Time(90)), + Poll(0, "Open", Time(120), Time(100)), + Poll(1, "Open", Time(120), Time(100)) + ], + ReconnectAttempts = 1, + SuccessfulReconnects = 1, + ReportResubscriptionsAfterReconnect = 1, + PollReferenceRecoveriesAfterReconnect = 1, + DynamicActivationAttempts = 2 + }; + + private static MmsDynamicReportShadowVerificationOptions StrictOptions() + => new() + { + MinimumReportEdges = 2, + MaximumReportToPollLag = TimeSpan.FromSeconds(5), + MaximumPollTransitionToReportLag = TimeSpan.FromSeconds(5), + MaximumDeviceTimestampDelta = TimeSpan.FromMilliseconds(250), + RequireQualityEvidence = true, + RequireDeviceTimestampEvidence = true, + RequireReconnectCycle = true, + MaximumDynamicActivationAttemptsPerAssociation = 1 + }; + + private static MmsDynamicReportShadowReportObservation Report( + int index, + string value, + ulong sequence, + DateTimeOffset receivedAt, + DateTimeOffset deviceTimestamp) + => new() + { + DataSetIndex = index, + MemberReference = Member(index), + Value = value, + Quality = "good", + DeviceTimestampUtc = deviceTimestamp, + ReceivedAtUtc = receivedAt, + SequenceNumber = sequence + }; + + private static MmsDynamicReportShadowPollObservation Poll( + int index, + string value, + DateTimeOffset readAt, + DateTimeOffset deviceTimestamp) + => new() + { + DataSetIndex = index, + MemberReference = Member(index), + Value = value, + Quality = "good", + DeviceTimestampUtc = deviceTimestamp, + ReadAtUtc = readAt + }; + + private static string Member(int index) => $"LD0/LN0$ST$Pos{index}$stVal"; + private static DateTimeOffset Time(int milliseconds) => DateTimeOffset.UnixEpoch.AddMilliseconds(milliseconds); +}