Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
cfd2a54
fix(m217k): project typed ST enum status FCD tuples for C264 Mod/PhyH…
masarray Oct 17, 2026
54bdc4b
test(m217k): physical C264 typed Mod/Beh/PhyHealth ST FCD and strict …
masarray Oct 17, 2026
e61989f
ci(m217k): run engine Windows test gate on pinned PR169 stacked branch
masarray Oct 17, 2026
51c2eef
test(m217k): incomplete live schema must not suppress typed C264 ST F…
masarray Oct 17, 2026
593399e
test(identity): expose false BCU truncation and unverified heuristic …
masarray Oct 19, 2026
5334194
fix(identity): retain full MMS domain prefix and require unique SCL e…
masarray Oct 19, 2026
d92c750
fix(identity): reject ambiguous SCL AP topology; assert confidence ca…
masarray Oct 19, 2026
3152ab0
test(rcb): reject exact-named control bound to a different live DataSet
masarray Oct 19, 2026
36ec638
feat(rcb): introduce deterministic target-scoped read-only candidate …
masarray Oct 19, 2026
34b1fb5
fix(rcb): replace global candidate rejection with exact-scope resumab…
masarray Oct 19, 2026
689e472
fix(rcb): never promote name-only RCB identity to operational DataSet…
masarray Oct 19, 2026
614090f
test(rcb): guard unknown DatSet and contradictory binding
masarray Oct 19, 2026
1ec0ec5
test(rcb): synthetic 66/300 RCB target-scope, continuation and ordering
masarray Oct 19, 2026
80128cc
fix(rcb): reject structurally inconsistent RP/BR candidate family evi…
masarray Oct 19, 2026
1ed776a
fix(rcb): restore syntactically valid source and reject RP/BR family …
masarray Oct 19, 2026
9630dd7
test(mms): RED malformed boolean/int tags must not become observed fa…
masarray Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/dotnet-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ on:
- "*.slnx"
- "Directory.Build.props"
pull_request:
branches: [ main, feat/495-dataset-type-closure ]
# The live ARSAS product pins PR #169 until targeted engine changes pass CI.
# Stacked engine PRs must run the exact same Windows build/test gate.
branches: [ main, feat/495-dataset-type-closure, fix/p10-9l-readonly-ctlmodel-probe ]
workflow_dispatch:

