Files
git.stella-ops.org/src/__Libraries/StellaOps.Canonicalization/Verification/DeterminismVerifier.cs
2026-02-01 21:37:40 +02:00

128 lines
4.3 KiB
C#

using StellaOps.Canonicalization.Json;
using System.Text.Json;
namespace StellaOps.Canonicalization.Verification;
/// <summary>
/// Verifies that serialization produces identical output across runs.
/// </summary>
public sealed class DeterminismVerifier
{
public DeterminismResult Verify<T>(T value, int iterations = 10)
{
var outputs = new HashSet<string>(StringComparer.Ordinal);
var digests = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < iterations; i++)
{
var (json, digest) = CanonicalJsonSerializer.SerializeWithDigest(value);
outputs.Add(json);
digests.Add(digest);
}
return new DeterminismResult(
IsDeterministic: outputs.Count == 1 && digests.Count == 1,
UniqueOutputs: outputs.Count,
UniqueDigests: digests.Count,
SampleOutput: outputs.FirstOrDefault() ?? string.Empty,
SampleDigest: digests.FirstOrDefault() ?? string.Empty);
}
public ComparisonResult Compare(string jsonA, string jsonB)
{
if (string.Equals(jsonA, jsonB, StringComparison.Ordinal))
{
return new ComparisonResult(true, []);
}
var differences = FindDifferences(jsonA, jsonB);
return new ComparisonResult(false, differences);
}
private static IReadOnlyList<string> FindDifferences(string a, string b)
{
var differences = new List<string>();
var parsedA = TryParseJson(a, "inputA", differences, out var docA);
var parsedB = TryParseJson(b, "inputB", differences, out var docB);
if (!parsedA || !parsedB)
{
return differences;
}
using (docA)
using (docB)
{
CompareElements(docA.RootElement, docB.RootElement, "$", differences);
}
return differences;
}
private static bool TryParseJson(
string json,
string label,
List<string> differences,
out JsonDocument doc)
{
try
{
doc = JsonDocument.Parse(json);
return true;
}
catch (JsonException ex)
{
differences.Add($"{label}: invalid JSON ({ex.Message})");
doc = null!;
return false;
}
}
private static void CompareElements(JsonElement a, JsonElement b, string path, List<string> differences)
{
if (a.ValueKind != b.ValueKind)
{
differences.Add($"{path}: type mismatch ({a.ValueKind} vs {b.ValueKind})");
return;
}
switch (a.ValueKind)
{
case JsonValueKind.Object:
var propsA = a.EnumerateObject().ToDictionary(p => p.Name, StringComparer.Ordinal);
var propsB = b.EnumerateObject().ToDictionary(p => p.Name, StringComparer.Ordinal);
foreach (var key in propsA.Keys.Union(propsB.Keys).OrderBy(k => k, StringComparer.Ordinal))
{
var hasA = propsA.TryGetValue(key, out var propA);
var hasB = propsB.TryGetValue(key, out var propB);
if (!hasA) differences.Add($"{path}.{key}: missing in first");
else if (!hasB) differences.Add($"{path}.{key}: missing in second");
else CompareElements(propA.Value, propB.Value, $"{path}.{key}", differences);
}
break;
case JsonValueKind.Array:
var arrA = a.EnumerateArray().ToList();
var arrB = b.EnumerateArray().ToList();
if (arrA.Count != arrB.Count)
differences.Add($"{path}: array length mismatch ({arrA.Count} vs {arrB.Count})");
for (var i = 0; i < Math.Min(arrA.Count, arrB.Count); i++)
CompareElements(arrA[i], arrB[i], $"{path}[{i}]", differences);
break;
default:
if (a.GetRawText() != b.GetRawText())
differences.Add($"{path}: value mismatch");
break;
}
}
}
public sealed record DeterminismResult(
bool IsDeterministic,
int UniqueOutputs,
int UniqueDigests,
string SampleOutput,
string SampleDigest);
public sealed record ComparisonResult(
bool IsIdentical,
IReadOnlyList<string> Differences);