using StellaOps.Canonicalization.Json; using System.Text.Json; namespace StellaOps.Canonicalization.Verification; /// /// Verifies that serialization produces identical output across runs. /// public sealed class DeterminismVerifier { public DeterminismResult Verify(T value, int iterations = 10) { var outputs = new HashSet(StringComparer.Ordinal); var digests = new HashSet(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 FindDifferences(string a, string b) { var differences = new List(); 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 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 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 Differences);