permissions:
Expand Down
27 changes: 7 additions & 20 deletions src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public static LiveIedIdentity Resolve(
if (!string.IsNullOrWhiteSpace(explicitIedName))
{
var name = explicitIedName.Trim();
return Create(name, "ExplicitOverride", LiveIedDiscoveryConfidenceLevel.Exact, false, [name], materialized,
return Create(name, "ExplicitOverride", LiveIedDiscoveryConfidenceLevel.Medium, false, [name], materialized,
[$"IED name was supplied explicitly as '{name}'."]);
}

Expand All @@ -64,9 +64,7 @@ public static LiveIedIdentity Resolve(
if (distinctSuffixCandidates.Length == 1)
{
var name = distinctSuffixCandidates[0];
var confidence = materialized.Length > 1
? LiveIedDiscoveryConfidenceLevel.High
: LiveIedDiscoveryConfidenceLevel.Medium;
var confidence = LiveIedDiscoveryConfidenceLevel.Medium;
var evidence = suffixMatches
.Select(match => $"MMS domain '{match.Domain}' matched the logical-device suffix pattern for IED '{name}'.")
.ToArray();
Expand All @@ -89,17 +87,15 @@ public static LiveIedIdentity Resolve(
var commonPrefix = InferCommonPrefix(materialized);
if (!string.IsNullOrWhiteSpace(commonPrefix))
{
var confidence = materialized.Length >= 3
? LiveIedDiscoveryConfidenceLevel.High
: LiveIedDiscoveryConfidenceLevel.Medium;
var confidence = LiveIedDiscoveryConfidenceLevel.Medium;
return Create(
commonPrefix,
"MmsDomainCommonPrefix",
confidence,
false,
[commonPrefix],
materialized,
[$"IED name '{commonPrefix}' was derived from the common prefix of {materialized.Length} MMS domain(s)."]);
[$"Provisional IED-name candidate '{commonPrefix}' comes from {materialized.Length} MMS domains; this is not verified wire identity."]);
}

// Retain the useful single-domain behavior (for example OLSF501LD0), but do not
Expand All @@ -112,7 +108,7 @@ public static LiveIedIdentity Resolve(
return Create(
name,
"MmsDomainKnownLogicalDeviceSuffix",
materialized.Length == 1 ? LiveIedDiscoveryConfidenceLevel.Medium : LiveIedDiscoveryConfidenceLevel.High,
LiveIedDiscoveryConfidenceLevel.Medium,
false,
distinctSuffixCandidates,
materialized,
Expand Down Expand Up @@ -235,18 +231,9 @@ private static string InferCommonPrefix(IReadOnlyList<string> domains)
return string.Empty;
}

// An exact shared prefix can itself end in a common logical-device stem.
// Only verified model evidence can justify removing that stem.
prefix = TrimBoundary(prefix);
foreach (var stem in KnownLogicalDeviceStems)
{
if (prefix.EndsWith(stem, StringComparison.OrdinalIgnoreCase) && prefix.Length > stem.Length)
{
var withoutStem = TrimBoundary(prefix[..^stem.Length]);
if (IsViableName(withoutStem))
prefix = withoutStem;
break;
}
}

return IsViableName(prefix) ? prefix : string.Empty;
}

Expand Down
20 changes: 10 additions & 10 deletions src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public static class TrustedSclIedIdentityMatcher
if (observed.Count == 0) return null;

var matches = new List<(string Ied, string Ap, Dictionary<string,string> Aliases, string? Ip)>();
var topologyMatchCount = 0;
foreach (var ied in trustedScl.Root.Elements(Scl + "IED"))
{
var name = ((string?)ied.Attribute("name") ?? "").Trim();
Expand Down Expand Up @@ -65,32 +66,31 @@ public static class TrustedSclIedIdentityMatcher
}
}
if (invalid || aliases.Count != observed.Count) continue;
topologyMatchCount++; // AP ambiguity is structural even if another AP lacks endpoint evidence.

var connected = trustedScl.Root.Element(Scl + "Communication")?
.Descendants(Scl + "ConnectedAP")
.Where(x => string.Equals((string?)x.Attribute("iedName"),name,StringComparison.OrdinalIgnoreCase)
&& string.Equals((string?)x.Attribute("apName"),apName,StringComparison.OrdinalIgnoreCase))
.ToArray() ?? Array.Empty<XElement>();
if (connected.Length > 1) continue;
// A uniquely matched endpoint is required before promoting SCL identity.
if (connected.Length != 1 || string.IsNullOrWhiteSpace(connectedHost)) continue;
var ipValues = connected.SingleOrDefault()?
.Element(Scl + "Address")?.Elements(Scl + "P")
.Where(x => string.Equals((string?)x.Attribute("type"),"IP",StringComparison.OrdinalIgnoreCase))
.Select(x => x.Value.Trim()).Take(2).ToArray() ?? Array.Empty<string>();
if (ipValues.Length > 1) continue;
if (ipValues.Length != 1) continue;
var ip = ipValues.SingleOrDefault();
if (!string.IsNullOrWhiteSpace(ip) && !string.IsNullOrWhiteSpace(connectedHost))
{
if (!IPAddress.TryParse(ip,out var designIp) ||
!IPAddress.TryParse(connectedHost.Trim(),out var observedIp) ||
!designIp.Equals(observedIp))
continue; // never promote a model for a different endpoint
}
if (!IPAddress.TryParse(ip, out var designIp) ||
!IPAddress.TryParse(connectedHost!.Trim(), out var observedIp) ||
!designIp.Equals(observedIp))
continue; // Never promote a model with an unverified endpoint.
matches.Add((name, apName, aliases, ip));
}
}
// A single IED can have multiple APs and the same LD topology. This
// remains ambiguous until AP is independently selected/proven.
if (matches.Count != 1) return null;
if (topologyMatchCount != 1 || matches.Count != 1) return null;
var match=matches[0];
return new LiveIedIdentity
{
Expand Down
61 changes: 25 additions & 36 deletions src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ public sealed class MmsCanonicalStaticLiveReconciliationOptions
/// The operation fails closed rather than reading an arbitrary subset.
/// </summary>
public int MaxReportControlCandidates { get; init; } = 64;

/// <summary>Zero-based continuation offset for one bounded read-only candidate window.</summary>
public int CandidateOffset { get; init; }
}

public sealed class MmsCanonicalStaticLiveReconciliationResult
Expand All @@ -64,6 +67,9 @@ public sealed class MmsCanonicalStaticLiveReconciliationResult
public MmsReportInventory Inventory { get; init; } = new();
public int EnumeratedVariableCount { get; init; }
public int CandidateReportControlCount { get; init; }
public int ExaminedCandidateCount { get; init; }
public int DeferredCandidateCount { get; init; }
public int? NextCandidateOffset { get; init; }
public int DataSetBoundReportControlCount { get; init; }
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();

Expand All @@ -72,8 +78,8 @@ public sealed class MmsCanonicalStaticLiveReconciliationResult

public string Summary =>
$"targeted live RCB reconciliation: status={Status}, domains={Plan.Domains.Count}, " +
$"rawVariables={EnumeratedVariableCount}, candidates={CandidateReportControlCount}, " +
$"datasetBound={DataSetBoundReportControlCount}, authority={Inventory.Authority}.";
$"rawVariables={EnumeratedVariableCount}, eligibleCandidates={CandidateReportControlCount}, " +
$"examined={ExaminedCandidateCount}, deferred={DeferredCandidateCount}, next={NextCandidateOffset?.ToString() ?? "none"}, datasetBound={DataSetBoundReportControlCount}, authority={Inventory.Authority}.";
}

