From 64d9342c6d824f668a378b2b91ec6869b64fe7ec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:04:09 +0700 Subject: [PATCH 1/5] feat(rcb): exact-reference-first candidate ordering with bounded continuation --- .../MmsCanonicalStaticLiveCandidatePlanner.cs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs new file mode 100644 index 00000000..2d96d28e --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs @@ -0,0 +1,53 @@ +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +/// +/// Selects exact configured RCB references ahead of bounded discovery fallback. +/// A reference match authorizes READ-only DatSet verification, not activation. +/// Unknown candidates remain deferred with a stable continuation offset. +/// +public static class MmsCanonicalStaticLiveCandidatePlanner +{ + public static MmsCanonicalStaticLiveCandidatePage Select( + CanonicalStaticReportCoveragePlan coverage, + IReadOnlyList candidates, + int maxReads, + int continuationOffset = 0) + { + ArgumentNullException.ThrowIfNull(coverage); + ArgumentNullException.ThrowIfNull(candidates); + if (maxReads < 1) throw new ArgumentOutOfRangeException(nameof(maxReads)); + if (continuationOffset < 0) throw new ArgumentOutOfRangeException(nameof(continuationOffset)); + + var expected = coverage.Segments + .SelectMany(segment => segment.ReportControls) + .Select(report => report.Reference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .ToHashSet(StringComparer.Ordinal); + + var ordered = candidates + .OrderBy(candidate => candidate.Reference, StringComparer.Ordinal) + .ToArray(); + var exact = ordered.Where(candidate => expected.Contains(candidate.Reference)).ToArray(); + var unknown = ordered.Where(candidate => !expected.Contains(candidate.Reference)).ToArray(); + + // Discovery without an exact catalog reference is not evidence that an + // arbitrary first RCB belongs to the selected DataSet. Only bounded + // read-only DatSet checks may resolve it, and completion remains partial. + var selected = exact.Skip(Math.Min(continuationOffset, exact.Length)) + .Concat(unknown.Skip(Math.Max(0, continuationOffset - exact.Length))) + .Take(maxReads) + .ToArray(); + var next = Math.Min(ordered.Length, continuationOffset + selected.Length); + return new MmsCanonicalStaticLiveCandidatePage( + selected, ordered.Length, exact.Length, + next < ordered.Length ? next : null); + } +} + +public sealed record MmsCanonicalStaticLiveCandidatePage( + IReadOnlyList Candidates, + int CatalogCount, + int ExactReferenceCount, + int? NextOffset); From 4af3e620b4fd87a1807cc6336d357aaa1f36d4cb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:04:27 +0700 Subject: [PATCH 2/5] fix(rcb): avoid unrelated reads when all exact configured RCB names are discovered --- .../Mms/MmsCanonicalStaticLiveCandidatePlanner.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs index 2d96d28e..bb531efe 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs @@ -35,14 +35,17 @@ public static MmsCanonicalStaticLiveCandidatePage Select( // Discovery without an exact catalog reference is not evidence that an // arbitrary first RCB belongs to the selected DataSet. Only bounded // read-only DatSet checks may resolve it, and completion remains partial. - var selected = exact.Skip(Math.Min(continuationOffset, exact.Length)) - .Concat(unknown.Skip(Math.Max(0, continuationOffset - exact.Length))) - .Take(maxReads) - .ToArray(); - var next = Math.Min(ordered.Length, continuationOffset + selected.Length); + // If every configured target has an exact live catalog reference, + // never probe unrelated siblings. A failed DatSet verification remains + // unresolved rather than silently falling back to broad mutation. + var allTargetsMapped = expected.Count > 0 && expected.All(reference => + exact.Any(candidate => candidate.Reference == reference)); + var scope = allTargetsMapped ? exact : exact.Concat(unknown).ToArray(); + var selected = scope.Skip(continuationOffset).Take(maxReads).ToArray(); + var next = Math.Min(scope.Length, continuationOffset + selected.Length); return new MmsCanonicalStaticLiveCandidatePage( selected, ordered.Length, exact.Length, - next < ordered.Length ? next : null); + next < scope.Length ? next : null); } } From 3ed91c23010c8b5ca68ec806633f25266b2c5d6e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:04:49 +0700 Subject: [PATCH 3/5] fix(rcb): replace global 64-candidate abort with exact scoped bounded read pages --- .../MmsCanonicalStaticLiveReconciliation.cs | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs index d2f3b82e..8d18e8ae 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs @@ -55,6 +55,9 @@ public sealed class MmsCanonicalStaticLiveReconciliationOptions /// The operation fails closed rather than reading an arbitrary subset. /// public int MaxReportControlCandidates { get; init; } = 64; + + /// Stable index for resuming deferred candidate READs in this inventory. + public int CandidateContinuationOffset { get; init; } } public sealed class MmsCanonicalStaticLiveReconciliationResult @@ -65,6 +68,8 @@ public sealed class MmsCanonicalStaticLiveReconciliationResult public int EnumeratedVariableCount { get; init; } public int CandidateReportControlCount { get; init; } public int DataSetBoundReportControlCount { get; init; } + public int? NextCandidateOffset { get; init; } + public int CandidateReadCount { get; init; } public IReadOnlyList Warnings { get; init; } = Array.Empty(); public bool HasLiveInventory => @@ -280,31 +285,18 @@ public async Task ReconcileCanonical .ToArray(); var maxCandidates = Math.Clamp(options.MaxReportControlCandidates, 1, 512); - if (candidates.Length > maxCandidates) - { - return new MmsCanonicalStaticLiveReconciliationResult - { - Plan = plan, - Status = MmsCanonicalStaticLiveReconciliationStatus.CandidateBudgetExceeded, - Inventory = emptyInventory, - EnumeratedVariableCount = variables.Values.Sum(list => list.Count), - CandidateReportControlCount = candidates.Length, - Warnings = plan.Warnings - .Concat(new[] - { - $"Target domains expose {candidates.Length} required-family RCB candidates, exceeding live reconciliation budget {maxCandidates}; no partial RCB read subset was started." - }) - .ToArray() - }; - } + var page = MmsCanonicalStaticLiveCandidatePlanner.Select( + coverage, candidates, maxCandidates, options.CandidateContinuationOffset); + // A page is a work budget, not an inventory completeness claim. + // Discovery never performs RCB writes or steals another association's reservation. var inventory = new MmsReportInventory { Authority = MmsReportInventoryAuthority.LiveMmsObserved }; - inventory.ReportControls.AddRange(candidates); + inventory.ReportControls.AddRange(page.Candidates); - foreach (var candidate in candidates) + foreach (var candidate in page.Candidates) { cancellationToken.ThrowIfCancellationRequested(); @@ -326,8 +318,10 @@ await TryReadReportAttributeSmartAsync( break; } - var complete = IsMmsInitiated && chains.All(chain => chain.IsComplete); + var complete = IsMmsInitiated && chains.All(chain => chain.IsComplete) && page.NextOffset is null; var warnings = plan.Warnings + .Concat(page.NextOffset is null ? Array.Empty() : + [$"RCB candidate checks deferred; resume from candidate offset {page.NextOffset.Value}."]) .Concat(chains .Where(chain => !chain.IsComplete) .Select(chain => $"Target domain '{chain.Domain}' NamedVariable enumeration is partial: {chain.Message}")) @@ -348,7 +342,9 @@ await TryReadReportAttributeSmartAsync( Inventory = inventory, EnumeratedVariableCount = variables.Values.Sum(list => list.Count), CandidateReportControlCount = candidates.Length, - DataSetBoundReportControlCount = candidates.Count(candidate => + CandidateReadCount = page.Candidates.Count, + NextCandidateOffset = page.NextOffset, + DataSetBoundReportControlCount = page.Candidates.Count(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference)), Warnings = warnings }; From 7fcbbd46ceee85279b1b0ee42dce8676dbde4507 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:05:19 +0700 Subject: [PATCH 4/5] test(rcb): 2/66/300 catalogs, exact targeting, deterministic bounded continuation --- ...anonicalStaticLiveCandidatePlannerTests.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveCandidatePlannerTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveCandidatePlannerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveCandidatePlannerTests.cs new file mode 100644 index 00000000..e117440e --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveCandidatePlannerTests.cs @@ -0,0 +1,96 @@ +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsCanonicalStaticLiveCandidatePlannerTests +{ + [Theory] + [InlineData(2)] + [InlineData(66)] + [InlineData(300)] + public void Exact_Target_Reads_Are_Independent_Of_Global_Rcb_Count(int catalogSize) + { + var catalog = Enumerable.Range(0, catalogSize) + .Select(i => Candidate($"IEDLD0/LLN0.BR.R{i:000}")) + .Reverse().ToArray(); + var exact = $"IEDLD0/LLN0.BR.R{catalogSize-1:000}"; + var page = MmsCanonicalStaticLiveCandidatePlanner.Select( + Coverage(exact), catalog, maxReads: 4); + + Assert.Equal(catalogSize, page.CatalogCount); + Assert.Equal(1, page.ExactReferenceCount); + Assert.Equal(exact, Assert.Single(page.Candidates).Reference); + Assert.Null(page.NextOffset); + } + + [Fact] + public void Unresolved_Catalog_Pages_Resume_Without_Repeats_And_Never_Claim_Completeness() + { + var catalog = Enumerable.Range(0, 300) + .Select(i => Candidate($"IEDLD0/LLN0.BR.R{i:000}")).Reverse().ToArray(); + var coverage = Coverage("IEDLD0/LLN0.BR.UNKNOWN"); + var all = new List(); + int? offset = 0; + for (var pageNumber = 0; offset is not null; pageNumber++) + { + Assert.True(pageNumber < 10); + var page = MmsCanonicalStaticLiveCandidatePlanner.Select( + coverage, catalog, 64, offset.Value); + Assert.InRange(page.Candidates.Count, 1, 64); + all.AddRange(page.Candidates.Select(item => item.Reference)); + offset = page.NextOffset; + } + Assert.Equal(300, all.Count); + Assert.Equal(300, all.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(all.OrderBy(item => item, StringComparer.Ordinal), all); + } + + [Fact] + public void Exact_Multi_Target_Order_Is_Deterministic() + { + var catalog = Enumerable.Range(0, 66) + .Select(i => Candidate($"IEDLD0/LLN0.BR.R{i:000}")).Reverse().ToArray(); + var coverage = Coverage("IEDLD0/LLN0.BR.R030", "IEDLD0/LLN0.BR.R010"); + var page = MmsCanonicalStaticLiveCandidatePlanner.Select(coverage, catalog, 8); + Assert.Equal(new[] {"IEDLD0/LLN0.BR.R010", "IEDLD0/LLN0.BR.R030"}, + page.Candidates.Select(x => x.Reference)); + Assert.Null(page.NextOffset); + } + + private static MmsReportControlCandidate Candidate(string reference) => new() + { + Reference = reference, + Domain = "IEDLD0", + LogicalNode = "LLN0", + Buffered = true, + FunctionalConstraint = "BR", + Name = reference.Split('.').Last() + }; + + private static CanonicalStaticReportCoveragePlan Coverage(params string[] reports) => new() + { + Signals = [new CanonicalStaticReportSignalCoverage + { + RequestedReference = "IEDLD0/XCBR1.Pos.stVal", + CanonicalReference = "IEDLD0/XCBR1.Pos.stVal", + Status = CanonicalStaticReportSignalCoverageStatus.Covered + }], + Segments = [new CanonicalStaticReportCoverageSegment + { + DataSetReference = "IEDLD0/LLN0.Events", + SourceDataSetReference = "IEDLD0/LLN0.Events", + SelectedSignalReferences = ["IEDLD0/XCBR1.Pos.stVal"], + ReportControls = reports.Select(reference => new CanonicalReportControl + { + Reference = reference, + MmsDomain = "IEDLD0", + LogicalNode = "LLN0", + Buffered = true, + DataSetReference = "IEDLD0/LLN0.Events", + Provenance = new CanonicalProvenance( + CanonicalEvidenceSource.SclDeclared, CanonicalConfidence.Exact) + }).ToArray() + }] + }; +} From c9573d2995a5e57f174a5447322e7f9bf0ecfbc3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:06:05 +0700 Subject: [PATCH 5/5] fix(rcb): admit only read-proven exact DataSet bindings into operational inventory --- .../MmsCanonicalStaticLiveReconciliation.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs index 8d18e8ae..e33d9682 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs @@ -294,7 +294,10 @@ public async Task ReconcileCanonical { Authority = MmsReportInventoryAuthority.LiveMmsObserved }; - inventory.ReportControls.AddRange(page.Candidates); + var expectedDataSets = coverage.Segments + .Select(segment => segment.DataSetReference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .ToHashSet(StringComparer.Ordinal); foreach (var candidate in page.Candidates) { @@ -314,6 +317,11 @@ await TryReadReportAttributeSmartAsync( .ConfigureAwait(false); } + // A catalog name alone never qualifies operational routing. + // DatSet must be read and match the selected static segment exactly. + if (expectedDataSets.Contains(candidate.DataSetReference)) + inventory.ReportControls.Add(candidate); + if (!IsMmsInitiated) break; } @@ -325,9 +333,9 @@ await TryReadReportAttributeSmartAsync( .Concat(chains .Where(chain => !chain.IsComplete) .Select(chain => $"Target domain '{chain.Domain}' NamedVariable enumeration is partial: {chain.Message}")) - .Concat(candidates - .Where(candidate => string.IsNullOrWhiteSpace(candidate.DataSetReference)) - .Select(candidate => $"Live RCB '{candidate.Reference}' did not expose a usable DatSet binding.")) + .Concat(page.Candidates + .Where(candidate => !expectedDataSets.Contains(candidate.DataSetReference)) + .Select(candidate => $"Live RCB '{candidate.Reference}' is not proven bound to a selected exact DataSet.")) .Distinct(StringComparer.Ordinal) .ToArray(); @@ -344,8 +352,7 @@ await TryReadReportAttributeSmartAsync( CandidateReportControlCount = candidates.Length, CandidateReadCount = page.Candidates.Count, NextCandidateOffset = page.NextOffset, - DataSetBoundReportControlCount = page.Candidates.Count(candidate => - !string.IsNullOrWhiteSpace(candidate.DataSetReference)), + DataSetBoundReportControlCount = inventory.ReportControls.Count, Warnings = warnings }; }