From ed2c5843e45b2dab7a2a2d6549631f9f1ed1087a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 06:09:31 +0200 Subject: [PATCH] Tests for CultMath.Expr: evaluator equals direct composition, canonical builds, substitution, validator rules, resolver round trip Co-Authored-By: Claude Opus 5.5 --- .../tests/CultMath.Tests/ExprTests.cs | 480 ++++++++++++++++++ .../CultMathExprSerializationTests.cs | 186 +++++++ 2 files changed, 666 insertions(+) create mode 100644 packages/cultmath/tests/CultMath.Tests/ExprTests.cs create mode 100644 tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs diff --git a/packages/cultmath/tests/CultMath.Tests/ExprTests.cs b/packages/cultmath/tests/CultMath.Tests/ExprTests.cs new file mode 100644 index 00000000..4a97507a --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/ExprTests.cs @@ -0,0 +1,480 @@ +using CultMath.Expr; +using Xunit; +using static CultMath.math; + +namespace CultMath.Tests; + +/// +/// CultMath.Expr: the evaluator equals the direct composition of the same math calls bit for bit (every op and a +/// corpus imitating Aetheria's trophic compositions), the builder is canonical and refuses at the call, substitution +/// builds stencils, constants and param defaults keep their bits, bindings resolve by name, the validator's rules +/// refuse each one by name, and no refusal echoes a value it was given. +/// +/// Hand mutations these must kill: an op's implementation or operand order swapped in the table (the per-op +/// comparison); hash-consing disabled or the dead-node pass removed (BuildIsCanonical); Substitute leaving an input +/// unmapped (SubstituteEqualsOffsetEvaluation); a validator rule deleted (ValidatorRefusesEachRuleByName and the +/// builder refusals); a constant routed through float arithmetic (ConstantsKeepTheirBits). +/// +public sealed class ExprTests +{ + private const int Points = 4096; + private const string Canary = "CANARY"; + + private static readonly float[] Edge = + { + 0f, -0f, 1f, -1f, 0.5f, 3f, 1e-30f, float.Epsilon, -float.Epsilon, 1.17549435e-38f / 2f, + float.NaN, float.PositiveInfinity, float.NegativeInfinity, float.MaxValue, -float.MaxValue, 1e30f, -1e30f, + }; + + private static bool Same(float a, float b) => + (float.IsNaN(a) && float.IsNaN(b)) || BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b); + + private static float[] Column(System.Random rng, int points, float lo, float hi, double edgeShare) + { + var column = new float[points]; + for (var i = 0; i < points; i++) + column[i] = rng.NextDouble() < edgeShare ? Edge[rng.Next(Edge.Length)] : lo + (hi - lo) * rng.NextSingle(); + return column; + } + + private static float[] Pack(params float[][] columns) => columns.SelectMany(column => column).ToArray(); + + private static float[] Run(ExprGraph graph, ExprBindings bindings, params float[][] columns) + { + var points = columns[0].Length; + var outputs = new float[points * graph.OutputNames.Count]; + ExprEvaluator.Evaluate(graph, bindings, Pack(columns), outputs); + return outputs; + } + + private static void AssertSame(float[] expected, float[] actual, int offset, string what) + { + for (var i = 0; i < expected.Length; i++) + Assert.True(Same(expected[i], actual[offset + i]), $"{what} point {i}: expected bits {BitConverter.SingleToInt32Bits(expected[i]):X8}, got {BitConverter.SingleToInt32Bits(actual[offset + i]):X8}"); + } + + // The reference side is written by hand, beside the table and not from it. + private static readonly Dictionary> Reference = new() + { + ["add"] = a => new[] { a[0] + a[1] }, + ["sub"] = a => new[] { a[0] - a[1] }, + ["mul"] = a => new[] { a[0] * a[1] }, + ["div"] = a => new[] { a[0] / a[1] }, + ["neg"] = a => new[] { -a[0] }, + ["min"] = a => new[] { min(a[0], a[1]) }, + ["max"] = a => new[] { max(a[0], a[1]) }, + ["abs"] = a => new[] { abs(a[0]) }, + ["floor"] = a => new[] { floor(a[0]) }, + ["frac"] = a => new[] { frac(a[0]) }, + ["sqrt"] = a => new[] { sqrt(a[0]) }, + ["exp"] = a => new[] { exp(a[0]) }, + ["log"] = a => new[] { log(a[0]) }, + ["pow"] = a => new[] { pow(a[0], a[1]) }, + ["sin"] = a => new[] { sin(a[0]) }, + ["cos"] = a => new[] { cos(a[0]) }, + ["clamp"] = a => new[] { clamp(a[0], a[1], a[2]) }, + ["saturate"] = a => new[] { saturate(a[0]) }, + ["lerp"] = a => new[] { lerp(a[0], a[1], a[2]) }, + ["step"] = a => new[] { step(a[0], a[1]) }, + ["smoothstep"] = a => new[] { smoothstep(a[0], a[1], a[2]) }, + ["select"] = a => new[] { a[0] > a[1] ? a[2] : a[3] }, + ["snoise2"] = a => new[] { snoise(new float2(a[0], a[1])) }, + ["snoise3"] = a => new[] { snoise(new float3(a[0], a[1], a[2])) }, + ["cellular3"] = a => + { + var c = cellular(new float3(a[0], a[1], a[2])); + return new[] { c.nearest.x, c.nearest.y, c.nearest.z, c.nearest.w, c.edge.x, c.edge.y, c.edge.z, c.edge.w, c.id }; + }, + }; + + [Fact] + public void OpTableIsTheStableVocabulary() + { + var expected = new (string Name, int Arity, int Results)[] + { + ("add", 2, 1), ("sub", 2, 1), ("mul", 2, 1), ("div", 2, 1), ("neg", 1, 1), ("min", 2, 1), ("max", 2, 1), + ("abs", 1, 1), ("floor", 1, 1), ("frac", 1, 1), ("sqrt", 1, 1), ("exp", 1, 1), ("log", 1, 1), ("pow", 2, 1), + ("sin", 1, 1), ("cos", 1, 1), ("clamp", 3, 1), ("saturate", 1, 1), ("lerp", 3, 1), ("step", 2, 1), + ("smoothstep", 3, 1), ("select", 4, 1), ("snoise2", 2, 1), ("snoise3", 3, 1), ("cellular3", 3, 9), + }; + Assert.Equal(expected, ExprOps.All.Select(op => (op.Name, op.Arity, op.ResultCount)).ToArray()); + Assert.Equal(ExprOps.All.Count, Reference.Count); + Assert.Equal(expected.Select(op => op.Name).OrderBy(name => name), Reference.Keys.OrderBy(name => name)); + Assert.Null(ExprOps.Find("input")); + Assert.Null(ExprOps.Find("nope")); + Assert.Same(ExprOps.All[3], ExprOps.Find("div")); + } + + [Fact] + public void EvaluatorEqualsDirectComposition() + { + var rng = new System.Random(20261014); + foreach (var op in ExprOps.All) + { + var builder = new ExprBuilder(); + var inputs = Enumerable.Range(0, op.Arity).Select(i => builder.Input("i" + i)).ToArray(); + var call = builder.Call(op.Name, inputs); + if (op.ResultCount == 1) builder.Output("r", call); + else for (var k = 0; k < op.ResultCount; k++) builder.Output("r" + k, builder.Pick(call, k)); + var graph = builder.Build(); + + var columns = Enumerable.Range(0, op.Arity).Select(_ => Column(rng, Points, -8f, 8f, 0.25)).ToArray(); + var outputs = Run(graph, ExprBindings.Bind(graph), columns); + for (var k = 0; k < op.ResultCount; k++) + { + var expected = new float[Points]; + for (var p = 0; p < Points; p++) + expected[p] = Reference[op.Name](columns.Select(column => column[p]).ToArray())[k]; + AssertSame(expected, outputs, k * Points, op.Name + " result " + k); + } + } + } + + [Fact] + public void EvaluatorEqualsDirectCompositionOnTheCorpus() + { + var rng = new System.Random(77); + var x = Column(rng, Points, -2f, 3f, 0.2); + var z = Column(rng, Points, -500f, 500f, 0.1); + + // smooth: smoothstep(0.2, 0.8, v) + var b = new ExprBuilder(); + b.Output("o", b.Call("smoothstep", b.Const(0.2f), b.Const(0.8f), b.Input("v"))); + var smooth = b.Build(); + AssertSame(x.Select(v => smoothstep(0.2f, 0.8f, v)).ToArray(), Run(smooth, ExprBindings.Bind(smooth), x), 0, "smooth"); + + // holling: p * (1 + k) / (p + k), k a param + b = new ExprBuilder(); + var p = b.Input("p"); + var k = b.Param("k", 0.25f); + b.Output("o", b.Call("div", b.Call("mul", p, b.Call("add", b.Const(1f), k)), b.Call("add", p, k))); + var holling = b.Build(); + AssertSame(x.Select(v => v * (1f + 0.25f) / (v + 0.25f)).ToArray(), Run(holling, ExprBindings.Bind(holling), x), 0, "holling"); + var overrides = new Dictionary { ["k"] = 0.4f }; + AssertSame(x.Select(v => v * (1f + 0.4f) / (v + 0.4f)).ToArray(), Run(holling, ExprBindings.Bind(holling, overrides), x), 0, "holling k=0.4"); + + // envelope: b^0.75 + b = new ExprBuilder(); + b.Output("o", b.Call("pow", b.Input("b"), b.Const(0.75f))); + var envelope = b.Build(); + AssertSame(x.Select(v => pow(v, 0.75f)).ToArray(), Run(envelope, ExprBindings.Bind(envelope), x), 0, "envelope"); + + // an 8-tap ring stencil of snoise2, with the centre, summed in order + var (ring, offsets) = RingStencil(48f); + var expected = new float[Points]; + for (var i = 0; i < Points; i++) + { + var sum = snoise(new float2(x[i] * 0.013f, z[i] * 0.017f)); + for (var t = 0; t < 8; t++) sum += snoise(new float2((x[i] + offsets[t].x) * 0.013f, (z[i] + offsets[t].y) * 0.017f)); + expected[i] = sum / 9f; + } + + AssertSame(expected, Run(ring, ExprBindings.Bind(ring), x, z), 0, "ring stencil"); + + // a cellular3 Pick chain: (edge.w - nearest.w) * id + b = new ExprBuilder(); + var cell = b.Call("cellular3", b.Input("x"), b.Input("y"), b.Input("z")); + b.Output("o", b.Call("mul", b.Call("sub", b.Pick(cell, 7), b.Pick(cell, 3)), b.Pick(cell, 8))); + var chain = b.Build(); + var y = Column(rng, Points, -6f, 6f, 0.05); + var cx = Column(rng, Points, -6f, 6f, 0.05); + var cz = Column(rng, Points, -6f, 6f, 0.05); + AssertSame( + Enumerable.Range(0, Points).Select(i => + { + var c = cellular(new float3(cx[i], y[i], cz[i])); + return (c.edge.w - c.nearest.w) * c.id; + }).ToArray(), + Run(chain, ExprBindings.Bind(chain), cx, y, cz), 0, "cellular chain"); + } + + private static ExprGraph Field() + { + var b = new ExprBuilder(); + b.Output("f", b.Call("snoise2", b.Call("mul", b.Input("x"), b.Const(0.013f)), b.Call("mul", b.Input("z"), b.Const(0.017f)))); + return b.Build(); + } + + private static (ExprGraph Graph, (float x, float y)[] Offsets) RingStencil(float radius) + { + var field = Field(); + var offsets = Enumerable.Range(0, 8) + .Select(t => (MathF.Cos(2f * MathF.PI * t / 8f) * radius, MathF.Sin(2f * MathF.PI * t / 8f) * radius)) + .ToArray(); + var b = new ExprBuilder(); + var x = b.Input("x"); + var z = b.Input("z"); + var sum = b.Substitute(field, new Dictionary { ["x"] = x, ["z"] = z })["f"]; + foreach (var (ox, oz) in offsets) + { + var tap = b.Substitute(field, new Dictionary + { + ["x"] = b.Call("add", x, b.Const(ox)), + ["z"] = b.Call("add", z, b.Const(oz)), + })["f"]; + sum = b.Call("add", sum, tap); + } + + b.Output("o", b.Call("div", sum, b.Const(9f))); + return (b.Build(), offsets); + } + + [Fact] + public void SubstituteEqualsOffsetEvaluation() + { + var rng = new System.Random(5); + var x = Column(rng, Points, -500f, 500f, 0.05); + var z = Column(rng, Points, -500f, 500f, 0.05); + var field = Field(); + var (stencil, offsets) = RingStencil(48f); + + var centre = Run(field, ExprBindings.Bind(field), x, z); + var sum = (float[])centre.Clone(); + for (var t = 0; t < 8; t++) + { + var tap = Run(field, ExprBindings.Bind(field), x.Select(v => v + offsets[t].x).ToArray(), z.Select(v => v + offsets[t].y).ToArray()); + for (var i = 0; i < Points; i++) sum[i] += tap[i]; + } + + var expected = sum.Select(s => s / 9f).ToArray(); + AssertSame(expected, Run(stencil, ExprBindings.Bind(stencil), x, z), 0, "substituted stencil"); + + // The wrong input substituted (z only) is a different graph and differs. + var b = new ExprBuilder(); + b.Input("x"); + var wz = b.Input("z"); + var wrong = b.Substitute(field, new Dictionary { ["z"] = b.Call("add", wz, b.Const(offsets[1].y)) })["f"]; + b.Output("o", wrong); + var wrongGraph = b.Build(); + var wrongOut = Run(wrongGraph, ExprBindings.Bind(wrongGraph), x, z); + var offsetOut = Run(field, ExprBindings.Bind(field), x, z.Select(v => v + offsets[1].y).ToArray()); + AssertSame(offsetOut, wrongOut, 0, "z-only substitution equals z-only offset"); + var xOffset = Run(field, ExprBindings.Bind(field), x.Select(v => v + offsets[1].x).ToArray(), z.Select(v => v + offsets[1].y).ToArray()); + Assert.Contains(Enumerable.Range(0, Points), i => !Same(xOffset[i], wrongOut[i])); + + // Substitute declares the graph's ports it does not replace, and refuses a name the graph lacks. + Assert.Equal(new[] { "x", "z" }, wrongGraph.InputNames); + var half = new ExprBuilder(); + half.Output("f", half.Substitute(field, new Dictionary { ["z"] = half.Const(1f) })["f"]); + Assert.Equal(new[] { "x" }, half.Build().InputNames); + var c = new ExprBuilder(); + var ex = Assert.Throws(() => c.Substitute(field, new Dictionary { [Canary] = c.Const(1f) })); + Assert.Equal("unknown-input", ex.Rule); + Assert.DoesNotContain(Canary, ex.Message); + var d = new ExprBuilder(); + var foreign = Assert.Throws(() => d.Substitute(field, new Dictionary { ["x"] = c.Const(1f) })); + Assert.Equal("foreign-operand", foreign.Rule); + var cell = d.Call("cellular3", d.Const(0f), d.Const(0f), d.Const(0f)); + Assert.Equal("bare-multi-result", Assert.Throws(() => d.Substitute(field, new Dictionary { ["x"] = cell })).Rule); + } + + private static string Signature(ExprGraph g) => + string.Join(";", g.InputNames) + "|" + string.Join(";", g.ParamNames.Zip(g.ParamDefaultBits, (n, v) => n + "=" + v)) + "|" + + string.Join(";", g.OutputNames.Zip(g.OutputNodes, (n, v) => n + "=" + v)) + "|" + + string.Join(";", g.Nodes.Select(n => n.WireName + "(" + string.Join(",", n.Operands) + ")" + n.Payload)); + + private static ExprGraph Sample(bool withDeadNode) + { + var b = new ExprBuilder(); + var x = b.Input("x"); + var y = b.Input("y"); + b.Param("unused", 2f); + var once = b.Call("mul", x, y); + var twice = b.Call("mul", x, y); + if (withDeadNode) b.Call("sin", b.Call("neg", y)); + b.Output("o", b.Call("add", once, twice)); + return b.Build(); + } + + [Fact] + public void BuildIsCanonical() + { + var graph = Sample(false); + Assert.Equal(Signature(graph), Signature(Sample(false))); + + Assert.Single(graph.Nodes, n => n.WireName == "mul"); + Assert.Equal(new[] { "input", "input", "mul", "add" }, graph.Nodes.Select(n => n.WireName)); + Assert.Equal(new[] { 2, 2 }, graph.Nodes[3].Operands); + + var withDead = Sample(true); + Assert.Equal(Signature(graph), Signature(withDead)); + Assert.DoesNotContain(withDead.Nodes, n => n.WireName == "sin" || n.WireName == "neg"); + + // The ports are the graph's contract: an unused param stays, and constants are told apart by bits. + Assert.Equal(new[] { "unused" }, graph.ParamNames); + var b = new ExprBuilder(); + var sameZero = b.Call("add", b.Const(0f), b.Const(0f)); + var minusZero = b.Const(-0f); + b.Output("a", sameZero); + b.Output("b", minusZero); + var zeros = b.Build(); + Assert.Equal(new[] { 0, int.MinValue }, zeros.Nodes.Where(n => n.Kind == ExprNodeKind.Const).Select(n => n.Payload)); + Assert.Equal(3, zeros.Nodes.Count); + } + + [Fact] + public void ConstantsKeepTheirBits() + { + var nanBits = 0x7FC12345; + var constants = new[] { -0f, BitConverter.Int32BitsToSingle(nanBits), BitConverter.Int32BitsToSingle(1) }; + var b = new ExprBuilder(); + for (var i = 0; i < constants.Length; i++) b.Output("c" + i, b.Const(constants[i])); + b.Output("p", b.Param("p", BitConverter.Int32BitsToSingle(nanBits))); + var graph = b.Build(); + + Assert.Equal(new[] { unchecked((int)0x80000000), nanBits, 1 }, graph.Nodes.Where(n => n.Kind == ExprNodeKind.Const).Select(n => n.Payload)); + Assert.Equal(new[] { nanBits }, graph.ParamDefaultBits); + var outputs = new float[graph.OutputNames.Count]; + ExprEvaluator.Evaluate(graph, ExprBindings.Bind(graph), ReadOnlySpan.Empty, outputs); + Assert.Equal(new[] { unchecked((int)0x80000000), nanBits, 1, nanBits }, outputs.Select(BitConverter.SingleToInt32Bits)); + } + + [Fact] + public void BindingsResolveByName() + { + var b = new ExprBuilder(); + b.Output("o", b.Call("sub", b.Param("a", 1.5f), b.Param("b", 4f))); + var graph = b.Build(); + float Eval(ExprBindings bindings) + { + var o = new float[1]; + ExprEvaluator.Evaluate(graph, bindings, ReadOnlySpan.Empty, o); + return o[0]; + } + + Assert.Equal(1.5f - 4f, Eval(ExprBindings.Bind(graph))); + Assert.Equal(1.5f - 4f, Eval(ExprBindings.Bind(graph, new Dictionary()))); + Assert.Equal(10f - 4f, Eval(ExprBindings.Bind(graph, new Dictionary { ["a"] = 10f }))); + Assert.Equal(1.5f - 7f, Eval(ExprBindings.Bind(graph, new Dictionary { ["b"] = 7f }))); + + var ex = Assert.Throws(() => ExprBindings.Bind(graph, new Dictionary { [Canary] = 1f })); + Assert.Equal("unknown-param", ex.Rule); + Assert.DoesNotContain(Canary, ex.Message); + + var other = new ExprBuilder(); + other.Output("o", other.Const(1f)); + var foreign = other.Build(); + Assert.Equal("foreign-bindings", Assert.Throws(() => + ExprEvaluator.Evaluate(graph, ExprBindings.Bind(foreign), ReadOnlySpan.Empty, new float[1])).Rule); + } + + [Fact] + public void EvaluateRefusesWrongColumnShapes() + { + var b = new ExprBuilder(); + b.Output("o", b.Input("x")); + b.Output("p", b.Call("neg", b.Input("x"))); + var graph = b.Build(); + var bindings = ExprBindings.Bind(graph); + Assert.Equal("column-shape", Assert.Throws(() => ExprEvaluator.Evaluate(graph, bindings, new float[4], new float[5])).Rule); + Assert.Equal("column-shape", Assert.Throws(() => ExprEvaluator.Evaluate(graph, bindings, new float[3], new float[4])).Rule); + var outputs = new float[6]; + ExprEvaluator.Evaluate(graph, bindings, new[] { 1f, 2f, 3f }, outputs); + Assert.Equal(new[] { 1f, 2f, 3f, -1f, -2f, -3f }, outputs); + } + + private static ExprGraphException Refusal(Action action) => Assert.Throws(action); + + [Fact] + public void TheBuilderRefusesAtTheCall() + { + var b = new ExprBuilder(); + var x = b.Input("x"); + var cell = b.Call("cellular3", x, x, x); + + var unknown = Refusal(() => b.Call(Canary + "op", x)); + Assert.Equal("unknown-op", unknown.Rule); + Assert.Equal(2, unknown.NodeIndex); + Assert.DoesNotContain(Canary, unknown.Message); + Assert.Equal("arity", Refusal(() => b.Call("add", x)).Rule); + Assert.Equal("arity", Refusal(() => b.Call("neg", x, x)).Rule); + Assert.Equal("pick-range", Refusal(() => b.Pick(x, 0)).Rule); + Assert.Equal("pick-range", Refusal(() => b.Pick(cell, 9)).Rule); + Assert.Equal("pick-range", Refusal(() => b.Pick(cell, -1)).Rule); + Assert.Equal("bare-multi-result", Refusal(() => b.Call("neg", cell)).Rule); + Assert.Equal("bare-multi-result", Refusal(() => b.Output("o", cell)).Rule); + Assert.Equal("foreign-operand", Refusal(() => b.Call("neg", new ExprBuilder().Const(1f))).Rule); + Assert.Equal("port-name", Refusal(() => b.Input(Canary + "-bad")).Rule); + Assert.Equal("port-name", Refusal(() => b.Input("1x")).Rule); + Assert.Equal("port-name", Refusal(() => b.Input("")).Rule); + Assert.Equal("port-name", Refusal(() => b.Param("a b", 1f)).Rule); + Assert.Equal("port-name", Refusal(() => b.Output("a.b", x)).Rule); + Assert.Equal(b.Pick(cell, 8).ResultCount, 1); + Assert.Equal(9, cell.ResultCount); + + b.Output("o", x); + Assert.Equal("duplicate-port-name", Refusal(() => b.Output("o", x)).Rule); + Assert.Same(x, b.Input("x")); + b.Param("p", 1f); + Assert.Equal("port-conflict", Refusal(() => b.Param("p", 2f)).Rule); + Assert.Same(b.Param("p", 1f), b.Param("p", 1f)); + Assert.NotNull(b.Input("p")); + Assert.Equal(new[] { "x", "p" }, b.Build().InputNames); + } + + private static ExprNode[] N(params ExprNode[] nodes) => nodes; + + private static ExprGraph Make( + ExprNode[] nodes, + int version = 1, + string[]? inputs = null, + string[]? paramNames = null, + int[]? paramBits = null, + string[]? outputNames = null, + int[]? outputNodes = null) => + ExprGraph.Create( + version, + inputs ?? new[] { "x" }, + paramNames ?? Array.Empty(), + paramBits ?? Array.Empty(), + outputNames ?? new[] { "o" }, + outputNodes ?? new[] { 0 }, + nodes); + + [Fact] + public void ValidatorRefusesEachRuleByName() + { + void Rule(string rule, Action act, int node = -1) + { + var ex = Assert.Throws(act); + Assert.Equal(rule, ex.Rule); + Assert.Equal(node, ex.NodeIndex); + } + + var x = ExprNode.Input(0); + Assert.Equal(1, Make(nodes: N(x)).Nodes.Count); + Rule("version", () => Make(version: 2, nodes: N(x))); + Rule("version", () => Make(version: 0, nodes: N(x))); + Rule("port-count", () => Make(paramNames: new[] { "a" }, nodes: N(x))); + Rule("port-count", () => Make(outputNames: new[] { "a", "b" }, nodes: N(x))); + Rule("port-name", () => Make(inputs: new[] { "9" }, nodes: N(x))); + Rule("duplicate-port-name", () => Make(inputs: new[] { "x", "x" }, nodes: N(x))); + Rule("duplicate-port-name", () => Make(paramNames: new[] { "a", "a" }, paramBits: new[] { 0, 0 }, nodes: N(x))); + Rule("duplicate-port-name", () => Make(outputNames: new[] { "a", "a" }, outputNodes: new[] { 0, 0 }, nodes: N(x))); + Assert.Equal(1, Make(inputs: new[] { "x" }, paramNames: new[] { "x" }, paramBits: new[] { 0 }, nodes: N(x)).Nodes.Count); + Rule("port-index", () => Make(nodes: N(ExprNode.Input(1))), 0); + Rule("port-index", () => Make(nodes: N(ExprNode.Input(-1))), 0); + Rule("port-index", () => Make(nodes: N(x, ExprNode.Param(0))), 1); + Rule("arity", () => Make(nodes: N(ExprNode.FromWire("input", new[] { 0 }, 0))), 0); + Rule("arity", () => Make(nodes: N(ExprNode.FromWire("const", new[] { 0 }, 0))), 0); + Rule("arity", () => Make(nodes: N(x, ExprNode.FromWire("pick", new int[0], 0))), 1); + Rule("payload", () => Make(nodes: N(x, ExprNode.FromWire("neg", new[] { 0 }, 7))), 1); + Rule("output-node", () => Make(outputNodes: new[] { 1 }, nodes: N(x))); + Rule("output-node", () => Make(outputNodes: new[] { -1 }, nodes: N(x))); + Rule("bare-multi-result", () => Make(outputNodes: new[] { 1 }, nodes: N(x, ExprNode.Call("cellular3", new[] { 0, 0, 0 }))), 1); + Rule("pick-range", () => Make(outputNodes: new[] { 1 }, nodes: N(x, ExprNode.Pick(0, 0))), 1); + Rule("operand-order", () => Make(nodes: N(x, ExprNode.Pick(5, 0))), 1); + Rule("operand-order", () => Make(nodes: N(x, ExprNode.Call("neg", new[] { -1 }))), 1); + } + + [Fact] + public void FromWireNamesTheFourLeafKindsAndCalls() + { + foreach (var kind in new[] { "input", "param", "const", "pick" }) + Assert.Equal(kind, ExprNode.FromWire(kind, Array.Empty(), 0).WireName); + Assert.Equal(ExprNodeKind.Call, ExprNode.FromWire("add", new[] { 0, 0 }, 0).Kind); + Assert.Equal(ExprNodeKind.Call, ExprNode.FromWire("nope", Array.Empty(), 0).Kind); + Assert.Equal(new[] { ExprNodeKind.Input, ExprNodeKind.Param, ExprNodeKind.Const, ExprNodeKind.Pick }, + new[] { "input", "param", "const", "pick" }.Select(name => ExprNode.FromWire(name, Array.Empty(), 0).Kind)); + } +} diff --git a/tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs b/tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs new file mode 100644 index 00000000..1ffc5abf --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath.Expr; +using FluentAssertions; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + /// + /// The MessagePack shape of CultMath.Expr.ExprGraph (CultMathExprResolver, in the default chain): a + /// consumer record with a graph field needs no registration, a graph encodes to canonical bytes and decodes to a + /// graph that evaluates bit for bit the same, and decode runs the one validator so a broken encoding is refused + /// whole, naming its rule and never echoing what it was given. + /// + public sealed class CultMathExprSerializationTests + { + private static readonly MessagePackSerializerOptions Options = CultDocumentMessagePackSerialization.Options; + + private const string Canary = "CANARY_NAME"; + + [CultDocument("tests.expr_holder", "tests.expr_holder.v1")] + [MessagePackObject(AllowPrivate = true)] + [CultGlobal] + internal sealed class ExprHolder + { + [Key(0)] public ExprGraph Graph = default!; + [Key(1)] public float Scale; + } + + private static ExprGraph Sample() + { + var b = new ExprBuilder(); + var x = b.Input("x"); + var z = b.Input("z"); + var k = b.Param("k", 1.5f); + var cell = b.Call("cellular3", x, z, k); + var field = b.Call("snoise2", b.Call("mul", x, k), z); + b.Output("o", b.Call("add", field, b.Call("sub", b.Pick(cell, 7), b.Const(-0f)))); + return b.Build(); + } + + private static byte[] Encode(ExprGraph graph) => MessagePackSerializer.Serialize(graph, Options); + + private static ExprGraph Decode(byte[] bytes) => MessagePackSerializer.Deserialize(bytes, Options); + + private static float[] Evaluate(ExprGraph graph, float[] x, float[] z) + { + var outputs = new float[x.Length * graph.OutputNames.Count]; + ExprEvaluator.Evaluate(graph, ExprBindings.Bind(graph), x.Concat(z).ToArray(), outputs); + return outputs; + } + + [Test] + public void ConsumerRecordRoundTrips() + { + var graph = Sample(); + var record = new ExprHolder { Graph = graph, Scale = 2f }; + + var bytes = MessagePackSerializer.Serialize(record, Options); + var decoded = MessagePackSerializer.Deserialize(bytes, Options); + + decoded.Scale.Should().Be(2f); + Encode(decoded.Graph).Should().Equal(Encode(graph)); + var rng = new Random(11); + var x = Enumerable.Range(0, 256).Select(_ => (float)(rng.NextDouble() * 20 - 10)).ToArray(); + var z = Enumerable.Range(0, 256).Select(_ => (float)(rng.NextDouble() * 20 - 10)).ToArray(); + var expected = Evaluate(graph, x, z); + var actual = Evaluate(decoded.Graph, x, z); + actual.Select(BitConverter.SingleToInt32Bits).Should().Equal(expected.Select(BitConverter.SingleToInt32Bits)); + } + + [Test] + public void BuildIsCanonical_TheSameCallsEncodeToTheSameBytes() + { + Encode(Sample()).Should().Equal(Encode(Sample())); + + var b = new ExprBuilder(); + var x = b.Input("x"); + var k = b.Param("k", 1.5f); + b.Output("o", b.Call("add", x, k)); + MessagePackSerializer.ConvertToJson(Encode(b.Build()), Options).Should().Be( + "[1,[\"x\"],[[\"k\",1069547520]],[[\"o\",2]],[[\"input\",[],0],[\"param\",[],0],[\"add\",[0,1],0]]]"); + + var dead = new ExprBuilder(); + var dx = dead.Input("x"); + var dk = dead.Param("k", 1.5f); + dead.Call("sin", dx); + dead.Output("o", dead.Call("add", dx, dk)); + Encode(dead.Build()).Should().Equal(Encode(b.Build())); + } + + [Test] + public void ConstantsKeepTheirBits() + { + const int nanBits = 0x7FC12345; + var b = new ExprBuilder(); + b.Output("a", b.Const(-0f)); + b.Output("b", b.Const(BitConverter.Int32BitsToSingle(nanBits))); + b.Output("c", b.Const(BitConverter.Int32BitsToSingle(1))); + b.Output("d", b.Param("p", BitConverter.Int32BitsToSingle(nanBits))); + var graph = b.Build(); + + var decoded = Decode(Encode(graph)); + + Encode(decoded).Should().Equal(Encode(graph)); + decoded.Nodes.Where(n => n.Kind == ExprNodeKind.Const).Select(n => n.Payload) + .Should().Equal(int.MinValue, nanBits, 1); + decoded.ParamDefaultBits.Should().Equal(nanBits); + var outputs = new float[4]; + ExprEvaluator.Evaluate(decoded, ExprBindings.Bind(decoded), ReadOnlySpan.Empty, outputs); + outputs.Select(BitConverter.SingleToInt32Bits).Should().Equal(int.MinValue, nanBits, 1, nanBits); + } + + private static object[] Input() => new object[] { "input", new object[0], 0 }; + + private static void Put(ref MessagePackWriter writer, object? value) + { + switch (value) + { + case null: writer.WriteNil(); break; + case int i: writer.Write(i); break; + case string s: writer.Write(s); break; + case System.Collections.IEnumerable items: + var all = items.Cast().ToArray(); + writer.WriteArrayHeader(all.Length); + foreach (var item in all) Put(ref writer, item); + break; + } + } + + private static byte[] Bytes(object? value) + { + var buffer = new System.Buffers.ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + Put(ref writer, value); + writer.Flush(); + return buffer.WrittenMemory.ToArray(); + } + + private static byte[] Wire(object version, object[] inputs, object[] nodes, int outputNode) => + Bytes(new object[] { version, inputs, new object[0], new object[] { new object[] { "o", outputNode } }, nodes }); + + private static void Refused(byte[] bytes, string rule, int node = -1) + { + var act = () => Decode(bytes); + var thrown = act.Should().Throw().Which; + var inner = thrown.InnerException.Should().BeOfType().Which; + inner.Rule.Should().Be(rule); + inner.NodeIndex.Should().Be(node); + thrown.ToString().Should().NotContain(Canary); + } + + [Test] + public void DecodeRefusesInvalidGraphs() + { + var x = new object[] { "x" }; + var cell = new object[] { "cellular3", new object[] { 0, 0, 0 }, 0 }; + + Decode(Wire(1, x, new[] { Input() }, 0)).Nodes.Should().HaveCount(1); + + Refused(Wire(1, x, new object[] { Input(), new object[] { Canary + "_op", new object[] { 0 }, 0 } }, 1), "unknown-op", 1); + Refused(Wire(1, x, new object[] { Input(), new object[] { "add", new object[] { 0 }, 0 } }, 1), "arity", 1); + Refused(Wire(1, x, new object[] { Input(), new object[] { "neg", new object[] { 2 }, 0 } }, 1), "operand-order", 1); + Refused(Wire(1, x, new object[] { Input(), new object[] { "neg", new object[] { 1 }, 0 } }, 1), "operand-order", 1); + Refused(Wire(1, x, new object[] { Input(), new object[] { "pick", new object[] { 0 }, 0 } }, 1), "pick-range", 1); + Refused(Wire(1, x, new object[] { Input(), cell, new object[] { "pick", new object[] { 1 }, 9 } }, 2), "pick-range", 2); + Refused(Wire(1, x, new object[] { Input(), cell, new object[] { "neg", new object[] { 1 }, 0 } }, 2), "bare-multi-result", 2); + Refused(Wire(1, new object[] { "x", "x" }, new[] { Input() }, 0), "duplicate-port-name"); + Refused(Wire(1, new object[] { Canary + "-bad" }, new[] { Input() }, 0), "port-name"); + Refused(Wire(2, x, new[] { Input() }, 0), "version"); + Refused(Wire(1, x, new[] { Input() }, 3), "output-node"); + } + + [Test] + public void DecodeRefusesNilAndWrongShapes() + { + Refused(Bytes(null), "shape"); + Refused(Bytes(new object[] { 1, new object[0] }), "shape"); + var act = () => MessagePackSerializer.Serialize(null!, Options); + act.Should().Throw().Which.InnerException.Should().BeOfType(); + } + } +}