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/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..2912f1ee
--- /dev/null
+++ b/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs
@@ -0,0 +1,57 @@
+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.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.Length; 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.OperandArray[0]] + node.Payload];
+ break;
+ default:
+ var op = node.Op!;
+ 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[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..d0edc488
--- /dev/null
+++ b/packages/cultmath/src/CultMath/Expr/ExprGraph.cs
@@ -0,0 +1,180 @@
+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;
+ 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++)
+ {
+ SlotStart[i] = slots;
+ slots += nodes[i].ResultCount;
+ }
+
+ SlotCount = slots;
+ }
+
+ public IReadOnlyList InputNames { get; }
+
+ public IReadOnlyList ParamNames { get; }
+
+ /// The default of each param as float bits, so a NaN payload or a signed zero survives.
+ public IReadOnlyList ParamDefaultBits { get; }
+
+ public IReadOnlyList OutputNames { get; }
+
+ /// The node each output reads.
+ public IReadOnlyList OutputNodes { get; }
+
+ 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; }
+
+ 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..d0bd7d39
--- /dev/null
+++ b/packages/cultmath/src/CultMath/Expr/ExprNode.cs
@@ -0,0 +1,83 @@
+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;
+ Operands = Array.AsReadOnly(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 { get; }
+
+ /// The evaluator's view of the operands: the private array, never handed out.
+ internal int[] OperandArray => 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/packages/cultmath/tests/CultMath.Tests/ExprTests.cs b/packages/cultmath/tests/CultMath.Tests/ExprTests.cs
new file mode 100644
index 00000000..491e7537
--- /dev/null
+++ b/packages/cultmath/tests/CultMath.Tests/ExprTests.cs
@@ -0,0 +1,611 @@
+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);
+
+ // 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]
+ 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)) + "|" +
+ 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[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();
+ ExprEvaluator.Evaluate(none, ExprBindings.Bind(none), ReadOnlySpan.Empty, Span.Empty);
+ 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]
+ 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);
+ 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]
+ 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/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..6b327e09
--- /dev/null
+++ b/src/GameCult.Caching.MessagePack/CultMathExprResolver.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Buffers;
+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. 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
+{
+ 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)
+ {
+ writer.WriteNil();
+ return;
+ }
+
+ Write(ref writer, value);
+ }
+
+ private static void Write(ref MessagePackWriter writer, ExprGraph value)
+ {
+ 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()) return null;
+ options.Security.DepthStep(ref reader);
+ try
+ {
+ var start = reader.Position;
+ 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()));
+ }
+
+ var graph = ExprGraph.Create(version, inputs, paramNames, paramBits, outputNames, outputNodes, nodes);
+ RequireCanonical(ref reader, start, graph);
+ return graph;
+ }
+ finally
+ {
+ reader.Depth--;
+ }
+ }
+
+ /// 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");
+ }
+
+ private static string ReadName(ref MessagePackReader reader) =>
+ reader.ReadString() ?? throw new ExprGraphException("shape");
+ }
+}
diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md
index ccd8f568..acb1142f 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 nil, as in every reference slot; decoding refuses any encoding but the canonical one.
+
| Type | MessagePack | JSON |
| --- | --- | --- |
| `float2`, `double2`, `int2`, `bool2` | `[x, y]` | `{"x":..,"y":..}` |
diff --git a/tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs b/tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs
new file mode 100644
index 00000000..503fa14f
--- /dev/null
+++ b/tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs
@@ -0,0 +1,266 @@
+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