diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs
new file mode 100644
index 00000000..bb531efe
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveCandidatePlanner.cs
@@ -0,0 +1,56 @@
+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.
+ // 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 < scope.Length ? next : null);
+ }
+}
+
+public sealed record MmsCanonicalStaticLiveCandidatePage(
+ IReadOnlyList Candidates,
+ int CatalogCount,
+ int ExactReferenceCount,
+ int? NextOffset);
diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs
index d2f3b82e..e33d9682 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,21 @@ 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);
+ var expectedDataSets = coverage.Segments
+ .Select(segment => segment.DataSetReference)
+ .Where(reference => !string.IsNullOrWhiteSpace(reference))
+ .ToHashSet(StringComparer.Ordinal);
- foreach (var candidate in candidates)
+ foreach (var candidate in page.Candidates)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -322,18 +317,25 @@ 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;
}
- 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}"))
- .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();
@@ -348,8 +350,9 @@ await TryReadReportAttributeSmartAsync(
Inventory = inventory,
EnumeratedVariableCount = variables.Values.Sum(list => list.Count),
CandidateReportControlCount = candidates.Length,
- DataSetBoundReportControlCount = candidates.Count(candidate =>
- !string.IsNullOrWhiteSpace(candidate.DataSetReference)),
+ CandidateReadCount = page.Candidates.Count,
+ NextCandidateOffset = page.NextOffset,
+ DataSetBoundReportControlCount = inventory.ReportControls.Count,
Warnings = warnings
};
}
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()
+ }]
+ };
+}