public sealed class MmsCanonicalStaticSmartPreparationResult
Expand Down Expand Up @@ -265,38 +271,15 @@ public async Task<MmsCanonicalStaticLiveReconciliationResult> ReconcileCanonical
DomainVariableLists = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
});

var targetsByDomain = plan.Domains.ToDictionary(
target => target.Domain,
StringComparer.Ordinal);

var candidates = observed.ReportControls
.Where(candidate =>
targetsByDomain.TryGetValue(candidate.Domain, out var target) &&
((candidate.Buffered && target.NeedsBuffered) ||
(!candidate.Buffered && target.NeedsUnbuffered)))
.OrderBy(candidate => candidate.Domain, StringComparer.Ordinal)
.ThenByDescending(candidate => candidate.Buffered)
.ThenBy(candidate => candidate.Reference, StringComparer.Ordinal)
.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()
};
}
// Only the selected static coverage determines the candidate scope.
// A large server-wide RCB catalog is not itself a failure. Unknown
// DatSet bindings are inspected in a bounded, resumable window.
var candidatePage = MmsCanonicalStaticRcbCandidatePager.BuildPage(
coverage,
observed.ReportControls,
options.MaxReportControlCandidates,
options.CandidateOffset);
var candidates = candidatePage.Candidates;

var inventory = new MmsReportInventory
{
Expand Down Expand Up @@ -326,11 +309,14 @@ await TryReadReportAttributeSmartAsync(
break;
}

var complete = IsMmsInitiated && chains.All(chain => chain.IsComplete);
var complete = IsMmsInitiated && chains.All(chain => chain.IsComplete) && !candidatePage.HasContinuation;
var warnings = plan.Warnings
.Concat(chains
.Where(chain => !chain.IsComplete)
.Select(chain => $"Target domain '{chain.Domain}' NamedVariable enumeration is partial: {chain.Message}"))
.Concat(candidatePage.HasContinuation
? new[] { $"Deferred {candidatePage.DeferredCandidateCount} target-scoped RCB candidates; resume at offset {candidatePage.NextOffset} with a new bounded read-only request." }
: Array.Empty<string>())
.Concat(candidates
.Where(candidate => string.IsNullOrWhiteSpace(candidate.DataSetReference))
.Select(candidate => $"Live RCB '{candidate.Reference}' did not expose a usable DatSet binding."))
Expand All @@ -347,7 +333,10 @@ await TryReadReportAttributeSmartAsync(
: MmsCanonicalStaticLiveReconciliationStatus.Partial,
Inventory = inventory,
EnumeratedVariableCount = variables.Values.Sum(list => list.Count),
CandidateReportControlCount = candidates.Length,
CandidateReportControlCount = candidatePage.EligibleCandidateCount,
ExaminedCandidateCount = candidatePage.ExaminedCandidateCount,
DeferredCandidateCount = candidatePage.DeferredCandidateCount,
NextCandidateOffset = candidatePage.NextOffset,
DataSetBoundReportControlCount = candidates.Count(candidate =>
!string.IsNullOrWhiteSpace(candidate.DataSetReference)),
Warnings = warnings
Expand Down
127 changes: 127 additions & 0 deletions src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCandidatePager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using AR.Iec61850.Engineering.Canonical;

namespace AR.Iec61850.Mms;

/// <summary>
/// Deterministic read-only candidate window. Known nonmatching DatSet bindings are
/// excluded; unknown bindings remain eligible for a bounded, resumable MMS read.
/// A catalog size is never an operation-size failure by itself.
/// </summary>
public static class MmsCanonicalStaticRcbCandidatePager
{
public static MmsCanonicalStaticRcbCandidatePage BuildPage(
CanonicalStaticReportCoveragePlan coverage,
IEnumerable<MmsReportControlCandidate> liveCandidates,
int maxPerPage,
int offset = 0)
{
ArgumentNullException.ThrowIfNull(coverage);
ArgumentNullException.ThrowIfNull(liveCandidates);

var targets = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
var configuredReferences = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var segment in coverage.Segments)
{
if (segment.SelectedSignalReferences.Length == 0)
continue;
var dataSet = MmsRcbAvailabilityEvaluator.NormalizeReference(segment.DataSetReference);
if (string.IsNullOrWhiteSpace(dataSet))
continue;

foreach (var report in segment.ReportControls)
{
var domain = DomainOf(report.MmsDomain, report.Reference);
if (domain.Length == 0)
continue;
var key = FamilyKey(domain, report.Buffered);
if (!targets.TryGetValue(key, out var bindings))
targets[key] = bindings = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bindings.Add(dataSet);

var configured = MmsRcbAvailabilityEvaluator.NormalizeReference(report.Reference);
if (configured.Length > 0)
configuredReferences.Add(configured);
}
}

var eligible = liveCandidates
.Where(candidate =>
{
if (!HasConsistentReportFamily(candidate))
return false;
var domain = DomainOf(candidate.Domain, candidate.Reference);
if (!targets.TryGetValue(FamilyKey(domain, candidate.Buffered), out var bindings))
return false;
var boundDataSet = MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference);
return boundDataSet.Length == 0 || bindings.Contains(boundDataSet);
})
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Reference))
.DistinctBy(candidate => MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.Reference),
StringComparer.OrdinalIgnoreCase)
.OrderBy(candidate =>
{
var binding = MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference);
if (binding.Length > 0) return 0; // already bound to selected DataSet by live evidence
return configuredReferences.Contains(
MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.Reference)) ? 1 : 2;
})
.ThenBy(candidate => candidate.Domain, StringComparer.OrdinalIgnoreCase)
.ThenBy(candidate => candidate.Reference, StringComparer.OrdinalIgnoreCase)
.ThenBy(candidate => candidate.Reference, StringComparer.Ordinal)
.ToArray();

