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");
+ }
+}