128 lines
4.3 KiB
C#
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);
|