From faff7998c3f8ef3cb59c9c2437fb8215f36c2575 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 14:35:48 +0200 Subject: [PATCH] CultMath GLSL fixture: C# evaluated on the committed arguments; phacelle's platform bound scaled; frustum check two-sided GoldenFixtureMatchesCSharp draws arguments only under CULTMATH_WRITE_GLSL=1: the draw calls MathF.Pow, whose bits differ by OS. Checking decodes each committed case's arguments and evaluates C# on them. phacelle is compared across platforms within 2^-20 max(|v|, 1) per component; iv_exp keeps one ulp. The iv_frustum_ball check gains the upper side r + d + 2^-19 (r + |c|_1) that NoiseBoundTests pins. Co-Authored-By: Claude Opus 5.5 --- .../tests/CultMath.Tests/GlslMirrorTests.cs | 154 +++++++++++------- .../CultMath.Tests/fixtures/glsl-parity.json | 4 +- 2 files changed, 101 insertions(+), 57 deletions(-) diff --git a/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs b/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs index 96d74f7c..d920bdda 100644 --- a/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs @@ -111,42 +111,59 @@ public sealed class GlslMirrorTests [Fact] public void GoldenFixtureMatchesCSharp() { - var expected = GoldenFixture(); + // Arguments are drawn only here, when regenerating: Interval draws widths through MathF.Pow, whose + // bits differ by OS. Checking evaluates C# on the committed fixture's own arguments. if (Write) { + var random = new System.Random(Seed); Directory.CreateDirectory(Path.GetDirectoryName(FixturePath)!); - File.WriteAllText(FixturePath, expected); + File.WriteAllText(FixturePath, GoldenFixture((f, _) => Families[f].Arguments(random))); } - // Family by family: every family is compared as text, except those whose tolerance names the - // platform, whose results are compared case by case within one ulp. A family with an enclosure - // check must also pass it on C#'s own results, so the rule the WebGL2 consumer applies holds for - // the reference. var committed = JsonNode.Parse(File.ReadAllText(FixturePath))!.AsObject(); - var current = JsonNode.Parse(expected)!.AsObject(); var committedFamilies = committed["functions"]!.AsArray(); + Assert.Equal(Families.Length, committedFamilies.Count); + for (var f = 0; f < Families.Length; f++) + Assert.True(committedFamilies[f]!["cases"]!.AsArray().Count == Points, $"{Families[f].Glsl}: the fixture does not hold {Points} cases"); + var current = JsonNode.Parse(GoldenFixture((f, p) => + Arguments(Resolve(Families[f].Glsl), ((string)committedFamilies[f]!["cases"]![p]!).Split(" -> ")[0])))!.AsObject(); var currentFamilies = current["functions"]!.AsArray(); - Assert.Equal(currentFamilies.Count, committedFamilies.Count); - for (var f = 0; f < currentFamilies.Count; f++) + + // Family by family: every family is compared as text, except those whose C# bits come from the + // platform's libm, which are compared case by case within their family's platform bound. A family + // with a check must also pass it on C#'s own results, so the rule the WebGL2 consumer applies holds + // for the reference. + for (var f = 0; f < Families.Length; f++) { var want = currentFamilies[f]!.AsObject(); var have = committedFamilies[f]!.AsObject(); var name = (string)want["glsl"]!; Assert.Equal(name, (string?)have["glsl"]); Assert.Equal((string)want["tolerance"]!, (string?)have["tolerance"]); - if (((string)want["tolerance"]!).Contains(PlatformTolerance)) + Assert.Equal((string?)want["check"], (string?)have["check"]); + switch (Families[f].Platform) { - var ulps = MaxUlps(have["cases"]!.AsArray(), want["cases"]!.AsArray(), name); - output.WriteLine($"{name}: within {ulps} ulp of this platform's libm ({RuntimeInformation.OSDescription}); fixture generated on {have["platform"]}"); - Assert.True(ulps <= 1, $"{name}: {ulps} ulp from this platform's result, more than 1"); - } - else - { - Assert.True(JsonNode.DeepEquals(want, have), $"fixtures/glsl-parity.json family {name} is not C#'s current output (regenerate with CULTMATH_WRITE_GLSL=1)"); + case Platform.Ulp: + var ulps = MaxDistance(have["cases"]!.AsArray(), want["cases"]!.AsArray(), (x, y) => Math.Abs((long)Ordered(x) - Ordered(y))); + output.WriteLine($"{name}: within {ulps} ulp of this platform's libm ({RuntimeInformation.OSDescription}); fixture generated on {have["platform"]}"); + Assert.True(ulps <= 1, $"{name}: {ulps} ulp from this platform's result, more than 1"); + break; + case Platform.Scaled: + var scaled = MaxDistance(have["cases"]!.AsArray(), want["cases"]!.AsArray(), (x, y) => + { + double v = BitConverter.Int32BitsToSingle(x), w = BitConverter.Int32BitsToSingle(y); + return x == y ? 0.0 : Math.Abs(v - w) / Math.Max(Math.Abs(v), 1.0); + }); + output.WriteLine($"{name}: |diff| / max(|v|, 1) at most {scaled:R} from this platform's libm ({RuntimeInformation.OSDescription}); fixture generated on {have["platform"]}"); + Assert.True(scaled <= ScaledPlatformBound, $"{name}: {scaled:R} max(|v|, 1) from this platform's result, more than 2^-20"); + break; + default: + Assert.True(JsonNode.DeepEquals(want, have), $"fixtures/glsl-parity.json family {name} is not C#'s current output (regenerate with CULTMATH_WRITE_GLSL=1)"); + break; } if (want["check"] is not null) - AssertFrustumEnclosure(have["cases"]!.AsArray()); + AssertFrustumBall(want["cases"]!.AsArray()); } committed.Remove("functions"); @@ -154,27 +171,24 @@ public sealed class GlslMirrorTests Assert.True(JsonNode.DeepEquals(current, committed), "fixtures/glsl-parity.json header is not the generator's (regenerate with CULTMATH_WRITE_GLSL=1)"); } - // The largest distance in float32 ulps between two families' results; arguments must be identical. - private static long MaxUlps(JsonArray committed, JsonArray current, string name) + // The largest distance, component by component, between two families' results (committed first). + private static double MaxDistance(JsonArray committed, JsonArray current, Func distance) { - Assert.Equal(current.Count, committed.Count); - long max = 0; + var max = 0.0; for (var c = 0; c < current.Count; c++) { - var have = ((string)committed[c]!).Split(" -> "); - var want = ((string)current[c]!).Split(" -> "); - Assert.True(have[0] == want[0], $"{name} case {c}: arguments differ"); - var haveBits = have[1].Split(' ').Select(h => (int)Convert.ToUInt32(h, 16)).ToArray(); - var wantBits = want[1].Split(' ').Select(h => (int)Convert.ToUInt32(h, 16)).ToArray(); + var haveBits = ((string)committed[c]!).Split(" -> ")[1].Split(' ').Select(h => (int)Convert.ToUInt32(h, 16)).ToArray(); + var wantBits = ((string)current[c]!).Split(" -> ")[1].Split(' ').Select(h => (int)Convert.ToUInt32(h, 16)).ToArray(); Assert.Equal(wantBits.Length, haveBits.Length); - max = Math.Max(max, haveBits.Zip(wantBits, (x, y) => Math.Abs((long)Ordered(x) - Ordered(y))).Max()); + max = Math.Max(max, haveBits.Zip(wantBits, distance).Max()); } return max; } - // FrustumEnclosure applied to C#'s own results: every case's ball encloses the exact one. - private static void AssertFrustumEnclosure(JsonArray cases) + // FrustumBall applied to C#'s own results: every case's ball encloses the exact one and is no wider + // than its rounding widening allows. + private static void AssertFrustumBall(JsonArray cases) { foreach (var entry in cases) { @@ -185,11 +199,36 @@ public sealed class GlslMirrorTests var zm = (z0 + z1) / 2.0; var radius = (z1 - z0) / 2.0 * Math.Sqrt(mx * mx + my * my + 1.0) + z1 * footprint + warp; double dx = b[0] - mx * zm, dy = b[1] - my * zm, dz = b[2] - zm; - var gap = b[3] - (radius + Math.Sqrt(dx * dx + dy * dy + dz * dz)); - Assert.True(gap >= 0.0, $"iv_frustum_ball case {entry}: radius short of the exact ball by {-gap}"); + var reach = radius + Math.Sqrt(dx * dx + dy * dy + dz * dz); + var c1 = Math.Abs(mx * zm) + Math.Abs(my * zm) + Math.Abs(zm); + Assert.True(b[3] >= reach, $"iv_frustum_ball case {entry}: radius short of the exact ball by {reach - b[3]}"); + Assert.True(b[3] <= reach + Math.ScaleB(radius + c1, -19), $"iv_frustum_ball case {entry}: radius past the exact ball by {b[3] - reach}, more than 2^-19 (r + |c|_1)"); } } + // A committed case's arguments, decoded into the C# method's parameter types in Hex's field order. + private static object[] Arguments(MethodInfo method, string hex) + { + var scalars = new Queue(hex.Split(' ').Select(h => Convert.ToUInt32(h, 16))); + var arguments = method.GetParameters().Select(parameter => FromScalars(parameter.ParameterType, scalars)).ToArray(); + Assert.Empty(scalars); + return arguments; + } + + private static object FromScalars(Type type, Queue scalars) + { + if (type == typeof(float)) + return BitConverter.Int32BitsToSingle((int)scalars.Dequeue()); + if (type == typeof(int)) + return (int)scalars.Dequeue(); + if (type == typeof(uint)) + return scalars.Dequeue(); + var value = Activator.CreateInstance(type)!; + foreach (var field in type.GetFields().Where(f => !f.IsStatic)) + field.SetValue(value, FromScalars(field.FieldType, scalars)); + return value; + } + private static double[] Floats(string hex) => hex.Split(' ').Select(h => (double)BitConverter.Int32BitsToSingle((int)Convert.ToUInt32(h, 16))).ToArray(); @@ -201,7 +240,16 @@ public sealed class GlslMirrorTests private const int Seed = 20261002; private const int Points = 256; - private sealed record Family(string Glsl, string Tolerance, Func Arguments, string? Check = null); + // How GoldenFixtureMatchesCSharp compares a family whose C# bits come from the platform's libm (exp, + // sin, cos) and so differ by OS; such an entry records the platform that generated it. Every other + // family is compared as text. + private enum Platform { None, Ulp, Scaled } + + // Platform.Scaled: |diff| <= 2^-20 max(|v|, 1) per component. Ulps mean nothing near zero, where + // phacelle's sums of exp, sin and cos cancel; Windows differs from Linux by up to 3.6e-7 of it. + private const double ScaledPlatformBound = 9.5367431640625e-7; + + private sealed record Family(string Glsl, string Tolerance, Func Arguments, string? Check = null, Platform Platform = Platform.None); private static float Uniform(System.Random r, float lo, float hi) => lo + (hi - lo) * r.NextSingle(); private static float3 Point3(System.Random r) => new(Uniform(r, -50.0f, 50.0f), Uniform(r, -50.0f, 50.0f), Uniform(r, -50.0f, 50.0f)); @@ -214,19 +262,15 @@ public sealed class GlslMirrorTests return new float2(centre - width * 0.5f, centre + width * 0.5f); } - // A tolerance containing this names a family whose C# bits come from the platform's libm (exp, sin, - // cos) and so differ by OS: GoldenFixtureMatchesCSharp compares it within one ulp, and its entry - // records the platform. - private const string PlatformTolerance = "platform "; - // iv_frustum_ball's sqrt is not correctly rounded on WebGL2 and its compilers may reassociate, so its - // bits are not portable; what the march needs is enclosure. The consumer checks that in double from - // each case's arguments. It is written into the family's fixture entry, where the consumer reads it. - private const string FrustumEnclosure = - "enclosure: from the arguments (mx, my, z0, z1, fp, warp) compute in double zm = (z0 + z1) / 2, " + - "c = (mx zm, my zm, zm), r = (z1 - z0) / 2 sqrt(mx^2 + my^2 + 1) + z1 fp + warp; every case must have " + - "GPU radius >= r + |GPU centre - c| (Euclidean). Report the largest ulp distance per component as for " + - "any ulp-bounded family; only enclosure fails the check."; + // bits are not portable; what the march needs is enclosure, and what culling needs is a ball no wider + // than its rounding widening. The consumer checks both in double from each case's arguments. It is + // written into the family's fixture entry, where the consumer reads it. + private const string FrustumBall = + "enclosure and tightness: from the arguments (mx, my, z0, z1, fp, warp) compute in double zm = (z0 + z1) / 2, " + + "c = (mx zm, my zm, zm), |c|_1 = |mx zm| + |my zm| + |zm|, r = (z1 - z0) / 2 sqrt(mx^2 + my^2 + 1) + z1 fp + warp " + + "and d = |GPU centre - c| (Euclidean); every case must have r + d <= GPU radius <= r + d + 2^-19 (r + |c|_1). " + + "Report the largest ulp distance per component as for any ulp-bounded family; only the two bounds fail the check."; // A Phacelle stripe wave vector: each component in [-6, 6], about one stripe per cell. private static float3 Side(System.Random r) => new(Uniform(r, -6.0f, 6.0f), Uniform(r, -6.0f, 6.0f), Uniform(r, -6.0f, 6.0f)); @@ -268,15 +312,15 @@ public sealed class GlslMirrorTests var slope = new float2(Uniform(r, -1.0f, 1.0f), Uniform(r, -1.0f, 1.0f)); var z0 = Uniform(r, 0.1f, 100.0f); return new object[] { slope, z0, z0 + Uniform(r, 0.01f, 50.0f), Uniform(r, 0.0001f, 0.05f), Uniform(r, 0.0f, 2.0f) }; - }, FrustumEnclosure), - new("vec2 cultmath_iv_exp(vec2)", "ulp-bounded, " + PlatformTolerance + "exp", r => new object[] { Interval(r) }), - new("CultPhasor cultmath_phacelle(vec3, vec3, float, float)", "ulp-bounded, " + PlatformTolerance + "exp, sin, cos", - r => new object[] { Point3(r), Side(r), Uniform(r, 0.0f, 1.0f), 0.5f }), + }, FrustumBall), + new("vec2 cultmath_iv_exp(vec2)", "ulp-bounded, platform exp", r => new object[] { Interval(r) }, Platform: Platform.Ulp), + new("CultPhasor cultmath_phacelle(vec3, vec3, float, float)", "ulp-bounded, platform exp, sin, cos; across platforms |diff| <= 2^-20 max(|v|, 1)", + r => new object[] { Point3(r), Side(r), Uniform(r, 0.0f, 1.0f), 0.5f }, Platform: Platform.Scaled), }; - private static string GoldenFixture() + // The fixture text, each case's arguments given by family and point index. + private static string GoldenFixture(Func arguments) { - var random = new System.Random(Seed); var json = new StringBuilder(); json.Append("{\n"); json.Append(" \"generator\": \"GlslMirrorTests.GoldenFixtureMatchesCSharp (packages/cultmath/tests/CultMath.Tests); regenerate with CULTMATH_WRITE_GLSL=1\",\n"); @@ -293,14 +337,14 @@ public sealed class GlslMirrorTests json.Append($" \"tolerance\": \"{family.Tolerance}\",\n"); if (family.Check is not null) json.Append($" \"check\": \"{family.Check}\",\n"); - if (family.Tolerance.Contains(PlatformTolerance)) + if (family.Platform != Platform.None) json.Append($" \"platform\": \"{RuntimeInformation.OSDescription}; {RuntimeInformation.FrameworkDescription}\",\n"); json.Append(" \"cases\": [\n"); for (var p = 0; p < Points; p++) { - var arguments = family.Arguments(random); - var result = method.Invoke(null, arguments)!; - json.Append(" \"").Append(Hex(arguments)).Append(" -> ").Append(Hex(new[] { result })).Append('"'); + var values = arguments(f, p); + var result = method.Invoke(null, values)!; + json.Append(" \"").Append(Hex(values)).Append(" -> ").Append(Hex(new[] { result })).Append('"'); json.Append(p + 1 < Points ? ",\n" : "\n"); } diff --git a/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json b/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json index c99a4600..3a945d94 100644 --- a/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json +++ b/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json @@ -6557,7 +6557,7 @@ { "glsl": "vec4 cultmath_iv_frustum_ball(vec2, float, float, float, float)", "tolerance": "ulp-bounded", - "check": "enclosure: from the arguments (mx, my, z0, z1, fp, warp) compute in double zm = (z0 + z1) / 2, c = (mx zm, my zm, zm), r = (z1 - z0) / 2 sqrt(mx^2 + my^2 + 1) + z1 fp + warp; every case must have GPU radius >= r + |GPU centre - c| (Euclidean). Report the largest ulp distance per component as for any ulp-bounded family; only enclosure fails the check.", + "check": "enclosure and tightness: from the arguments (mx, my, z0, z1, fp, warp) compute in double zm = (z0 + z1) / 2, c = (mx zm, my zm, zm), |c|_1 = |mx zm| + |my zm| + |zm|, r = (z1 - z0) / 2 sqrt(mx^2 + my^2 + 1) + z1 fp + warp and d = |GPU centre - c| (Euclidean); every case must have r + d <= GPU radius <= r + d + 2^-19 (r + |c|_1). Report the largest ulp distance per component as for any ulp-bounded family; only the two bounds fail the check.", "cases": [ "3eba7b90 3eb7145c 427992e9 42ba552a 3c779789 3f90a589 -> 41e2a26a 41de7fa1 429b8f4f 419e80d6", "3f73f64c 3f0c1560 4222d6fe 4297c95b 3d2b135d 3f4911fc -> 425e3d89 41ff38c0 426934da 41f0be8c", @@ -7082,7 +7082,7 @@ }, { "glsl": "CultPhasor cultmath_phacelle(vec3, vec3, float, float)", - "tolerance": "ulp-bounded, platform exp, sin, cos", + "tolerance": "ulp-bounded, platform exp, sin, cos; across platforms |diff| <= 2^-20 max(|v|, 1)", "platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", "cases": [ "c01d8630 41b2cd8c 41ea274c bfa19894 40aeb6ce 40918978 3e9be843 3f000000 -> bf74d9fb 3fb71956 bf35fe4e 3f21280a c01277e2 403b5fe4 40464bbd bd5b4455",