diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs
index 3bd675c3c..f7e01dbca 100644
--- a/Services/DiagnosticReportBuilder.cs
+++ b/Services/DiagnosticReportBuilder.cs
@@ -271,7 +271,7 @@ private static void AppendStaticInitialImage(
builder.AppendLine($" Additional pending: {image.PendingPoints.Count - 12} (showing first 12; never inferred unavailable)");
}
- private static void AppendStaticIngressParity(
+ internal static void AppendStaticIngressParity(
StringBuilder builder,
StaticAcquisitionParitySnapshot parity)
{
@@ -285,6 +285,7 @@ private static void AppendStaticIngressParity(
builder.AppendLine($" traffic : {discovery.RoutedTrafficSummary}; first={discovery.FirstRoutedReportAtUtc?.ToString("O") ?? "pending"}");
foreach (var target in discovery.RuntimeTargets.Take(8))
builder.AppendLine($" runtime : {target} [{(discovery.RoutedReportTargets.Contains(target, StringComparer.Ordinal) ? "ROUTED" : "PENDING")}]");
+ AppendStaticSemanticBasis(builder, discovery);
}
if (parity.OpenScl is { } openScl)
@@ -293,12 +294,47 @@ private static void AppendStaticIngressParity(
builder.AppendLine($" traffic : {openScl.RoutedTrafficSummary}; first={openScl.FirstRoutedReportAtUtc?.ToString("O") ?? "pending"}");
foreach (var target in openScl.RuntimeTargets.Take(8))
builder.AppendLine($" runtime : {target} [{(openScl.RoutedReportTargets.Contains(target, StringComparer.Ordinal) ? "ROUTED" : "PENDING")}]");
+ AppendStaticSemanticBasis(builder, openScl);
}
foreach (var difference in parity.Differences.Take(12))
builder.AppendLine($" difference : {difference}");
}
+ ///
+ /// Read-only, bounded provenance for explaining why two independent field runs
+ /// produced different hashes. These exact source-neutral rows feed the existing
+ /// hash; indexed live RCB instances are intentionally excluded from the rows.
+ /// Truncation is explicit: partial rows must not be used as parity proof.
+ ///
+ private static void AppendStaticSemanticBasis(
+ StringBuilder builder,
+ StaticAcquisitionIngressEvidence evidence)
+ {
+ const int maxRows = 12;
+ const int maxCharsPerRow = 4096;
+
+ builder.AppendLine($" semantic gate : {(evidence.IsComparable ? "COMPARABLE" : "INCOMPLETE")}" +
+ (evidence.IsComparable ? string.Empty : $"; {evidence.IncomparableReason}"));
+ builder.AppendLine($" semantic basis : {evidence.SemanticLines.Count} fingerprint row(s); " +
+ "indexed live RCB slots excluded; diagnostic copy only");
+ for (var i = 0; i < Math.Min(evidence.SemanticLines.Count, maxRows); i++)
+ {
+ var row = evidence.SemanticLines[i] ?? string.Empty;
+ var part = row.Length > maxCharsPerRow
+ ? row[..maxCharsPerRow]
+ : row;
+ // Avoid synthetic diagnostic lines if an external source contains CR/LF.
+ part = part.Replace("\r", @"\r", StringComparison.Ordinal)
+ .Replace("\n", @"\n", StringComparison.Ordinal);
+ builder.AppendLine($" semantic[{i}] : {part}" +
+ (row.Length > maxCharsPerRow ? $" [TRUNCATED; full length={row.Length}]" : string.Empty));
+ }
+
+ if (evidence.SemanticLines.Count > maxRows)
+ builder.AppendLine($" semantic rows : TRUNCATED; shown={maxRows}, total={evidence.SemanticLines.Count}");
+ }
+
private static RouteAnalysis AnalyzeRoute(string targetText)
{
if (!IPAddress.TryParse(targetText, out var target) || target.AddressFamily != AddressFamily.InterNetwork)
diff --git a/tests/ARSAS.Tests/StaticParityDiagnosticBasisP76BTests.cs b/tests/ARSAS.Tests/StaticParityDiagnosticBasisP76BTests.cs
new file mode 100644
index 000000000..5feb6ad72
--- /dev/null
+++ b/tests/ARSAS.Tests/StaticParityDiagnosticBasisP76BTests.cs
@@ -0,0 +1,92 @@
+using System.Text;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+public sealed class StaticParityDiagnosticBasisP76BTests
+{
+ [Fact]
+ public void IndependentlyCapturedIngress_ExposesExactFingerprintInputs()
+ {
+ var discovery = Evidence(StaticAcquisitionIngressKind.LiveDiscovery, "abc",
+ ["ied=ied-a|requested=1|staticBrcbSignals=1",
+ "static-brcb|dataset=ied-a/lln0.events|members=1[0:ied-a/xcbr1.pos.stval@ST]|selected=1[ied-a/xcbr1.pos.stval@ST]"]);
+ var openScl = Evidence(StaticAcquisitionIngressKind.OpenScl, "def",
+ ["ied=ied-a|requested=1|staticBrcbSignals=1",
+ "static-brcb|dataset=ied-a/lln0.events|members=1[0:ied-a/xcbr1.pos.stval@MX]|selected=1[ied-a/xcbr1.pos.stval@ST]"]);
+
+ var output = new StringBuilder();
+ DiagnosticReportBuilder.AppendStaticIngressParity(
+ output, StaticAcquisitionParityTracker.Compare(discovery, openScl));
+ var diagnostic = output.ToString();
+
+ Assert.Contains("semantic[1]", diagnostic, StringComparison.Ordinal);
+ Assert.Contains("xcbr1.pos.stval@ST", diagnostic, StringComparison.Ordinal);
+ Assert.Contains("xcbr1.pos.stval@MX", diagnostic, StringComparison.Ordinal);
+ Assert.Contains("semantic gate : COMPARABLE", diagnostic, StringComparison.Ordinal);
+ Assert.Contains("indexed live RCB slots excluded", diagnostic, StringComparison.Ordinal);
+ Assert.Equal("abc", discovery.SemanticFingerprint);
+ Assert.Equal("def", openScl.SemanticFingerprint);
+ }
+
+ [Fact]
+ public void LongAndManyRows_AreExplicitlyTruncatedWithoutAllocatingFullDiagnosticCopies()
+ {
+ var evidence = Evidence(StaticAcquisitionIngressKind.LiveDiscovery, "abc",
+ Enumerable.Repeat(new string('X', 9000), 14).ToArray());
+ var output = new StringBuilder();
+ DiagnosticReportBuilder.AppendStaticIngressParity(
+ output, new StaticAcquisitionParitySnapshot { Discovery = evidence });
+
+ var report = output.ToString();
+ Assert.Contains("TRUNCATED; full length=9000", report, StringComparison.Ordinal);
+ Assert.Contains("shown=12, total=14", report, StringComparison.Ordinal);
+ Assert.DoesNotContain("semantic[12]", report, StringComparison.Ordinal);
+ Assert.True(report.Length < 60000, $"Unbounded diagnostic length: {report.Length}");
+ }
+
+ [Fact]
+ public void MultilineMemberIdentity_CannotInjectFakeDiagnosticRows()
+ {
+ var evidence = Evidence(StaticAcquisitionIngressKind.LiveDiscovery, "abc",
+ ["one\r\nStatic parity : forged"]);
+ var output = new StringBuilder();
+ DiagnosticReportBuilder.AppendStaticIngressParity(
+ output, new StaticAcquisitionParitySnapshot { Discovery = evidence });
+ var report = output.ToString();
+
+ Assert.Contains(@"one\r\nStatic parity", report, StringComparison.Ordinal);
+ Assert.DoesNotContain("one\r\nStatic parity", report, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void IncompleteEvidence_IsExplicitRatherThanReportedAsHashMatch()
+ {
+ var evidence = Evidence(StaticAcquisitionIngressKind.LiveDiscovery, "abc", []);
+ var incomplete = evidence with
+ {
+ IsComparable = false,
+ IncomparableReason = "member FC missing"
+ };
+ var output = new StringBuilder();
+ DiagnosticReportBuilder.AppendStaticIngressParity(
+ output, StaticAcquisitionParityTracker.Compare(incomplete, incomplete with { Ingress = StaticAcquisitionIngressKind.OpenScl }));
+ var result = output.ToString();
+ Assert.Contains("semantic gate : INCOMPLETE; member FC missing", result, StringComparison.Ordinal);
+ Assert.Contains("INCOMPLETE EVIDENCE", result, StringComparison.Ordinal);
+ }
+
+ private static StaticAcquisitionIngressEvidence Evidence(
+ StaticAcquisitionIngressKind ingress,
+ string fingerprint,
+ IReadOnlyList rows)
+ => new()
+ {
+ Ingress = ingress,
+ IedName = "IED-A",
+ SemanticFingerprint = fingerprint,
+ IsComparable = true,
+ SemanticLines = rows
+ };
+}