From dc302a96a7d0a61ab6289e37f8be1b8b6ee00dfc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 06:05:18 +0200 Subject: [PATCH 1/7] CultMath.Expr: scalar SSA expression graph, builder with substitution, one validator, evaluator, MessagePack shape ExprGraph (immutable class, one validator in Create), ExprBuilder (hash-consing, dead-node removal, Substitute), ExprOps (the one op table), ExprEvaluator, ExprBindings, ExprGraphException; CultMathExprResolver appended to the default chain after CultMathResolver. Co-Authored-By: Claude Opus 5.5 --- packages/cultmath/src/CultMath/Expr.meta | 8 + .../src/CultMath/Expr/ExprBindings.cs | 39 ++++ .../src/CultMath/Expr/ExprBindings.cs.meta | 2 + .../cultmath/src/CultMath/Expr/ExprBuilder.cs | 184 ++++++++++++++++++ .../src/CultMath/Expr/ExprBuilder.cs.meta | 2 + .../src/CultMath/Expr/ExprEvaluator.cs | 56 ++++++ .../src/CultMath/Expr/ExprEvaluator.cs.meta | 2 + .../cultmath/src/CultMath/Expr/ExprGraph.cs | 168 ++++++++++++++++ .../src/CultMath/Expr/ExprGraph.cs.meta | 2 + .../src/CultMath/Expr/ExprGraphException.cs | 22 +++ .../CultMath/Expr/ExprGraphException.cs.meta | 2 + .../cultmath/src/CultMath/Expr/ExprNode.cs | 79 ++++++++ .../src/CultMath/Expr/ExprNode.cs.meta | 2 + .../cultmath/src/CultMath/Expr/ExprOps.cs | 86 ++++++++ .../src/CultMath/Expr/ExprOps.cs.meta | 2 + .../CultDocumentMessagePackSerialization.cs | 2 +- .../CultMathExprResolver.cs | 125 ++++++++++++ 17 files changed, 782 insertions(+), 1 deletion(-) create mode 100644 packages/cultmath/src/CultMath/Expr.meta create mode 100644 packages/cultmath/src/CultMath/Expr/ExprBindings.cs create mode 100644 packages/cultmath/src/CultMath/Expr/ExprBindings.cs.meta create mode 100644 packages/cultmath/src/CultMath/Expr/ExprBuilder.cs create mode 100644 packages/cultmath/src/CultMath/Expr/ExprBuilder.cs.meta create mode 100644 packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs create mode 100644 packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs.meta create mode 100644 packages/cultmath/src/CultMath/Expr/ExprGraph.cs create mode 100644 packages/cultmath/src/CultMath/Expr/ExprGraph.cs.meta create mode 100644 packages/cultmath/src/CultMath/Expr/ExprGraphException.cs create mode 100644 packages/cultmath/src/CultMath/Expr/ExprGraphException.cs.meta create mode 100644 packages/cultmath/src/CultMath/Expr/ExprNode.cs create mode 100644 packages/cultmath/src/CultMath/Expr/ExprNode.cs.meta create mode 100644 packages/cultmath/src/CultMath/Expr/ExprOps.cs create mode 100644 packages/cultmath/src/CultMath/Expr/ExprOps.cs.meta create mode 100644 src/GameCult.Caching.MessagePack/CultMathExprResolver.cs diff --git a/packages/cultmath/src/CultMath/Expr.meta b/packages/cultmath/src/CultMath/Expr.meta new file mode 100644 index 00000000..6bab6f53 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a99b96b837a3454781b426cec6bfe837 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/src/CultMath/Expr/ExprBindings.cs b/packages/cultmath/src/CultMath/Expr/ExprBindings.cs new file mode 100644 index 00000000..0382e026 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprBindings.cs @@ -0,0 +1,39 @@ +namespace CultMath.Expr; + +/// +/// The param values of one graph for an evaluation batch: every param at its default unless overridden by name. +/// +public sealed class ExprBindings +{ + private ExprBindings(ExprGraph graph, float[] values) + { + Graph = graph; + Values = values; + } + + public ExprGraph Graph { get; } + + /// One value per param, in the graph's param order. + internal float[] Values { get; } + + /// + /// Binds 's params: a name in replaces the param's default + /// and every other param keeps it. A name the graph has no param for is refused. + /// + public static ExprBindings Bind(ExprGraph graph, IReadOnlyDictionary? overrides = null) + { + var values = new float[graph.ParamNames.Count]; + for (var i = 0; i < values.Length; i++) values[i] = BitConverter.Int32BitsToSingle(graph.ParamDefaultBits[i]); + if (overrides is not null) + { + foreach (var pair in overrides) + { + var index = graph.ParamIndex(pair.Key); + if (index < 0) throw new ExprGraphException("unknown-param"); + values[index] = pair.Value; + } + } + + return new ExprBindings(graph, values); + } +} diff --git a/packages/cultmath/src/CultMath/Expr/ExprBindings.cs.meta b/packages/cultmath/src/CultMath/Expr/ExprBindings.cs.meta new file mode 100644 index 00000000..f306a3b1 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprBindings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c9dea1bb6c854e8f80a5e0f05902fd17 diff --git a/packages/cultmath/src/CultMath/Expr/ExprBuilder.cs b/packages/cultmath/src/CultMath/Expr/ExprBuilder.cs new file mode 100644 index 00000000..7ffca144 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprBuilder.cs @@ -0,0 +1,184 @@ +namespace CultMath.Expr; + +/// A node of an under construction. Only the builder that made it accepts it. +public sealed class ExprValue +{ + internal ExprValue(ExprBuilder owner, int index, int resultCount) + { + Owner = owner; + Index = index; + ResultCount = resultCount; + } + + internal ExprBuilder Owner { get; } + + internal int Index { get; } + + /// More than one for a multi-result op, whose results are read only through . + public int ResultCount { get; } +} + +/// +/// Builds an . Identical (op, operands, constant bits) nodes are one node, and +/// drops nodes no output reaches and renumbers densely, so the same call sequence builds +/// byte-identical graphs. Every call runs the validator's rules and refuses at the call. +/// +public sealed class ExprBuilder +{ + private readonly List nodes = new(); + private readonly List handles = new(); + private readonly Dictionary interned = new(); + private readonly List inputNames = new(); + private readonly List paramNames = new(); + private readonly List paramDefaultBits = new(); + private readonly List outputNames = new(); + private readonly List outputNodes = new(); + + /// The graph input of this name, declared on first use. + public ExprValue Input(string name) + { + var port = inputNames.IndexOf(name); + if (port < 0) + { + ExprGraph.CheckPortName(name); + port = inputNames.Count; + inputNames.Add(name); + } + + return Intern(ExprNode.Input(port)); + } + + /// The graph param of this name, declared on first use. Declaring it again with other default bits is refused. + public ExprValue Param(string name, float defaultValue) => ParamBits(name, BitConverter.SingleToInt32Bits(defaultValue)); + + /// A constant; its bits (a signed zero, a NaN payload, a subnormal) are kept. + public ExprValue Const(float value) => ConstBits(BitConverter.SingleToInt32Bits(value)); + + public ExprValue Call(string op, params ExprValue[] operands) + { + var indices = new int[operands.Length]; + for (var i = 0; i < operands.Length; i++) indices[i] = Own(operands[i]); + return Intern(ExprNode.Call(op, indices)); + } + + /// Result of a multi-result node. + public ExprValue Pick(ExprValue node, int k) => Intern(ExprNode.Pick(Own(node), k)); + + /// Names a single-result node as an output. + public void Output(string name, ExprValue value) + { + ExprGraph.CheckPortName(name); + if (outputNames.Contains(name)) throw new ExprGraphException("duplicate-port-name"); + var node = Own(value); + ExprGraph.CheckOutput(node, nodes); + outputNames.Add(name); + outputNodes.Add(node); + } + + /// The graph of the outputs declared so far: only nodes an output reaches, renumbered densely in build order. + public ExprGraph Build() + { + var live = new bool[nodes.Count]; + foreach (var node in outputNodes) live[node] = true; + for (var i = nodes.Count - 1; i >= 0; i--) + if (live[i]) + foreach (var operand in nodes[i].Operands) live[operand] = true; + + var renumbered = new int[nodes.Count]; + var kept = new List(); + for (var i = 0; i < nodes.Count; i++) + { + if (!live[i]) continue; + renumbered[i] = kept.Count; + kept.Add(nodes[i].WithOperands(nodes[i].Operands.Select(operand => renumbered[operand]).ToArray())); + } + + return ExprGraph.Create( + ExprGraph.FormatVersion, inputNames, paramNames, paramDefaultBits, outputNames, outputNodes.Select(node => renumbered[node]).ToList(), kept); + } + + /// + /// Rebuilds inside this builder with the named inputs replaced by the given values + /// (Fidget's remap_xyz) and returns each output's value by name. An input not named is this builder's input + /// of that name; every param is this builder's param of that name. A stencil is N substitutions combined by + /// ops: an ordinary flat graph, with no loop construct. + /// + public IReadOnlyDictionary Substitute(ExprGraph graph, IReadOnlyDictionary replacements) + { + foreach (var pair in replacements) + { + if (!graph.InputNames.Contains(pair.Key)) throw new ExprGraphException("unknown-input"); + Own(pair.Value); + if (pair.Value.ResultCount != 1) throw new ExprGraphException("bare-multi-result"); + } + + var mapped = new ExprValue[graph.Nodes.Count]; + for (var i = 0; i < mapped.Length; i++) + { + var node = graph.Nodes[i]; + switch (node.Kind) + { + case ExprNodeKind.Input: + var input = graph.InputNames[node.Payload]; + mapped[i] = replacements.TryGetValue(input, out var replacement) ? replacement : Input(input); + break; + case ExprNodeKind.Param: + mapped[i] = ParamBits(graph.ParamNames[node.Payload], graph.ParamDefaultBits[node.Payload]); + break; + case ExprNodeKind.Const: + mapped[i] = ConstBits(node.Payload); + break; + case ExprNodeKind.Pick: + mapped[i] = Pick(mapped[node.Operands[0]], node.Payload); + break; + default: + mapped[i] = Call(node.WireName, node.Operands.Select(operand => mapped[operand]).ToArray()); + break; + } + } + + var outputs = new Dictionary(); + for (var o = 0; o < graph.OutputNames.Count; o++) outputs[graph.OutputNames[o]] = mapped[graph.OutputNodes[o]]; + return outputs; + } + + private ExprValue ParamBits(string name, int bits) + { + var port = paramNames.IndexOf(name); + if (port < 0) + { + ExprGraph.CheckPortName(name); + port = paramNames.Count; + paramNames.Add(name); + paramDefaultBits.Add(bits); + } + else if (paramDefaultBits[port] != bits) + { + throw new ExprGraphException("port-conflict"); + } + + return Intern(ExprNode.Param(port)); + } + + private ExprValue ConstBits(int bits) => Intern(ExprNode.Const(bits)); + + private int Own(ExprValue value) + { + if (value.Owner != this) throw new ExprGraphException("foreign-operand", nodes.Count); + return value.Index; + } + + private ExprValue Intern(ExprNode node) + { + ExprGraph.CheckNode(nodes.Count, node, nodes, inputNames.Count, paramNames.Count); + if (!interned.TryGetValue(node.Key, out var index)) + { + index = nodes.Count; + nodes.Add(node); + handles.Add(new ExprValue(this, index, node.ResultCount)); + interned[node.Key] = index; + } + + return handles[index]; + } +} diff --git a/packages/cultmath/src/CultMath/Expr/ExprBuilder.cs.meta b/packages/cultmath/src/CultMath/Expr/ExprBuilder.cs.meta new file mode 100644 index 00000000..a65eb534 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7326eeabc2d04a43b3d0a1fe6e45b018 diff --git a/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs b/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs new file mode 100644 index 00000000..3a7496af --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs @@ -0,0 +1,56 @@ +namespace CultMath.Expr; + +/// +/// Evaluates an per point: nodes in order through , IEEE single per op, +/// so a result equals the direct composition of the same math calls bit for bit. Allocates once per call, never per point. +/// +public static class ExprEvaluator +{ + /// + /// Columns are contiguous: input i of point p is inputs[i * n + p], and output o is + /// outputs[o * n + p], where n = outputs.Length / graph.OutputNames.Count. + /// + public static void Evaluate(ExprGraph graph, ExprBindings bindings, ReadOnlySpan inputs, Span outputs) + { + if (bindings.Graph != graph) throw new ExprGraphException("foreign-bindings"); + var outputCount = graph.OutputNames.Count; + if (outputCount == 0) return; + if (outputs.Length % outputCount != 0) throw new ExprGraphException("column-shape"); + var points = outputs.Length / outputCount; + if (inputs.Length != points * graph.InputNames.Count) throw new ExprGraphException("column-shape"); + + var nodes = graph.Nodes; + var start = graph.SlotStart; + var slots = new float[graph.SlotCount]; + Span operands = stackalloc float[ExprOps.MaxArity]; + for (var point = 0; point < points; point++) + { + for (var i = 0; i < nodes.Count; i++) + { + var node = nodes[i]; + switch (node.Kind) + { + case ExprNodeKind.Input: + slots[start[i]] = inputs[node.Payload * points + point]; + break; + case ExprNodeKind.Param: + slots[start[i]] = bindings.Values[node.Payload]; + break; + case ExprNodeKind.Const: + slots[start[i]] = BitConverter.Int32BitsToSingle(node.Payload); + break; + case ExprNodeKind.Pick: + slots[start[i]] = slots[start[node.Operands[0]] + node.Payload]; + break; + default: + var op = node.Op!; + for (var k = 0; k < op.Arity; k++) operands[k] = slots[start[node.Operands[k]]]; + op.Evaluate(operands.Slice(0, op.Arity), new Span(slots, start[i], op.ResultCount)); + break; + } + } + + for (var o = 0; o < outputCount; o++) outputs[o * points + point] = slots[start[graph.OutputNodes[o]]]; + } + } +} diff --git a/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs.meta b/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs.meta new file mode 100644 index 00000000..f89bacf4 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 38108254b5b4430b8f87094e6905dc59 diff --git a/packages/cultmath/src/CultMath/Expr/ExprGraph.cs b/packages/cultmath/src/CultMath/Expr/ExprGraph.cs new file mode 100644 index 00000000..a75bb941 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprGraph.cs @@ -0,0 +1,168 @@ +namespace CultMath.Expr; + +/// +/// An immutable scalar SSA expression graph: nodes in topological order (an operand names a lower node), named +/// inputs that vary per point, named params that are uniform per batch and carry a default, and named outputs. +/// A graph is a value; an edit builds a new one. is the one validator: the builder runs +/// it on and a decoder runs it on persisted bytes, so no partial graph is ever returned. +/// +public sealed class ExprGraph +{ + /// The persisted format version this build reads and writes. + public const int FormatVersion = 1; + + private readonly string[] inputNames; + private readonly string[] paramNames; + private readonly int[] paramDefaultBits; + private readonly string[] outputNames; + private readonly int[] outputNodes; + private readonly ExprNode[] nodes; + + private ExprGraph(string[] inputNames, string[] paramNames, int[] paramDefaultBits, string[] outputNames, int[] outputNodes, ExprNode[] nodes) + { + this.inputNames = inputNames; + this.paramNames = paramNames; + this.paramDefaultBits = paramDefaultBits; + this.outputNames = outputNames; + this.outputNodes = outputNodes; + this.nodes = nodes; + SlotStart = new int[nodes.Length]; + var slots = 0; + for (var i = 0; i < nodes.Length; i++) + { + SlotStart[i] = slots; + slots += nodes[i].ResultCount; + } + + SlotCount = slots; + } + + public IReadOnlyList InputNames => inputNames; + + public IReadOnlyList ParamNames => paramNames; + + /// The default of each param as float bits, so a NaN payload or a signed zero survives. + public IReadOnlyList ParamDefaultBits => paramDefaultBits; + + public IReadOnlyList OutputNames => outputNames; + + /// The node each output reads. + public IReadOnlyList OutputNodes => outputNodes; + + public IReadOnlyList Nodes => nodes; + + /// Where each node's results start in an evaluator's value array. + internal int[] SlotStart { get; } + + internal int SlotCount { get; } + + /// The index of the param of this name, or -1. + internal int ParamIndex(string name) => Array.IndexOf(paramNames, name); + + /// + /// Validates and returns a graph, or throws naming the rule and the node. + /// The rules: format version, port counts, port names (syntax, unique per kind), and per node its kind's + /// operand count, a known op and its arity, operands below the node and never a bare multi-result node, + /// pick range, port index and payload; and per output a node in range that is not a bare multi-result node. + /// + public static ExprGraph Create( + int version, + IReadOnlyList inputNames, + IReadOnlyList paramNames, + IReadOnlyList paramDefaultBits, + IReadOnlyList outputNames, + IReadOnlyList outputNodes, + IReadOnlyList nodes) + { + if (version != FormatVersion) throw new ExprGraphException("version"); + if (paramNames.Count != paramDefaultBits.Count || outputNames.Count != outputNodes.Count) throw new ExprGraphException("port-count"); + CheckPortNames(inputNames); + CheckPortNames(paramNames); + CheckPortNames(outputNames); + + var before = new List(nodes.Count); + for (var i = 0; i < nodes.Count; i++) + { + CheckNode(i, nodes[i], before, inputNames.Count, paramNames.Count); + before.Add(nodes[i]); + } + + foreach (var node in outputNodes) CheckOutput(node, before); + + return new ExprGraph( + inputNames.ToArray(), paramNames.ToArray(), paramDefaultBits.ToArray(), outputNames.ToArray(), outputNodes.ToArray(), nodes.ToArray()); + } + + /// A port name is [A-Za-z_][A-Za-z0-9_]*. + internal static void CheckPortName(string name) + { + if (name.Length == 0 || !IsHead(name[0])) throw new ExprGraphException("port-name"); + for (var i = 1; i < name.Length; i++) + if (!IsHead(name[i]) && !(name[i] >= '0' && name[i] <= '9')) throw new ExprGraphException("port-name"); + } + + private static bool IsHead(char c) => c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + + private static void CheckPortNames(IReadOnlyList names) + { + var seen = new HashSet(); + foreach (var name in names) + { + CheckPortName(name); + if (!seen.Add(name)) throw new ExprGraphException("duplicate-port-name"); + } + } + + /// The rules for one node given the nodes before it. The builder runs this per call, the validator per node. + internal static void CheckNode(int index, ExprNode node, IReadOnlyList before, int inputCount, int paramCount) + { + var operands = node.Operands; + switch (node.Kind) + { + case ExprNodeKind.Input: + CheckLeaf(index, node, inputCount); + break; + case ExprNodeKind.Param: + CheckLeaf(index, node, paramCount); + break; + case ExprNodeKind.Const: + if (operands.Count != 0) throw new ExprGraphException("arity", index); + break; + case ExprNodeKind.Pick: + if (operands.Count != 1) throw new ExprGraphException("arity", index); + CheckOperandOrder(index, operands[0]); + var results = before[operands[0]].ResultCount; + if (results <= 1 || node.Payload < 0 || node.Payload >= results) throw new ExprGraphException("pick-range", index); + break; + default: + if (node.Op is null) throw new ExprGraphException("unknown-op", index); + if (operands.Count != node.Op.Arity) throw new ExprGraphException("arity", index); + if (node.Payload != 0) throw new ExprGraphException("payload", index); + foreach (var operand in operands) + { + CheckOperandOrder(index, operand); + if (before[operand].ResultCount != 1) throw new ExprGraphException("bare-multi-result", index); + } + + break; + } + } + + private static void CheckLeaf(int index, ExprNode node, int portCount) + { + if (node.Operands.Count != 0) throw new ExprGraphException("arity", index); + if (node.Payload < 0 || node.Payload >= portCount) throw new ExprGraphException("port-index", index); + } + + private static void CheckOperandOrder(int index, int operand) + { + if (operand < 0 || operand >= index) throw new ExprGraphException("operand-order", index); + } + + /// An output reads a node in range that is single-result. + internal static void CheckOutput(int node, IReadOnlyList nodes) + { + if (node < 0 || node >= nodes.Count) throw new ExprGraphException("output-node"); + if (nodes[node].ResultCount != 1) throw new ExprGraphException("bare-multi-result", node); + } +} diff --git a/packages/cultmath/src/CultMath/Expr/ExprGraph.cs.meta b/packages/cultmath/src/CultMath/Expr/ExprGraph.cs.meta new file mode 100644 index 00000000..d6d57122 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprGraph.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 04d707ad2a4e4e0299407ce1d86afea3 diff --git a/packages/cultmath/src/CultMath/Expr/ExprGraphException.cs b/packages/cultmath/src/CultMath/Expr/ExprGraphException.cs new file mode 100644 index 00000000..7d10a002 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprGraphException.cs @@ -0,0 +1,22 @@ +namespace CultMath.Expr; + +/// +/// The one refusal of the expression graph: a build call, a decoded encoding, a binding or an evaluation +/// that breaks a rule. names the rule and the node it broke at +/// (-1 when no node is involved). The message names the rule and never echoes a value it was given. +/// +public sealed class ExprGraphException : Exception +{ + public ExprGraphException(string rule, int nodeIndex = -1) + : base(nodeIndex < 0 ? rule : rule + " at node " + nodeIndex) + { + Rule = rule; + NodeIndex = nodeIndex; + } + + /// The rule broken, a short stable name such as unknown-op or operand-order. + public string Rule { get; } + + /// The index of the node the rule broke at, or -1. + public int NodeIndex { get; } +} diff --git a/packages/cultmath/src/CultMath/Expr/ExprGraphException.cs.meta b/packages/cultmath/src/CultMath/Expr/ExprGraphException.cs.meta new file mode 100644 index 00000000..02d5feca --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprGraphException.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 88e355a7be3f44ada7f1d9464c60428e diff --git a/packages/cultmath/src/CultMath/Expr/ExprNode.cs b/packages/cultmath/src/CultMath/Expr/ExprNode.cs new file mode 100644 index 00000000..719eac66 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprNode.cs @@ -0,0 +1,79 @@ +namespace CultMath.Expr; + +/// What a node is: a graph input, a graph param, a constant, a pick of one result of a multi-result node, or a call of an op. +public enum ExprNodeKind +{ + Input, + Param, + Const, + Pick, + Call, +} + +/// +/// One node of an : a kind, the operand node indices (always lower than its own), +/// and one integer payload: the port index of an input or param, the float bits of a constant, the result +/// index of a pick, and zero for a call. Immutable. +/// +public sealed class ExprNode +{ + private const string InputName = "input"; + private const string ParamName = "param"; + private const string ConstName = "const"; + private const string PickName = "pick"; + + private static readonly int[] NoOperands = Array.Empty(); + + private readonly int[] operandArray; + + private ExprNode(ExprNodeKind kind, string wireName, int[] operands, int payload, ExprOp? op) + { + Kind = kind; + WireName = wireName; + operandArray = operands; + Payload = payload; + Op = op; + ResultCount = op?.ResultCount ?? 1; + } + + public ExprNodeKind Kind { get; } + + /// The persisted name: an op name for a call, otherwise input, param, const or pick. + public string WireName { get; } + + public IReadOnlyList Operands => operandArray; + + public int Payload { get; } + + /// How many floats the node produces: the op's result count for a call, otherwise one. + public int ResultCount { get; } + + /// The op of a known call; null for every other node and for a call naming an unknown op. + internal ExprOp? Op { get; } + + public static ExprNode Input(int port) => new(ExprNodeKind.Input, InputName, NoOperands, port, null); + + public static ExprNode Param(int port) => new(ExprNodeKind.Param, ParamName, NoOperands, port, null); + + public static ExprNode Const(int bits) => new(ExprNodeKind.Const, ConstName, NoOperands, bits, null); + + public static ExprNode Pick(int node, int result) => new(ExprNodeKind.Pick, PickName, new[] { node }, result, null); + + public static ExprNode Call(string op, IReadOnlyList operands) => + new(ExprNodeKind.Call, op, operands.ToArray(), 0, ExprOps.Find(op)); + + /// The node a persisted names. A name that is none of the four kinds is a call, which the validator checks against the op table. + public static ExprNode FromWire(string wireName, IReadOnlyList operands, int payload) => wireName switch + { + InputName => new ExprNode(ExprNodeKind.Input, wireName, operands.ToArray(), payload, null), + ParamName => new ExprNode(ExprNodeKind.Param, wireName, operands.ToArray(), payload, null), + ConstName => new ExprNode(ExprNodeKind.Const, wireName, operands.ToArray(), payload, null), + PickName => new ExprNode(ExprNodeKind.Pick, wireName, operands.ToArray(), payload, null), + _ => new ExprNode(ExprNodeKind.Call, wireName, operands.ToArray(), payload, ExprOps.Find(wireName)), + }; + + internal ExprNode WithOperands(int[] operands) => new(Kind, WireName, operands, Payload, Op); + + /// Equal keys are the same expression: the builder's hash-consing identity. + internal string Key => WireName + "|" + string.Join(",", operandArray) + "|" + Payload; +} diff --git a/packages/cultmath/src/CultMath/Expr/ExprNode.cs.meta b/packages/cultmath/src/CultMath/Expr/ExprNode.cs.meta new file mode 100644 index 00000000..99f5388e --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprNode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc3cb4abaa27482aa98a76cd876b8805 diff --git a/packages/cultmath/src/CultMath/Expr/ExprOps.cs b/packages/cultmath/src/CultMath/Expr/ExprOps.cs new file mode 100644 index 00000000..f8a63d9c --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprOps.cs @@ -0,0 +1,86 @@ +using static CultMath.math; + +namespace CultMath.Expr; + +/// One entry of the op table: a stable name, its arity, its result count and its C# implementation. +public sealed class ExprOp +{ + /// Writes ( floats) from + /// ( floats). + public delegate void Function(ReadOnlySpan operands, Span results); + + private readonly Function function; + + internal ExprOp(string name, int arity, int resultCount, Function function) + { + Name = name; + Arity = arity; + ResultCount = resultCount; + this.function = function; + } + + /// The persisted name. Append-only: a released name keeps its meaning. + public string Name { get; } + + public int Arity { get; } + + /// One for a scalar op; more for an op read only through . + public int ResultCount { get; } + + public void Evaluate(ReadOnlySpan operands, Span results) => function(operands, results); +} + +/// +/// The one op table of the expression graph: each op names a CultMath function and calls it, wrapping no type +/// and copying no body. The C# evaluator reads it, and so does any later lowering. +/// +public static class ExprOps +{ + /// The largest arity in the table. + internal const int MaxArity = 4; + + private static ExprOp Op(string name, int arity, ExprOp.Function function) => new(name, arity, 1, function); + + private static readonly ExprOp[] Table = + { + Op("add", 2, (a, r) => r[0] = a[0] + a[1]), + Op("sub", 2, (a, r) => r[0] = a[0] - a[1]), + Op("mul", 2, (a, r) => r[0] = a[0] * a[1]), + Op("div", 2, (a, r) => r[0] = a[0] / a[1]), + Op("neg", 1, (a, r) => r[0] = -a[0]), + Op("min", 2, (a, r) => r[0] = min(a[0], a[1])), + Op("max", 2, (a, r) => r[0] = max(a[0], a[1])), + Op("abs", 1, (a, r) => r[0] = abs(a[0])), + Op("floor", 1, (a, r) => r[0] = floor(a[0])), + Op("frac", 1, (a, r) => r[0] = frac(a[0])), + Op("sqrt", 1, (a, r) => r[0] = sqrt(a[0])), + Op("exp", 1, (a, r) => r[0] = exp(a[0])), + Op("log", 1, (a, r) => r[0] = log(a[0])), + Op("pow", 2, (a, r) => r[0] = pow(a[0], a[1])), + Op("sin", 1, (a, r) => r[0] = sin(a[0])), + Op("cos", 1, (a, r) => r[0] = cos(a[0])), + Op("clamp", 3, (a, r) => r[0] = clamp(a[0], a[1], a[2])), + Op("saturate", 1, (a, r) => r[0] = saturate(a[0])), + Op("lerp", 3, (a, r) => r[0] = lerp(a[0], a[1], a[2])), + Op("step", 2, (a, r) => r[0] = step(a[0], a[1])), + Op("smoothstep", 3, (a, r) => r[0] = smoothstep(a[0], a[1], a[2])), + Op("select", 4, (a, r) => r[0] = select(a[0] > a[1], a[2], a[3])), + Op("snoise2", 2, (a, r) => r[0] = snoise(new float2(a[0], a[1]))), + Op("snoise3", 3, (a, r) => r[0] = snoise(new float3(a[0], a[1], a[2]))), + new ExprOp("cellular3", 3, 9, (a, r) => + { + var c = cellular(new float3(a[0], a[1], a[2])); + r[0] = c.nearest.x; r[1] = c.nearest.y; r[2] = c.nearest.z; r[3] = c.nearest.w; + r[4] = c.edge.x; r[5] = c.edge.y; r[6] = c.edge.z; r[7] = c.edge.w; + r[8] = c.id; + }), + }; + + private static readonly Dictionary ByName = Table.ToDictionary(op => op.Name); + + /// Every op, in declaration order. + public static IReadOnlyList All => Table; + + /// The op with this persisted name, or null. + public static ExprOp? Find(string name) => ByName.TryGetValue(name, out var op) ? op : null; +} diff --git a/packages/cultmath/src/CultMath/Expr/ExprOps.cs.meta b/packages/cultmath/src/CultMath/Expr/ExprOps.cs.meta new file mode 100644 index 00000000..d2f541e5 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprOps.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f2cb6ebc7a4f43d59009e613a1b75888 diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 2a3353de..df7302de 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -58,7 +58,7 @@ public static class CultDocumentMessagePackSerialization // The guard goes first and wraps whatever formatter the rest of the composite would hand out, so a consumer // resolver still encodes an arm while the key check runs. var inner = CompositeResolver.Create( - consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(StandardResolver.Instance).ToArray()); + consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(CultMathExprResolver.Instance).Append(StandardResolver.Instance).ToArray()); return MessagePackSerializerOptions.Standard .WithResolver(CompositeResolver.Create(new CultUnionGuard(inner), inner)) .WithSecurity(CultMessagePackSecurity.Instance); diff --git a/src/GameCult.Caching.MessagePack/CultMathExprResolver.cs b/src/GameCult.Caching.MessagePack/CultMathExprResolver.cs new file mode 100644 index 00000000..4c70b2a8 --- /dev/null +++ b/src/GameCult.Caching.MessagePack/CultMathExprResolver.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using CultMath.Expr; +using MessagePack; +using MessagePack.Formatters; + +namespace GameCult.Caching.MessagePack; + +/// +/// The canonical MessagePack encoding of , beside in the +/// default chain. A graph is [version, inputs, params, outputs, nodes]: inputs are names; a param is +/// [name, default float bits]; an output is [name, node]; a node is [wire name, operands, payload] +/// (the payload is the port index of an input or param, the float bits of a constant, the result index of a pick, +/// zero for a call). Decoding runs , the one validator, so a graph that breaks a rule +/// (unknown op, newer version, forward operand) is refused whole with an and never +/// partly loaded. A nil graph is refused both ways. +/// +public sealed class CultMathExprResolver : IFormatterResolver +{ + public static readonly CultMathExprResolver Instance = new(); + private CultMathExprResolver() { } + + public IMessagePackFormatter? GetFormatter() => Cache.Formatter; + + private static class Cache + { + public static readonly IMessagePackFormatter? Formatter = + typeof(T) == typeof(ExprGraph) ? (IMessagePackFormatter)(object)new GraphFormatter() : null; + } + + private sealed class GraphFormatter : IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, ExprGraph value, MessagePackSerializerOptions options) + { + if (value is null) throw new ExprGraphException("shape"); + writer.WriteArrayHeader(5); + writer.Write(ExprGraph.FormatVersion); + + writer.WriteArrayHeader(value.InputNames.Count); + foreach (var name in value.InputNames) writer.Write(name); + + writer.WriteArrayHeader(value.ParamNames.Count); + for (var i = 0; i < value.ParamNames.Count; i++) + { + writer.WriteArrayHeader(2); + writer.Write(value.ParamNames[i]); + writer.Write(value.ParamDefaultBits[i]); + } + + writer.WriteArrayHeader(value.OutputNames.Count); + for (var i = 0; i < value.OutputNames.Count; i++) + { + writer.WriteArrayHeader(2); + writer.Write(value.OutputNames[i]); + writer.Write(value.OutputNodes[i]); + } + + writer.WriteArrayHeader(value.Nodes.Count); + foreach (var node in value.Nodes) + { + writer.WriteArrayHeader(3); + writer.Write(node.WireName); + writer.WriteArrayHeader(node.Operands.Count); + foreach (var operand in node.Operands) writer.Write(operand); + writer.Write(node.Payload); + } + } + + public ExprGraph Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + if (reader.TryReadNil()) throw new ExprGraphException("shape"); + options.Security.DepthStep(ref reader); + try + { + Expect(ref reader, 5); + var version = reader.ReadInt32(); + + var inputs = new List(); + for (int i = 0, n = reader.ReadArrayHeader(); i < n; i++) inputs.Add(ReadName(ref reader)); + + var paramNames = new List(); + var paramBits = new List(); + for (int i = 0, n = reader.ReadArrayHeader(); i < n; i++) + { + Expect(ref reader, 2); + paramNames.Add(ReadName(ref reader)); + paramBits.Add(reader.ReadInt32()); + } + + var outputNames = new List(); + var outputNodes = new List(); + for (int i = 0, n = reader.ReadArrayHeader(); i < n; i++) + { + Expect(ref reader, 2); + outputNames.Add(ReadName(ref reader)); + outputNodes.Add(reader.ReadInt32()); + } + + var nodes = new List(); + for (int i = 0, n = reader.ReadArrayHeader(); i < n; i++) + { + Expect(ref reader, 3); + var wireName = ReadName(ref reader); + var operands = new List(); + for (int k = 0, m = reader.ReadArrayHeader(); k < m; k++) operands.Add(reader.ReadInt32()); + nodes.Add(ExprNode.FromWire(wireName, operands, reader.ReadInt32())); + } + + return ExprGraph.Create(version, inputs, paramNames, paramBits, outputNames, outputNodes, nodes); + } + finally + { + reader.Depth--; + } + } + + private static void Expect(ref MessagePackReader reader, int count) + { + if (reader.ReadArrayHeader() != count) throw new ExprGraphException("shape"); + } + + private static string ReadName(ref MessagePackReader reader) => + reader.ReadString() ?? throw new ExprGraphException("shape"); + } +} From defe2b80de791ab95e4484e454a9592826ae082e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 06:05:19 +0200 Subject: [PATCH 2/7] Document CultMath.Expr: design.md Expressions section, resolver README Co-Authored-By: Claude Opus 5.5 --- packages/cultmath/docs/design.md | 33 ++++++++++++++++++++++ src/GameCult.Caching.MessagePack/README.md | 8 ++++++ 2 files changed, 41 insertions(+) diff --git a/packages/cultmath/docs/design.md b/packages/cultmath/docs/design.md index a3c1519f..6cca55ee 100644 --- a/packages/cultmath/docs/design.md +++ b/packages/cultmath/docs/design.md @@ -834,6 +834,39 @@ interval, under the affine bound and under the oracle mask (one logarithmic scale) and the transmittance. Its run: 4.23, 3.60 and 2.02 cells per ray on average, 44 the most, mean transmittance 0.278. +## Expressions + +`CultMath.Expr` holds a scalar SSA expression graph as a persisted value, a builder +for it, and a C# evaluator. + +- **Graph (`ExprGraph`)**: a sealed immutable class. It has no name of its own; the + consumer record that holds it names it. Nodes are in topological order, an operand + names a lower node, and an edit builds a new graph. A persisted graph carries a + format version (1); one naming an unknown op, a newer format or a forward operand is + refused whole at decode. `ExprGraph.Create` is the one validator, run by the builder + and by decode. +- **Node**: its index in the topological order. The builder hash-conses identical + (op, operands, constant bits) nodes and drops nodes no output reaches, so one + expression always builds the same bytes; any rebuild may renumber nodes. +- **Port**: graph inputs vary per point, params are uniform per batch and carry a + default, outputs name a node. Names are `[A-Za-z_][A-Za-z0-9_]*`, unique per kind. + An op's operands are positional; a multi-result op (`cellular3`: nearest.xyzw, + edge.xyzw, id) is read only through `Pick`. +- **Op vocabulary**: one table (`ExprOps`), a stable string per op, append-only. + Vector arguments are decomposed operands: `snoise2`, `snoise3`, `cellular3`. An op + calls its CultMath function; a function's value change is a Breaking release note. +- **Evaluation**: `ExprEvaluator.Evaluate` runs the nodes in order through the table, + one IEEE single per op, so a result equals the direct composition of the same math + calls bit for bit. Params bind by name (`ExprBindings`): a missing name takes its + default and an unknown name is refused. +- **Stencils** are substitution at build time (`ExprBuilder.Substitute`, Fidget's + `remap_xyz`): N substitutions of the position inputs combined by ops give an ordinary + flat graph. There is no loop construct. + +The MessagePack shape belongs to `GameCult.Caching.MessagePack` (`CultMathExprResolver`). +Interval, affine and gradient evaluation over the graph, bytecode, the HLSL +interpreter and pruning are not built. + ## Rules - Keep type names HLSL-shaped: `float2`, `float3`, `float4`, `float3x3`, and `math`. diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index ccd8f568..cfe332bf 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -10,6 +10,7 @@ format for the attribute-first `GameCult.Caching` stack. - `CultDocumentResolver` - `CultRecordRefFormatter` - `CultMathResolver`: the canonical component-array encoding of every public CultMath value type, part of the default resolver chain +- `CultMathExprResolver`: the canonical encoding of `CultMath.Expr.ExprGraph`, decoded through its validator, part of the default resolver chain Cult document payloads serialize through `MessagePackSerializer` with the options of the document's assembly (`CultDocumentMessagePackSerialization.OptionsFor`), @@ -83,6 +84,13 @@ refusal on write is a `JsonException` where System.Text.Json throws `ArgumentException`. A consumer resolver or converter registered ahead of these wins. +`CultMath.Expr.ExprGraph` is a class, not a value type, and has its own shape +(`CultMathExprResolver`, in the default chain): `[version, inputs, params, outputs, nodes]`, +with a param `[name, default float bits]`, an output `[name, node]` and a node +`[op name, operands, payload]`. Decoding runs the graph's one validator and refuses a +broken graph whole (an `ExprGraphException` naming the rule), so a consumer +`[CultDocument]` record with an `ExprGraph` field needs no registration. A nil graph is refused. + | Type | MessagePack | JSON | | --- | --- | --- | | `float2`, `double2`, `int2`, `bool2` | `[x, y]` | `{"x":..,"y":..}` | From ed2c5843e45b2dab7a2a2d6549631f9f1ed1087a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 06:09:31 +0200 Subject: [PATCH 3/7] 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(); + } + } +} From 0a4a14dd5e1d4403e19d980ec27f5aaa6ef2239b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 06:40:09 +0200 Subject: [PATCH 4/7] Test that Substitute carries params, constants and picks Co-Authored-By: Claude Opus 5.5 --- .../tests/CultMath.Tests/ExprTests.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/cultmath/tests/CultMath.Tests/ExprTests.cs b/packages/cultmath/tests/CultMath.Tests/ExprTests.cs index 4a97507a..91adcba6 100644 --- a/packages/cultmath/tests/CultMath.Tests/ExprTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/ExprTests.cs @@ -268,6 +268,43 @@ public sealed class ExprTests Assert.Equal("bare-multi-result", Assert.Throws(() => d.Substitute(field, new Dictionary { ["x"] = cell })).Rule); } + [Fact] + public void SubstituteCarriesParamsConstantsAndPicks() + { + // o = p * k + cellular(p, p, k).edge.w - 0.5, with k a param of default 0.25. + var g = new ExprBuilder(); + var gp = g.Input("p"); + var gk = g.Param("k", 0.25f); + var cell = g.Call("cellular3", gp, gp, gk); + g.Output("o", g.Call("sub", g.Call("add", g.Call("mul", gp, gk), g.Pick(cell, 7)), g.Const(0.5f))); + var graph = g.Build(); + + var b = new ExprBuilder(); + var shifted = b.Call("add", b.Input("x"), b.Const(1.5f)); + b.Output("o", b.Substitute(graph, new Dictionary { ["p"] = shifted })["o"]); + var rebuilt = b.Build(); + + Assert.Equal(new[] { "x" }, rebuilt.InputNames); + Assert.Equal(graph.ParamNames, rebuilt.ParamNames); + Assert.Equal(graph.ParamDefaultBits, rebuilt.ParamDefaultBits); + var x = Column(new System.Random(3), 512, -5f, 5f, 0.1); + foreach (var k in new[] { 0.25f, 0.9f }) + { + var expected = x.Select(v => + { + var p = v + 1.5f; + return p * k + cellular(new float3(p, p, k)).edge.w - 0.5f; + }).ToArray(); + var bindings = ExprBindings.Bind(rebuilt, new Dictionary { ["k"] = k }); + AssertSame(expected, Run(rebuilt, bindings, x), 0, "substituted graph, k=" + k); + } + + // A param met again with other default bits is a conflict, not a silent pick. + var c = new ExprBuilder(); + c.Param("k", 0.5f); + Assert.Equal("port-conflict", Refusal(() => c.Substitute(graph, new Dictionary())).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)) + "|" + From 2c5f71b9592a3fb3a950861209dd21274f6718b5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 07:01:34 +0200 Subject: [PATCH 5/7] Close the Stryker and hand-mutant survivors: port-name alphabet, refusal messages, zero outputs, column shape, substitution checks, self operand Co-Authored-By: Claude Opus 5.5 --- .../tests/CultMath.Tests/ExprTests.cs | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/cultmath/tests/CultMath.Tests/ExprTests.cs b/packages/cultmath/tests/CultMath.Tests/ExprTests.cs index 91adcba6..c4f3a2c2 100644 --- a/packages/cultmath/tests/CultMath.Tests/ExprTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/ExprTests.cs @@ -266,6 +266,15 @@ public sealed class ExprTests 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); + + // The checks are the call's own: a graph that is only its input still refuses a foreign or multi-result replacement. + var identity = new ExprBuilder(); + identity.Output("o", identity.Input("x")); + var passThrough = identity.Build(); + var e = new ExprBuilder(); + Assert.Equal("foreign-operand", Refusal(() => e.Substitute(passThrough, new Dictionary { ["x"] = c.Const(1f) })).Rule); + Assert.Equal("bare-multi-result", Refusal(() => e.Substitute(passThrough, new Dictionary { ["x"] = e.Call("cellular3", e.Const(0f), e.Const(0f), e.Const(0f)) })).Rule); + Assert.Same(c.Const(1f), c.Substitute(passThrough, new Dictionary { ["x"] = c.Const(1f) })["o"]); } [Fact] @@ -403,10 +412,12 @@ public sealed class ExprTests 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[2], 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); + var none = new ExprBuilder().Build(); + ExprEvaluator.Evaluate(none, ExprBindings.Bind(none), ReadOnlySpan.Empty, Span.Empty); Assert.Equal(new[] { 1f, 2f, 3f, -1f, -2f, -3f }, outputs); } @@ -502,6 +513,32 @@ public sealed class ExprTests 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); + Rule("operand-order", () => Make(nodes: N(x, ExprNode.Call("neg", new[] { 1 }))), 1); + } + + [Fact] + public void PortNamesAcceptTheirWholeAlphabetAndNothingBeside() + { + foreach (var name in new[] { "a", "z", "A", "Z", "_", "_9", "a0", "Z9", "x_Y_z_0" }) + Assert.Equal(new[] { name }, Make(nodes: N(ExprNode.Input(0)), inputs: new[] { name }).InputNames); + foreach (var name in new[] { "", "9", "@", "[", "`", "{", "/", ":", "a b", "a-b", "é" }) + Assert.Equal("port-name", Refusal(() => Make(nodes: N(ExprNode.Input(0)), inputs: new[] { name })).Rule); + foreach (var name in new[] { "a@", "a[", "a`", "a{", "a/", "a:" }) + Assert.Equal("port-name", Refusal(() => Make(nodes: N(ExprNode.Input(0)), inputs: new[] { name })).Rule); + } + + [Fact] + public void RefusalsNameTheirRuleAndNodeAndNothingElse() + { + var b = new ExprBuilder(); + var x = b.Input("x"); + Assert.Equal("unknown-op at node 1", Refusal(() => b.Call("nope", x)).Message); + Assert.Equal("port-name", Refusal(() => b.Input("1")).Message); + Assert.Equal("version", Refusal(() => Make(nodes: N(ExprNode.Input(0)), version: 3)).Message); + Assert.Equal(-1, Refusal(() => b.Input("1")).NodeIndex); + Assert.Equal(1, Refusal(() => b.Call("nope", x)).NodeIndex); + Assert.Equal(0, new ExprGraphException("r", 0).NodeIndex); + Assert.Equal("r at node 0", new ExprGraphException("r", 0).Message); } [Fact] From 646cc59426bd8cf0aca44403619ffb3ea72d27ac Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 07:54:31 +0200 Subject: [PATCH 6/7] ExprGraph lists are read-only views over private arrays; test short evaluator inputs Co-Authored-By: Claude Opus 5.5 --- .../src/CultMath/Expr/ExprEvaluator.cs | 11 ++-- .../cultmath/src/CultMath/Expr/ExprGraph.cs | 24 ++++++-- .../cultmath/src/CultMath/Expr/ExprNode.cs | 6 +- .../tests/CultMath.Tests/ExprTests.cs | 57 +++++++++++++++++++ 4 files changed, 86 insertions(+), 12 deletions(-) diff --git a/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs b/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs index 3a7496af..2912f1ee 100644 --- a/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs +++ b/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs @@ -19,13 +19,14 @@ public static class ExprEvaluator var points = outputs.Length / outputCount; if (inputs.Length != points * graph.InputNames.Count) throw new ExprGraphException("column-shape"); - var nodes = graph.Nodes; + var nodes = graph.NodeArray; + var outputNodes = graph.OutputNodeArray; var start = graph.SlotStart; var slots = new float[graph.SlotCount]; Span operands = stackalloc float[ExprOps.MaxArity]; for (var point = 0; point < points; point++) { - for (var i = 0; i < nodes.Count; i++) + for (var i = 0; i < nodes.Length; i++) { var node = nodes[i]; switch (node.Kind) @@ -40,17 +41,17 @@ public static class ExprEvaluator slots[start[i]] = BitConverter.Int32BitsToSingle(node.Payload); break; case ExprNodeKind.Pick: - slots[start[i]] = slots[start[node.Operands[0]] + node.Payload]; + slots[start[i]] = slots[start[node.OperandArray[0]] + node.Payload]; break; default: var op = node.Op!; - for (var k = 0; k < op.Arity; k++) operands[k] = slots[start[node.Operands[k]]]; + for (var k = 0; k < op.Arity; k++) operands[k] = slots[start[node.OperandArray[k]]]; op.Evaluate(operands.Slice(0, op.Arity), new Span(slots, start[i], op.ResultCount)); break; } } - for (var o = 0; o < outputCount; o++) outputs[o * points + point] = slots[start[graph.OutputNodes[o]]]; + for (var o = 0; o < outputCount; o++) outputs[o * points + point] = slots[start[outputNodes[o]]]; } } } diff --git a/packages/cultmath/src/CultMath/Expr/ExprGraph.cs b/packages/cultmath/src/CultMath/Expr/ExprGraph.cs index a75bb941..d0edc488 100644 --- a/packages/cultmath/src/CultMath/Expr/ExprGraph.cs +++ b/packages/cultmath/src/CultMath/Expr/ExprGraph.cs @@ -26,6 +26,12 @@ public sealed class ExprGraph this.outputNames = outputNames; this.outputNodes = outputNodes; this.nodes = nodes; + InputNames = Array.AsReadOnly(inputNames); + ParamNames = Array.AsReadOnly(paramNames); + ParamDefaultBits = Array.AsReadOnly(paramDefaultBits); + OutputNames = Array.AsReadOnly(outputNames); + OutputNodes = Array.AsReadOnly(outputNodes); + Nodes = Array.AsReadOnly(nodes); SlotStart = new int[nodes.Length]; var slots = 0; for (var i = 0; i < nodes.Length; i++) @@ -37,19 +43,25 @@ public sealed class ExprGraph SlotCount = slots; } - public IReadOnlyList InputNames => inputNames; + public IReadOnlyList InputNames { get; } - public IReadOnlyList ParamNames => paramNames; + public IReadOnlyList ParamNames { get; } /// The default of each param as float bits, so a NaN payload or a signed zero survives. - public IReadOnlyList ParamDefaultBits => paramDefaultBits; + public IReadOnlyList ParamDefaultBits { get; } - public IReadOnlyList OutputNames => outputNames; + public IReadOnlyList OutputNames { get; } /// The node each output reads. - public IReadOnlyList OutputNodes => outputNodes; + public IReadOnlyList OutputNodes { get; } - public IReadOnlyList Nodes => nodes; + public IReadOnlyList Nodes { get; } + + /// The evaluator's view of the nodes: the private array, never handed out. + internal ExprNode[] NodeArray => nodes; + + /// The evaluator's view of the output nodes: the private array, never handed out. + internal int[] OutputNodeArray => outputNodes; /// Where each node's results start in an evaluator's value array. internal int[] SlotStart { get; } diff --git a/packages/cultmath/src/CultMath/Expr/ExprNode.cs b/packages/cultmath/src/CultMath/Expr/ExprNode.cs index 719eac66..d0bd7d39 100644 --- a/packages/cultmath/src/CultMath/Expr/ExprNode.cs +++ b/packages/cultmath/src/CultMath/Expr/ExprNode.cs @@ -31,6 +31,7 @@ public sealed class ExprNode Kind = kind; WireName = wireName; operandArray = operands; + Operands = Array.AsReadOnly(operands); Payload = payload; Op = op; ResultCount = op?.ResultCount ?? 1; @@ -41,7 +42,10 @@ public sealed class ExprNode /// The persisted name: an op name for a call, otherwise input, param, const or pick. public string WireName { get; } - public IReadOnlyList Operands => operandArray; + public IReadOnlyList Operands { get; } + + /// The evaluator's view of the operands: the private array, never handed out. + internal int[] OperandArray => operandArray; public int Payload { get; } diff --git a/packages/cultmath/tests/CultMath.Tests/ExprTests.cs b/packages/cultmath/tests/CultMath.Tests/ExprTests.cs index c4f3a2c2..491e7537 100644 --- a/packages/cultmath/tests/CultMath.Tests/ExprTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/ExprTests.cs @@ -414,6 +414,10 @@ public sealed class ExprTests var bindings = ExprBindings.Bind(graph); Assert.Equal("column-shape", Assert.Throws(() => ExprEvaluator.Evaluate(graph, bindings, new float[2], new float[5])).Rule); Assert.Equal("column-shape", Assert.Throws(() => ExprEvaluator.Evaluate(graph, bindings, new float[3], new float[4])).Rule); + var short_ = new float[4]; + Assert.Equal("column-shape", Assert.Throws(() => ExprEvaluator.Evaluate(graph, bindings, new float[1], short_)).Rule); + Assert.Equal("column-shape", Assert.Throws(() => ExprEvaluator.Evaluate(graph, bindings, ReadOnlySpan.Empty, short_)).Rule); + Assert.Equal(new float[4], short_); var outputs = new float[6]; ExprEvaluator.Evaluate(graph, bindings, new[] { 1f, 2f, 3f }, outputs); var none = new ExprBuilder().Build(); @@ -421,6 +425,59 @@ public sealed class ExprTests Assert.Equal(new[] { 1f, 2f, 3f, -1f, -2f, -3f }, outputs); } + [Fact] + public void AValidatedGraphCannotBeChangedThroughItsLists() + { + var b = new ExprBuilder(); + var x = b.Input("x"); + var k = b.Param("k", 2f); + b.Output("o", b.Call("mul", x, k)); + var graph = b.Build(); + var input = new[] { 1f, 2f, 3f }; + var before = new float[3]; + ExprEvaluator.Evaluate(graph, ExprBindings.Bind(graph), input, before); + var operands = graph.Nodes[2].Operands; + + Assert.False(graph.InputNames is string[] || graph.ParamNames is string[] || graph.OutputNames is string[]); + Assert.False(graph.ParamDefaultBits is int[] || graph.OutputNodes is int[] || operands is int[] || graph.Nodes is ExprNode[]); + Assert.Throws(() => ((IList)operands)[0] = 7); + Assert.Throws(() => ((IList)graph.OutputNodes)[0] = 99); + Assert.Throws(() => ((IList)graph.ParamDefaultBits)[0] = 0); + Assert.Throws(() => ((IList)graph.InputNames)[0] = "bad name"); + Assert.Throws(() => ((IList)graph.ParamNames)[0] = "bad name"); + Assert.Throws(() => ((IList)graph.OutputNames)[0] = "bad name"); + Assert.Throws(() => ((IList)graph.Nodes)[0] = graph.Nodes[1]); + + var after = new float[3]; + ExprEvaluator.Evaluate(graph, ExprBindings.Bind(graph), input, after); + Assert.Equal(before, after); + Assert.Equal(new[] { 2f, 4f, 6f }, after); + } + + [Fact] + public void CreateCopiesWhatItIsGiven() + { + var inputs = new[] { "x" }; + var outputs = new[] { "o" }; + var outputNodes = new[] { 0 }; + var nodes = new[] { ExprNode.Input(0) }; + var graph = ExprGraph.Create(ExprGraph.FormatVersion, inputs, Array.Empty(), Array.Empty(), outputs, outputNodes, nodes); + + inputs[0] = "bad name"; + outputs[0] = "bad name"; + outputNodes[0] = 99; + nodes[0] = ExprNode.Const(0); + + Assert.Equal(new[] { "x" }, graph.InputNames); + Assert.Equal(new[] { "o" }, graph.OutputNames); + Assert.Equal(new[] { 0 }, graph.OutputNodes); + Assert.Equal(ExprNodeKind.Input, graph.Nodes[0].Kind); + var operands = new[] { 0 }; + var call = ExprNode.Call("neg", operands); + operands[0] = 5; + Assert.Equal(new[] { 0 }, call.Operands); + } + private static ExprGraphException Refusal(Action action) => Assert.Throws(action); [Fact] From d47811c3238fb50cffa07d27bc2696c320d1e732 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 07:54:32 +0200 Subject: [PATCH 7/7] ExprGraph resolver: nil is nil, decode refuses non-canonical encodings, pin nested shape, nil-name and depth rules Co-Authored-By: Claude Opus 5.5 --- .../CultMathExprResolver.cs | 41 +++++++-- src/GameCult.Caching.MessagePack/README.md | 2 +- .../CultMathExprSerializationTests.cs | 86 ++++++++++++++++++- 3 files changed, 118 insertions(+), 11 deletions(-) diff --git a/src/GameCult.Caching.MessagePack/CultMathExprResolver.cs b/src/GameCult.Caching.MessagePack/CultMathExprResolver.cs index 4c70b2a8..6b327e09 100644 --- a/src/GameCult.Caching.MessagePack/CultMathExprResolver.cs +++ b/src/GameCult.Caching.MessagePack/CultMathExprResolver.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Generic; using CultMath.Expr; using MessagePack; @@ -13,7 +14,8 @@ namespace GameCult.Caching.MessagePack; /// (the payload is the port index of an input or param, the float bits of a constant, the result index of a pick, /// zero for a call). Decoding runs , the one validator, so a graph that breaks a rule /// (unknown op, newer version, forward operand) is refused whole with an and never -/// partly loaded. A nil graph is refused both ways. +/// partly loaded. Decoding also refuses any encoding other than the one Serialize writes (a wider integer, +/// a longer length prefix), so a graph has one byte string. A nil graph is nil, as in every reference slot. /// public sealed class CultMathExprResolver : IFormatterResolver { @@ -28,11 +30,21 @@ public sealed class CultMathExprResolver : IFormatterResolver typeof(T) == typeof(ExprGraph) ? (IMessagePackFormatter)(object)new GraphFormatter() : null; } - private sealed class GraphFormatter : IMessagePackFormatter + private sealed class GraphFormatter : IMessagePackFormatter { - public void Serialize(ref MessagePackWriter writer, ExprGraph value, MessagePackSerializerOptions options) + public void Serialize(ref MessagePackWriter writer, ExprGraph? value, MessagePackSerializerOptions options) + { + if (value is null) + { + writer.WriteNil(); + return; + } + + Write(ref writer, value); + } + + private static void Write(ref MessagePackWriter writer, ExprGraph value) { - if (value is null) throw new ExprGraphException("shape"); writer.WriteArrayHeader(5); writer.Write(ExprGraph.FormatVersion); @@ -66,12 +78,13 @@ public sealed class CultMathExprResolver : IFormatterResolver } } - public ExprGraph Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + public ExprGraph? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { - if (reader.TryReadNil()) throw new ExprGraphException("shape"); + if (reader.TryReadNil()) return null; options.Security.DepthStep(ref reader); try { + var start = reader.Position; Expect(ref reader, 5); var version = reader.ReadInt32(); @@ -106,7 +119,9 @@ public sealed class CultMathExprResolver : IFormatterResolver nodes.Add(ExprNode.FromWire(wireName, operands, reader.ReadInt32())); } - return ExprGraph.Create(version, inputs, paramNames, paramBits, outputNames, outputNodes, nodes); + var graph = ExprGraph.Create(version, inputs, paramNames, paramBits, outputNames, outputNodes, nodes); + RequireCanonical(ref reader, start, graph); + return graph; } finally { @@ -114,6 +129,18 @@ public sealed class CultMathExprResolver : IFormatterResolver } } + /// The bytes just read must be the bytes produces for the graph they decoded to. + private static void RequireCanonical(ref MessagePackReader reader, SequencePosition start, ExprGraph graph) + { + var canonical = new ArrayBufferWriter(); + var writer = new MessagePackWriter(canonical); + Write(ref writer, graph); + writer.Flush(); + var read = reader.Sequence.Slice(start, reader.Position); + if (read.Length != canonical.WrittenCount || !read.ToArray().AsSpan().SequenceEqual(canonical.WrittenSpan)) + throw new ExprGraphException("non-canonical"); + } + private static void Expect(ref MessagePackReader reader, int count) { if (reader.ReadArrayHeader() != count) throw new ExprGraphException("shape"); diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index cfe332bf..acb1142f 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -89,7 +89,7 @@ registered ahead of these wins. with a param `[name, default float bits]`, an output `[name, node]` and a node `[op name, operands, payload]`. Decoding runs the graph's one validator and refuses a broken graph whole (an `ExprGraphException` naming the rule), so a consumer -`[CultDocument]` record with an `ExprGraph` field needs no registration. A nil graph is refused. +`[CultDocument]` record with an `ExprGraph` field needs no registration. A nil graph is nil, as in every reference slot; decoding refuses any encoding but the canonical one. | Type | MessagePack | JSON | | --- | --- | --- | diff --git a/tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs b/tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs index 1ffc5abf..503fa14f 100644 --- a/tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs +++ b/tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs @@ -177,10 +177,90 @@ namespace GameCult.Caching.Tests [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(); + } + + [Test] + public void ANilGraphIsNilLikeEveryOtherReferenceSlot() + { + MessagePackSerializer.Serialize(null, Options).Should().Equal(new byte[] { 0xc0 }); + MessagePackSerializer.Deserialize(new byte[] { 0xc0 }, Options).Should().BeNull(); + + var bytes = MessagePackSerializer.Serialize(new ExprHolder { Graph = null!, Scale = 3f }, Options); + var decoded = MessagePackSerializer.Deserialize(bytes, Options); + + decoded.Graph.Should().BeNull(); + decoded.Scale.Should().Be(3f); + } + + // [1, ["x"], [], [["o", 0]], [["input", [], 0]]] + private static readonly byte[] Minimal = + { + 0x95, 0x01, 0x91, 0xa1, 0x78, 0x90, 0x91, 0x92, 0xa1, 0x6f, 0x00, 0x91, 0x93, 0xa5, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x90, 0x00, + }; + + private static byte[] Replace(byte[] bytes, int at, int length, params byte[] with) => + bytes.Take(at).Concat(with).Concat(bytes.Skip(at + length)).ToArray(); + + [Test] + public void DecodeRefusesEveryEncodingButTheOneEncodeWrites() + { + Encode(Decode(Minimal)).Should().Equal(Minimal); + + Refused(Replace(Minimal, 1, 1, 0xd2, 0, 0, 0, 1), "non-canonical"); // version as int32 + Refused(Replace(Minimal, 1, 1, 0xcc, 1), "non-canonical"); // version as uint8 + Refused(Replace(Minimal, 3, 2, 0xd9, 1, 0x78), "non-canonical"); // "x" as str8 + Refused(Replace(Minimal, 2, 1, 0xdc, 0, 1), "non-canonical"); // input list header as array16 + Refused(Replace(Minimal, 5, 1, 0xdd, 0, 0, 0, 0), "non-canonical"); // empty param list as array32 + Refused(Replace(Minimal, Minimal.Length - 1, 1, 0xcc, 0), "non-canonical"); // payload as uint8 + Refused(Replace(Minimal, 10, 1, 0xd0, 0), "non-canonical"); // output node as int8 + } + + [Test] + public void DecodeRefusesNestedShapesAndNilNames() + { + var node = new object[] { "input", new object[0], 0 }; + object[] Graph(object[]? param = null, object[]? output = null, object? node0 = null) => + new object[] + { + 1, + new object[] { "x" }, + param is null ? new object[0] : new object[] { param }, + new object[] { output ?? new object[] { "o", 0 } }, + new object[] { node0 ?? node }, + }; + + Decode(Bytes(Graph(param: new object[] { "k", 0 }, node0: new object[] { "param", new object[0], 0 }))) + .ParamNames.Should().Equal("k"); + + Refused(Bytes(Graph(param: new object[] { "k", 0, 0 })), "shape"); + Refused(Bytes(Graph(param: new object[] { "k" })), "shape"); + Refused(Bytes(Graph(output: new object[] { "o", 0, 0 })), "shape"); + Refused(Bytes(Graph(output: new object[] { "o" })), "shape"); + Refused(Bytes(Graph(node0: new object[] { "input", new object[0], 0, 0 })), "shape"); + Refused(Bytes(Graph(node0: new object[] { "input", new object[0] })), "shape"); + Refused(Bytes(new object[] { 1, new object?[] { null }, new object[0], new object[0], new object[0] }), "shape"); + Refused(Bytes(Graph(param: new object?[] { null, 0 })), "shape"); + Refused(Bytes(Graph(output: new object?[] { null, 0 })), "shape"); + Refused(Bytes(Graph(node0: new object?[] { null, new object[0], 0 })), "shape"); + Refused(Bytes(new object[] { 1, new object[0], new object[0], new object[0], new object[0], 0 }), "shape"); + } + + [Test] + public void DepthIsReleasedAfterEachGraph() + { + var shallow = Options.WithSecurity(MessagePackSecurity.UntrustedData.WithMaximumObjectGraphDepth(4)); + var graphs = Enumerable.Range(0, 40).Select(_ => Sample()).ToArray(); + + var bytes = MessagePackSerializer.Serialize(graphs, shallow); + var decoded = MessagePackSerializer.Deserialize(bytes, shallow); + + decoded.Should().HaveCount(40); + Encode(decoded[39]).Should().Equal(Encode(graphs[0])); + + var tooDeep = Options.WithSecurity(MessagePackSecurity.UntrustedData.WithMaximumObjectGraphDepth(1)); + var act = () => MessagePackSerializer.Deserialize(bytes, tooDeep); + act.Should().Throw(); } } }