From c3faecd3b2e2ba04babaf10e9040e65eb3b7d7dd Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 16:03:22 +0700 Subject: [PATCH 1/6] Separate MAC and identifier comparison rules --- .../Profiles/SvConfigurationComparer.cs | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs index 77142ab..27872d1 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs @@ -64,11 +64,11 @@ public SvConfigurationComparisonResult Compare( var findings = new List(); CompareNullable("SV_ETHERTYPE", "EtherType", expected.EtherType, observed.EtherType, value => $"0x{value:X4}", mode, findings); CompareNullable("SV_APPID", "APPID", expected.AppId, observed.AppId, value => $"0x{value:X4}", mode, findings); - CompareText("SV_DEST_MAC", "Destination MAC", expected.DestinationMac, observed.DestinationMac, mode, findings); + CompareMacAddress("SV_DEST_MAC", "Destination MAC", expected.DestinationMac, observed.DestinationMac, mode, findings); CompareNullable("SV_VLAN_ID", "VLAN ID", expected.VlanId, observed.VlanId, value => value.ToString(), mode, findings); CompareNullable("SV_VLAN_PRIORITY", "VLAN priority", expected.VlanPriority, observed.VlanPriority, value => value.ToString(), mode, findings); - CompareText("SV_ID", "svID", expected.SvId, observed.SvId, mode, findings); - CompareText("SV_DATASET", "Dataset reference", expected.DataSetReference, observed.DataSetReference, mode, findings); + CompareIdentifier("SV_ID", "svID", expected.SvId, observed.SvId, mode, findings); + CompareIdentifier("SV_DATASET", "Dataset reference", expected.DataSetReference, observed.DataSetReference, mode, findings); CompareNullable("SV_CONFREV", "confRev", expected.ConfigurationRevision, observed.ConfigurationRevision, value => value.ToString(), mode, findings); CompareNullable("SV_ASDU_COUNT", "ASDU per frame", expected.AsduPerFrame, observed.AsduPerFrame, value => value.ToString(), mode, findings); CompareNullable("SV_PAYLOAD_LENGTH", "Payload bytes per ASDU", expected.PayloadBytesPerAsdu, observed.PayloadBytesPerAsdu, value => value.ToString(), mode, findings); @@ -83,11 +83,34 @@ public SvConfigurationComparisonResult Compare( }; } + private static void CompareMacAddress( + string code, + string field, + string expected, + string observed, + SvComparisonMode mode, + List findings) + { + CompareText(code, field, expected, observed, NormalizeMacAddress, mode, findings); + } + + private static void CompareIdentifier( + string code, + string field, + string expected, + string observed, + SvComparisonMode mode, + List findings) + { + CompareText(code, field, expected, observed, NormalizeIdentifier, mode, findings); + } + private static void CompareText( string code, string field, string expected, string observed, + Func normalize, SvComparisonMode mode, List findings) { @@ -100,7 +123,7 @@ private static void CompareText( return; } - if (!string.Equals(NormalizeText(expected), NormalizeText(observed), StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(normalize(expected), normalize(observed), StringComparison.Ordinal)) findings.Add(Mismatch(code, field, expected, observed, mode)); } @@ -184,10 +207,14 @@ private static SvConfigurationFindingSeverity Severity(SvComparisonMode mode) ? SvConfigurationFindingSeverity.Error : SvConfigurationFindingSeverity.Warning; - private static string NormalizeText(string value) - => value.Replace("-", string.Empty, StringComparison.Ordinal) - .Replace(":", string.Empty, StringComparison.Ordinal) - .Trim(); + private static string NormalizeMacAddress(string value) + => new((value ?? string.Empty) + .Where(Uri.IsHexDigit) + .Select(char.ToUpperInvariant) + .ToArray()); + + private static string NormalizeIdentifier(string value) + => (value ?? string.Empty).Trim(); private static string SignatureText(IReadOnlyList signature) => string.Join(", ", signature.Select(item => item.NormalizedBType)); From 3351dc0f4fe8f7b7f03bf2b1fa569a8f665a5921 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 16:04:46 +0700 Subject: [PATCH 2/6] Gate profile confidence by evidence maturity --- .../SampledValues/Profiles/SvProfileModels.cs | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileModels.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileModels.cs index 2c05170..be634d6 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileModels.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileModels.cs @@ -33,6 +33,16 @@ public enum SvProfileEvidenceOutcome Unknown } +public enum SvFactSource +{ + Unknown, + WireObserved, + CaptureCalculated, + SclDerived, + TrustedContext, + ProfileInferred +} + public sealed record SvProfileSourceEvidence( string SourceId, string Description, @@ -55,6 +65,15 @@ private static string Normalize(string value) .ToArray()); } +public sealed record SvCounterTransitionSummary +{ + public int SequentialCount { get; init; } + public int GapCount { get; init; } + public int DuplicateCount { get; init; } + public int OutOfOrderOrResetCount { get; init; } + public int ConfirmedWrapCount { get; init; } +} + public sealed record SvObservedStreamFacts { public ushort? EtherType { get; init; } @@ -70,11 +89,14 @@ public sealed record SvObservedStreamFacts public double? ObservedFramesPerSecond { get; init; } public double? ObservedSamplesPerSecond { get; init; } public int? ObservedCounterWrap { get; init; } + public SvCounterTransitionSummary CounterTransitions { get; init; } = new(); public ushort? DeclaredSampleRate { get; init; } public ushort? DeclaredSampleMode { get; init; } public double? NominalFrequencyHz { get; init; } public IReadOnlyList DataSetSignature { get; init; } = Array.Empty(); + public IReadOnlyDictionary Provenance { get; init; } + = new Dictionary(StringComparer.Ordinal); public int ObservationCount { get; init; } public DateTimeOffset? FirstTimestamp { get; init; } public DateTimeOffset? LastTimestamp { get; init; } @@ -160,8 +182,19 @@ public sealed record SvProfileMatchEvidence( public sealed record SvProfileDetectionResult { + private SvProfileConfidence _rawConfidence; + public SvProfileDefinition Profile { get; init; } = new(); - public SvProfileConfidence Confidence { get; init; } + public SvProfileConfidence RawConfidence + { + get => _rawConfidence; + init => _rawConfidence = value; + } + public SvProfileConfidence Confidence + { + get => SvProfileConfidencePolicy.ApplyEvidenceMaturityCeiling(_rawConfidence, Profile.EvidenceStatus); + init => _rawConfidence = value; + } public double ScorePercent { get; init; } public int MatchedWeight { get; init; } public int ConflictWeight { get; init; } @@ -171,3 +204,37 @@ public sealed record SvProfileDetectionResult public bool HasConflicts => Evidence.Any(item => item.Outcome == SvProfileEvidenceOutcome.Conflict); } + +public static class SvProfileConfidencePolicy +{ + public static SvProfileConfidence ApplyEvidenceMaturityCeiling( + SvProfileConfidence confidence, + SvProfileEvidenceStatus evidenceStatus) + { + if (confidence is SvProfileConfidence.Unknown or SvProfileConfidence.Conflict) + return confidence; + + var ceiling = evidenceStatus switch + { + SvProfileEvidenceStatus.ResearchCandidate => SvProfileConfidence.Possible, + SvProfileEvidenceStatus.ImplementedGeneric => SvProfileConfidence.Likely, + SvProfileEvidenceStatus.VerifiedStandard => SvProfileConfidence.Confirmed, + SvProfileEvidenceStatus.VerifiedCapture => SvProfileConfidence.Confirmed, + SvProfileEvidenceStatus.VerifiedLab => SvProfileConfidence.Confirmed, + _ => SvProfileConfidence.Possible + }; + + return Rank(confidence) <= Rank(ceiling) ? confidence : ceiling; + } + + private static int Rank(SvProfileConfidence confidence) + => confidence switch + { + SvProfileConfidence.Unknown => 0, + SvProfileConfidence.Possible => 1, + SvProfileConfidence.Likely => 2, + SvProfileConfidence.Confirmed => 3, + SvProfileConfidence.Conflict => 4, + _ => 0 + }; +} From 1882c666dc024009b40edb71db13b0401f4407fe Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 16:06:24 +0700 Subject: [PATCH 3/6] Bound and harden observation windows for live capture --- .../Profiles/SvObservationAccumulator.cs | 168 +++++++++++++++--- 1 file changed, 148 insertions(+), 20 deletions(-) diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvObservationAccumulator.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvObservationAccumulator.cs index 1953a7d..edc1f13 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvObservationAccumulator.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvObservationAccumulator.cs @@ -32,25 +32,68 @@ public void Validate() public sealed class SvObservationAccumulator { - private readonly List _observations = []; + public const int DefaultMaximumObservations = 4096; + public static readonly TimeSpan DefaultMaximumAge = TimeSpan.FromSeconds(5); - public int Count => _observations.Count; + private readonly object _gate = new(); + private readonly Queue _observations = new(); + private readonly int _maximumObservations; + private readonly TimeSpan _maximumAge; + + public SvObservationAccumulator( + int maximumObservations = DefaultMaximumObservations, + TimeSpan? maximumAge = null) + { + if (maximumObservations < 2) + throw new ArgumentOutOfRangeException(nameof(maximumObservations), "At least two observations are required for rate analysis."); + + _maximumAge = maximumAge ?? DefaultMaximumAge; + if (_maximumAge <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(maximumAge), "Observation age must be greater than zero."); + + _maximumObservations = maximumObservations; + } + + public int Count + { + get + { + lock (_gate) + return _observations.Count; + } + } + + public int MaximumObservations => _maximumObservations; + public TimeSpan MaximumAge => _maximumAge; public void Add(SvFrameObservation observation) { ArgumentNullException.ThrowIfNull(observation); observation.Validate(); - _observations.Add(observation); + + lock (_gate) + { + _observations.Enqueue(observation); + TrimWindow(observation.Timestamp); + } } - public void Clear() => _observations.Clear(); + public void Clear() + { + lock (_gate) + _observations.Clear(); + } public SvObservedStreamFacts BuildFacts() { - if (_observations.Count == 0) + SvFrameObservation[] snapshot; + lock (_gate) + snapshot = _observations.ToArray(); + + if (snapshot.Length == 0) return new SvObservedStreamFacts(); - var ordered = _observations.OrderBy(item => item.Timestamp).ToArray(); + var ordered = snapshot.OrderBy(item => item.Timestamp).ToArray(); var diagnostics = new List(); var first = ordered[0]; var last = ordered[^1]; @@ -69,7 +112,8 @@ public SvObservedStreamFacts BuildFacts() diagnostics.Add("At least two timestamped frames are required to estimate stream rate."); } - var counterWrap = DetectCounterWrap(ordered, diagnostics); + var counterAnalysis = AnalyzeCounters(ordered, diagnostics); + var provenance = BuildProvenance(); return new SvObservedStreamFacts { @@ -85,11 +129,13 @@ public SvObservedStreamFacts BuildFacts() PayloadBytesPerAsdu = StableValue(ordered.Select(item => item.PayloadBytesPerAsdu), "payload length", diagnostics), ObservedFramesPerSecond = framesPerSecond, ObservedSamplesPerSecond = samplesPerSecond, - ObservedCounterWrap = counterWrap, + ObservedCounterWrap = counterAnalysis.Wrap, + CounterTransitions = counterAnalysis.Summary, DeclaredSampleRate = StableNullableValue(ordered.Select(item => item.DeclaredSampleRate), "declared sample rate", diagnostics), DeclaredSampleMode = StableNullableValue(ordered.Select(item => item.DeclaredSampleMode), "declared sample mode", diagnostics), NominalFrequencyHz = StableNullableValue(ordered.Select(item => item.NominalFrequencyHz), "nominal frequency", diagnostics), DataSetSignature = StableSignature(ordered.Select(item => item.DataSetSignature), diagnostics), + Provenance = provenance, ObservationCount = ordered.Length, FirstTimestamp = first.Timestamp, LastTimestamp = last.Timestamp, @@ -97,32 +143,113 @@ public SvObservedStreamFacts BuildFacts() }; } - private static int? DetectCounterWrap( + private void TrimWindow(DateTimeOffset newestTimestamp) + { + while (_observations.Count > _maximumObservations) + _observations.Dequeue(); + + var oldestAllowed = newestTimestamp - _maximumAge; + while (_observations.Count > 0 && _observations.Peek().Timestamp < oldestAllowed) + _observations.Dequeue(); + } + + private static (int? Wrap, SvCounterTransitionSummary Summary) AnalyzeCounters( IReadOnlyList observations, List diagnostics) { var sampleCounts = observations.SelectMany(item => item.SampleCounts).ToArray(); if (sampleCounts.Length < 2) - return null; + return (null, new SvCounterTransitionSummary()); + + var sequential = 0; + var gaps = 0; + var duplicates = 0; + var outOfOrderOrReset = 0; + var confirmedWraps = 0; + var wrapCandidates = new HashSet(); - var wraps = new HashSet(); for (var index = 1; index < sampleCounts.Length; index++) { var previous = sampleCounts[index - 1]; var current = sampleCounts[index]; - if (current < previous) - wraps.Add(previous + 1); + + if (current == previous) + { + duplicates++; + continue; + } + + if (current == previous + 1) + { + sequential++; + continue; + } + + if (current > previous + 1) + { + gaps++; + continue; + } + + var hasSequentialRecovery = index + 1 < sampleCounts.Length && sampleCounts[index + 1] == current + 1; + if (current == 0 && hasSequentialRecovery && previous > 1) + { + confirmedWraps++; + wrapCandidates.Add(previous + 1); + continue; + } + + outOfOrderOrReset++; } - if (wraps.Count == 1) - return wraps.Single(); + int? wrap = null; + if (wrapCandidates.Count == 1) + wrap = wrapCandidates.Single(); + else if (wrapCandidates.Count > 1) + diagnostics.Add($"Multiple sample-counter wrap candidates were observed: {string.Join(", ", wrapCandidates.Order())}."); - if (wraps.Count > 1) - diagnostics.Add($"Multiple sample-counter wrap candidates were observed: {string.Join(", ", wraps.Order())}."); + if (gaps > 0) + diagnostics.Add($"Observed {gaps} forward sample-counter gap transition(s)."); + if (duplicates > 0) + diagnostics.Add($"Observed {duplicates} duplicate sample-counter transition(s)."); + if (outOfOrderOrReset > 0) + diagnostics.Add($"Observed {outOfOrderOrReset} out-of-order or reset transition(s); these were not classified as wraps."); - return null; + return ( + wrap, + new SvCounterTransitionSummary + { + SequentialCount = sequential, + GapCount = gaps, + DuplicateCount = duplicates, + OutOfOrderOrResetCount = outOfOrderOrReset, + ConfirmedWrapCount = confirmedWraps + }); } + private static IReadOnlyDictionary BuildProvenance() + => new Dictionary(StringComparer.Ordinal) + { + [nameof(SvObservedStreamFacts.EtherType)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.AppId)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.DestinationMac)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.VlanId)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.VlanPriority)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.SvId)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.DataSetReference)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.ConfigurationRevision)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.AsduPerFrame)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.PayloadBytesPerAsdu)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.DeclaredSampleRate)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.DeclaredSampleMode)] = SvFactSource.WireObserved, + [nameof(SvObservedStreamFacts.ObservedFramesPerSecond)] = SvFactSource.CaptureCalculated, + [nameof(SvObservedStreamFacts.ObservedSamplesPerSecond)] = SvFactSource.CaptureCalculated, + [nameof(SvObservedStreamFacts.ObservedCounterWrap)] = SvFactSource.CaptureCalculated, + [nameof(SvObservedStreamFacts.CounterTransitions)] = SvFactSource.CaptureCalculated, + [nameof(SvObservedStreamFacts.NominalFrequencyHz)] = SvFactSource.TrustedContext, + [nameof(SvObservedStreamFacts.DataSetSignature)] = SvFactSource.SclDerived + }; + private static T? StableValue( IEnumerable values, string field, @@ -171,7 +298,8 @@ private static IReadOnlyList StableSignature( IEnumerable> signatures, List diagnostics) { - var normalized = signatures + var materialized = signatures.ToArray(); + var normalized = materialized .Select(signature => signature.Select(ToKey).ToArray()) .ToArray(); @@ -180,7 +308,7 @@ private static IReadOnlyList StableSignature( var first = normalized[0]; if (normalized.All(signature => signature.SequenceEqual(first, StringComparer.Ordinal))) - return signatures.First().ToArray(); + return materialized[0].ToArray(); diagnostics.Add("Observed dataset signature changed within the analysis window."); return Array.Empty(); From 713ccf51264b13617d0f5f8c837e2ea46d3c2ee7 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 16:08:20 +0700 Subject: [PATCH 4/6] Test evidence maturity confidence ceilings --- .../Profiles/SvProfileDetectorTests.cs | 57 ++++++++----------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileDetectorTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileDetectorTests.cs index a0b8240..b3ac257 100644 --- a/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileDetectorTests.cs +++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileDetectorTests.cs @@ -6,20 +6,28 @@ namespace ARSVIN.Tests.SampledValues.Profiles; public sealed class SvProfileDetectorTests { [Fact] - public void ExactSyntheticDefinitionProducesConfirmedExplainableResult() + public void ExactVerifiedDefinitionProducesConfirmedExplainableResult() { - var detector = new SvProfileDetector(); - var result = detector.Evaluate(CreateMatchingFacts(), CreateSyntheticProfile()); + var result = new SvProfileDetector().Evaluate(CreateMatchingFacts(), CreateSyntheticProfile(SvProfileEvidenceStatus.VerifiedLab)); + Assert.Equal(SvProfileConfidence.Confirmed, result.RawConfidence); Assert.Equal(SvProfileConfidence.Confirmed, result.Confidence); Assert.Equal(100, result.ScorePercent); Assert.False(result.HasConflicts); - Assert.Contains(result.Evidence, item => - item.Field == "Dataset signature" && - item.Outcome == SvProfileEvidenceOutcome.Match); - Assert.Contains(result.Evidence, item => - item.Field == "Observed samples per second" && - item.Message.Contains("within", StringComparison.Ordinal)); + Assert.Contains(result.Evidence, item => item.Field == "Dataset signature" && item.Outcome == SvProfileEvidenceOutcome.Match); + } + + [Theory] + [InlineData(SvProfileEvidenceStatus.ResearchCandidate, SvProfileConfidence.Possible)] + [InlineData(SvProfileEvidenceStatus.ImplementedGeneric, SvProfileConfidence.Likely)] + public void UnverifiedDefinitionCannotProduceConfirmedPublicConfidence( + SvProfileEvidenceStatus status, + SvProfileConfidence expectedCeiling) + { + var result = new SvProfileDetector().Evaluate(CreateMatchingFacts(), CreateSyntheticProfile(status)); + + Assert.Equal(SvProfileConfidence.Confirmed, result.RawConfidence); + Assert.Equal(expectedCeiling, result.Confidence); } [Fact] @@ -33,22 +41,17 @@ public void ConflictingWireFactsReturnConflictWithFieldEvidence() ObservedCounterWrap = 2000 }; - var result = new SvProfileDetector().Evaluate(facts, CreateSyntheticProfile()); + var result = new SvProfileDetector().Evaluate(facts, CreateSyntheticProfile(SvProfileEvidenceStatus.VerifiedLab)); Assert.Equal(SvProfileConfidence.Conflict, result.Confidence); Assert.True(result.HasConflicts); - Assert.Contains(result.Evidence, item => - item.Field == "Payload bytes per ASDU" && - item.Outcome == SvProfileEvidenceOutcome.Conflict); - Assert.Contains(result.Evidence, item => - item.Field == "Sample-counter wrap" && - item.Observed == "2000"); + Assert.Contains(result.Evidence, item => item.Field == "Payload bytes per ASDU" && item.Outcome == SvProfileEvidenceOutcome.Conflict); } [Fact] public void MissingObservedFactsRemainUnknownAndDoNotCreateFalseMatch() { - var result = new SvProfileDetector().Evaluate(new SvObservedStreamFacts(), CreateSyntheticProfile()); + var result = new SvProfileDetector().Evaluate(new SvObservedStreamFacts(), CreateSyntheticProfile(SvProfileEvidenceStatus.VerifiedLab)); Assert.Equal(SvProfileConfidence.Unknown, result.Confidence); Assert.Equal(0, result.EvaluatedWeight); @@ -58,11 +61,7 @@ public void MissingObservedFactsRemainUnknownAndDoNotCreateFalseMatch() [Fact] public void SparseGenericEvidenceCannotProduceFalseConfirmation() { - var facts = new SvObservedStreamFacts - { - EtherType = 0x88BA, - ObservationCount = 1 - }; + var facts = new SvObservedStreamFacts { EtherType = 0x88BA, ObservationCount = 1 }; var result = new SvProfileDetector().Evaluate(facts, SvProfileCatalog.GenericSclLayer2); @@ -82,12 +81,9 @@ public void MatchingStaticFieldsWithoutDatasetOrRateRemainOnlyPossible() ObservedCounterWrap = null }; - var result = new SvProfileDetector().Evaluate(facts, CreateSyntheticProfile()); + var result = new SvProfileDetector().Evaluate(facts, CreateSyntheticProfile(SvProfileEvidenceStatus.VerifiedLab)); Assert.Equal(SvProfileConfidence.Possible, result.Confidence); - Assert.DoesNotContain(result.Evidence, item => - item.Field == "Dataset signature" && - item.Outcome == SvProfileEvidenceOutcome.Match); } [Fact] @@ -121,7 +117,7 @@ private static SvObservedStreamFacts CreateMatchingFacts() ObservationCount = 100 }; - private static SvProfileDefinition CreateSyntheticProfile() + private static SvProfileDefinition CreateSyntheticProfile(SvProfileEvidenceStatus evidenceStatus) => new() { Id = "synthetic-protection-profile", @@ -141,13 +137,10 @@ private static SvProfileDefinition CreateSyntheticProfile() AllowedNominalFrequenciesHz = [50], ExpectedCounterWrap = 4000, RateTolerancePercent = 0.5, - EvidenceStatus = SvProfileEvidenceStatus.ResearchCandidate, + EvidenceStatus = evidenceStatus, Sources = [ - new SvProfileSourceEvidence( - "test-fixture", - "Synthetic values used only to verify detector behavior.", - SvProfileEvidenceStatus.ResearchCandidate) + new SvProfileSourceEvidence("test-fixture", "Synthetic values used only to verify detector behavior.", evidenceStatus) ] }; } From ae036ad492ef9d9b1da1ed87c258028cbdf3cbb6 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 16:09:05 +0700 Subject: [PATCH 5/6] Protect SV identifiers from false punctuation matches --- .../Profiles/SvConfigurationComparerTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvConfigurationComparerTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvConfigurationComparerTests.cs index fd86fc9..2573dd7 100644 --- a/tests/ARSVIN.Tests/SampledValues/Profiles/SvConfigurationComparerTests.cs +++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvConfigurationComparerTests.cs @@ -46,6 +46,26 @@ public void EquivalentMacFormattingDoesNotCreateFalseMismatch() Assert.True(result.IsExactMatch); } + [Theory] + [InlineData("MU-01SV01", "MU01SV01", "SV_ID_MISMATCH")] + [InlineData("MU01MUnn/LLN0$Phs-Meas", "MU01MUnn/LLN0$PhsMeas", "SV_DATASET_MISMATCH")] + public void IdentifierPunctuationIsSemanticallySignificant( + string expectedIdentifier, + string observedIdentifier, + string expectedCode) + { + var expected = expectedCode == "SV_ID_MISMATCH" + ? CreateExpected() with { SvId = expectedIdentifier } + : CreateExpected() with { DataSetReference = expectedIdentifier }; + var observed = expectedCode == "SV_ID_MISMATCH" + ? CreateObserved() with { SvId = observedIdentifier } + : CreateObserved() with { DataSetReference = observedIdentifier }; + + var result = new SvConfigurationComparer().Compare(expected, observed, SvComparisonMode.Strict); + + Assert.Equal(expectedCode, Assert.Single(result.Findings).Code); + } + [Fact] public void MissingDatasetSignatureIsReportedWithoutThrowing() { From 6e818e9b5786e8c9b934f87f2bca11673503de24 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 16:09:44 +0700 Subject: [PATCH 6/6] Test bounded windows and counter transition classification --- .../Profiles/SvObservationAccumulatorTests.cs | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvObservationAccumulatorTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvObservationAccumulatorTests.cs index ac7202c..b61480c 100644 --- a/tests/ARSVIN.Tests/SampledValues/Profiles/SvObservationAccumulatorTests.cs +++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvObservationAccumulatorTests.cs @@ -6,7 +6,7 @@ namespace ARSVIN.Tests.SampledValues.Profiles; public sealed class SvObservationAccumulatorTests { [Fact] - public void BuildFactsCalculatesRatesAndCounterWrapWithoutTransportDependencies() + public void BuildFactsCalculatesRatesAndConfirmedCounterWrapWithoutTransportDependencies() { var accumulator = new SvObservationAccumulator(); accumulator.Add(CreateObservation(0, [3998, 3999])); @@ -23,9 +23,70 @@ public void BuildFactsCalculatesRatesAndCounterWrapWithoutTransportDependencies( Assert.Equal(1000, facts.ObservedFramesPerSecond!.Value, precision: 6); Assert.Equal(2000, facts.ObservedSamplesPerSecond!.Value, precision: 6); Assert.Equal(4000, facts.ObservedCounterWrap); + Assert.Equal(1, facts.CounterTransitions.ConfirmedWrapCount); + Assert.Equal(SvFactSource.CaptureCalculated, facts.Provenance[nameof(SvObservedStreamFacts.ObservedCounterWrap)]); Assert.Empty(facts.Diagnostics); } + [Fact] + public void OutOfOrderCounterIsNotMisclassifiedAsWrap() + { + var accumulator = new SvObservationAccumulator(); + accumulator.Add(CreateObservation(0, [100, 101])); + accumulator.Add(CreateObservation(1, [99, 102])); + + var facts = accumulator.BuildFacts(); + + Assert.Null(facts.ObservedCounterWrap); + Assert.Equal(1, facts.CounterTransitions.OutOfOrderOrResetCount); + Assert.Contains(facts.Diagnostics, item => item.Contains("not classified as wraps", StringComparison.Ordinal)); + } + + [Fact] + public void DuplicateAndGapTransitionsAreReportedSeparately() + { + var accumulator = new SvObservationAccumulator(); + accumulator.Add(CreateObservation(0, [10, 10])); + accumulator.Add(CreateObservation(1, [13, 14])); + + var facts = accumulator.BuildFacts(); + + Assert.Equal(1, facts.CounterTransitions.DuplicateCount); + Assert.Equal(1, facts.CounterTransitions.GapCount); + Assert.Contains(facts.Diagnostics, item => item.Contains("duplicate", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(facts.Diagnostics, item => item.Contains("gap", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void AccumulatorKeepsOnlyConfiguredMaximumObservationCount() + { + var accumulator = new SvObservationAccumulator(maximumObservations: 3, maximumAge: TimeSpan.FromMinutes(1)); + + for (var index = 0; index < 6; index++) + accumulator.Add(CreateObservation(index, [(ushort)index])); + + var facts = accumulator.BuildFacts(); + + Assert.Equal(3, accumulator.Count); + Assert.Equal(3, facts.ObservationCount); + Assert.Equal(DateTimeOffset.UnixEpoch.AddMilliseconds(3), facts.FirstTimestamp); + } + + [Fact] + public void AccumulatorDropsObservationsOutsideMaximumAge() + { + var accumulator = new SvObservationAccumulator(maximumObservations: 100, maximumAge: TimeSpan.FromMilliseconds(2)); + accumulator.Add(CreateObservation(0, [0])); + accumulator.Add(CreateObservation(1, [1])); + accumulator.Add(CreateObservation(4, [4])); + + var facts = accumulator.BuildFacts(); + + Assert.Single(new[] { facts.ObservationCount }); + Assert.Equal(1, facts.ObservationCount); + Assert.Equal(DateTimeOffset.UnixEpoch.AddMilliseconds(4), facts.FirstTimestamp); + } + [Fact] public void BuildFactsMarksChangingConfigurationAsUnknownInsteadOfThrowing() {