var start = Math.Clamp(offset, 0, eligible.Length);
var windowSize = Math.Clamp(maxPerPage, 1, 512);
var page = eligible.Skip(start).Take(windowSize).ToArray();
var next = start + page.Length;

return new MmsCanonicalStaticRcbCandidatePage
{
Candidates = page,
EligibleCandidateCount = eligible.Length,
ExaminedCandidateCount = page.Length,
DeferredCandidateCount = eligible.Length - next,
NextOffset = next < eligible.Length ? next : null
};
}

private static bool HasConsistentReportFamily(MmsReportControlCandidate candidate)
{
var fc = candidate.FunctionalConstraint?.Trim() ?? string.Empty;
if (fc.Equals("BR", StringComparison.OrdinalIgnoreCase) && !candidate.Buffered)
return false;
if (fc.Equals("RP", StringComparison.OrdinalIgnoreCase) && candidate.Buffered)
return false;

// The MMS reference's RP/BR segment is stronger than a cached Boolean.
var reference = candidate.Reference.Replace('$', '.');
if (reference.Contains(".BR.", StringComparison.OrdinalIgnoreCase))
return candidate.Buffered;
if (reference.Contains(".RP.", StringComparison.OrdinalIgnoreCase))
return !candidate.Buffered;
return true;
}

private static string DomainOf(string? domain, string? reference)
{
if (!string.IsNullOrWhiteSpace(domain))
return domain.Trim();
var value = reference?.Trim() ?? string.Empty;
var slash = value.IndexOf('/');
return slash > 0 ? value[..slash] : string.Empty;
}

private static string FamilyKey(string domain, bool buffered)
=> domain + (buffered ? "|BR" : "|RP");
}

public sealed class MmsCanonicalStaticRcbCandidatePage
{
public IReadOnlyList<MmsReportControlCandidate> Candidates { get; init; }
= Array.Empty<MmsReportControlCandidate>();
public int EligibleCandidateCount { get; init; }
public int ExaminedCandidateCount { get; init; }
public int DeferredCandidateCount { get; init; }
public int? NextOffset { get; init; }
public bool HasContinuation => NextOffset.HasValue;
}
Loading
Loading