CultMath.Expr: scalar SSA expression graph, builder with substitution, one validator, evaluator, MessagePack shape

ExprGraph (immutable class, one validator in Create), ExprBuilder (hash-consing, dead-node removal, Substitute), ExprOps (the one op table), ExprEvaluator, ExprBindings, ExprGraphException; CultMathExprResolver appended to the default chain after CultMathResolver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-14 06:05:18 +02:00
commit dc302a96a7
17 changed files with 782 additions and 1 deletions

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a99b96b837a3454781b426cec6bfe837
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,39 @@
namespace CultMath.Expr;
/// <summary>
/// The param values of one graph for an evaluation batch: every param at its default unless overridden by name.
/// </summary>
public sealed class ExprBindings
{
private ExprBindings(ExprGraph graph, float[] values)
{
Graph = graph;
Values = values;
}
public ExprGraph Graph { get; }
/// <summary>One value per param, in the graph's param order.</summary>
internal float[] Values { get; }
/// <summary>
/// Binds <paramref name="graph"/>'s params: a name in <paramref name="overrides"/> replaces the param's default
/// and every other param keeps it. A name the graph has no param for is refused.
/// </summary>
public static ExprBindings Bind(ExprGraph graph, IReadOnlyDictionary<string, float>? 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);
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c9dea1bb6c854e8f80a5e0f05902fd17

View file

@ -0,0 +1,184 @@
namespace CultMath.Expr;
/// <summary>A node of an <see cref="ExprBuilder"/> under construction. Only the builder that made it accepts it.</summary>
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; }
/// <summary>More than one for a multi-result op, whose results are read only through <see cref="ExprBuilder.Pick"/>.</summary>
public int ResultCount { get; }
}
/// <summary>
/// Builds an <see cref="ExprGraph"/>. Identical (op, operands, constant bits) nodes are one node, and
/// <see cref="Build"/> 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.
/// </summary>
public sealed class ExprBuilder
{
private readonly List<ExprNode> nodes = new();
private readonly List<ExprValue> handles = new();
private readonly Dictionary<string, int> interned = new();
private readonly List<string> inputNames = new();
private readonly List<string> paramNames = new();
private readonly List<int> paramDefaultBits = new();
private readonly List<string> outputNames = new();
private readonly List<int> outputNodes = new();
/// <summary>The graph input of this name, declared on first use.</summary>
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));
}
/// <summary>The graph param of this name, declared on first use. Declaring it again with other default bits is refused.</summary>
public ExprValue Param(string name, float defaultValue) => ParamBits(name, BitConverter.SingleToInt32Bits(defaultValue));
/// <summary>A constant; its bits (a signed zero, a NaN payload, a subnormal) are kept.</summary>
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));
}
/// <summary>Result <paramref name="k"/> of a multi-result node.</summary>
public ExprValue Pick(ExprValue node, int k) => Intern(ExprNode.Pick(Own(node), k));
/// <summary>Names a single-result node as an output.</summary>
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);
}
/// <summary>The graph of the outputs declared so far: only nodes an output reaches, renumbered densely in build order.</summary>
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<ExprNode>();
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);
}
/// <summary>
/// Rebuilds <paramref name="graph"/> 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.
/// </summary>
public IReadOnlyDictionary<string, ExprValue> Substitute(ExprGraph graph, IReadOnlyDictionary<string, ExprValue> 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<string, ExprValue>();
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];
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7326eeabc2d04a43b3d0a1fe6e45b018

View file

@ -0,0 +1,56 @@
namespace CultMath.Expr;
/// <summary>
/// Evaluates an <see cref="ExprGraph"/> per point: nodes in order through <see cref="ExprOps"/>, 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.
/// </summary>
public static class ExprEvaluator
{
/// <summary>
/// Columns are contiguous: input <c>i</c> of point <c>p</c> is <c>inputs[i * n + p]</c>, and output <c>o</c> is
/// <c>outputs[o * n + p]</c>, where n = <c>outputs.Length / graph.OutputNames.Count</c>.
/// </summary>
public static void Evaluate(ExprGraph graph, ExprBindings bindings, ReadOnlySpan<float> inputs, Span<float> 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<float> 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<float>(slots, start[i], op.ResultCount));
break;
}
}
for (var o = 0; o < outputCount; o++) outputs[o * points + point] = slots[start[graph.OutputNodes[o]]];
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 38108254b5b4430b8f87094e6905dc59

View file

@ -0,0 +1,168 @@
namespace CultMath.Expr;
/// <summary>
/// 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. <see cref="Create"/> is the one validator: the builder runs
/// it on <see cref="ExprBuilder.Build"/> and a decoder runs it on persisted bytes, so no partial graph is ever returned.
/// </summary>
public sealed class ExprGraph
{
/// <summary>The persisted format version this build reads and writes.</summary>
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<string> InputNames => inputNames;
public IReadOnlyList<string> ParamNames => paramNames;
/// <summary>The default of each param as float bits, so a NaN payload or a signed zero survives.</summary>
public IReadOnlyList<int> ParamDefaultBits => paramDefaultBits;
public IReadOnlyList<string> OutputNames => outputNames;
/// <summary>The node each output reads.</summary>
public IReadOnlyList<int> OutputNodes => outputNodes;
public IReadOnlyList<ExprNode> Nodes => nodes;
/// <summary>Where each node's results start in an evaluator's value array.</summary>
internal int[] SlotStart { get; }
internal int SlotCount { get; }
/// <summary>The index of the param of this name, or -1.</summary>
internal int ParamIndex(string name) => Array.IndexOf(paramNames, name);
/// <summary>
/// Validates and returns a graph, or throws <see cref="ExprGraphException"/> 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.
/// </summary>
public static ExprGraph Create(
int version,
IReadOnlyList<string> inputNames,
IReadOnlyList<string> paramNames,
IReadOnlyList<int> paramDefaultBits,
IReadOnlyList<string> outputNames,
IReadOnlyList<int> outputNodes,
IReadOnlyList<ExprNode> 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<ExprNode>(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());
}
/// <summary>A port name is <c>[A-Za-z_][A-Za-z0-9_]*</c>.</summary>
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<string> names)
{
var seen = new HashSet<string>();
foreach (var name in names)
{
CheckPortName(name);
if (!seen.Add(name)) throw new ExprGraphException("duplicate-port-name");
}
}
/// <summary>The rules for one node given the nodes before it. The builder runs this per call, the validator per node.</summary>
internal static void CheckNode(int index, ExprNode node, IReadOnlyList<ExprNode> 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);
}
/// <summary>An output reads a node in range that is single-result.</summary>
internal static void CheckOutput(int node, IReadOnlyList<ExprNode> nodes)
{
if (node < 0 || node >= nodes.Count) throw new ExprGraphException("output-node");
if (nodes[node].ResultCount != 1) throw new ExprGraphException("bare-multi-result", node);
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 04d707ad2a4e4e0299407ce1d86afea3

View file

@ -0,0 +1,22 @@
namespace CultMath.Expr;
/// <summary>
/// The one refusal of the expression graph: a build call, a decoded encoding, a binding or an evaluation
/// that breaks a rule. <see cref="Rule"/> names the rule and <see cref="NodeIndex"/> the node it broke at
/// (-1 when no node is involved). The message names the rule and never echoes a value it was given.
/// </summary>
public sealed class ExprGraphException : Exception
{
public ExprGraphException(string rule, int nodeIndex = -1)
: base(nodeIndex < 0 ? rule : rule + " at node " + nodeIndex)
{
Rule = rule;
NodeIndex = nodeIndex;
}
/// <summary>The rule broken, a short stable name such as <c>unknown-op</c> or <c>operand-order</c>.</summary>
public string Rule { get; }
/// <summary>The index of the node the rule broke at, or -1.</summary>
public int NodeIndex { get; }
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 88e355a7be3f44ada7f1d9464c60428e

View file

@ -0,0 +1,79 @@
namespace CultMath.Expr;
/// <summary>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.</summary>
public enum ExprNodeKind
{
Input,
Param,
Const,
Pick,
Call,
}
/// <summary>
/// One node of an <see cref="ExprGraph"/>: 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.
/// </summary>
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<int>();
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; }
/// <summary>The persisted name: an op name for a call, otherwise <c>input</c>, <c>param</c>, <c>const</c> or <c>pick</c>.</summary>
public string WireName { get; }
public IReadOnlyList<int> Operands => operandArray;
public int Payload { get; }
/// <summary>How many floats the node produces: the op's result count for a call, otherwise one.</summary>
public int ResultCount { get; }
/// <summary>The op of a known call; null for every other node and for a call naming an unknown op.</summary>
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<int> operands) =>
new(ExprNodeKind.Call, op, operands.ToArray(), 0, ExprOps.Find(op));
/// <summary>The node a persisted <paramref name="wireName"/> names. A name that is none of the four kinds is a call, which the validator checks against the op table.</summary>
public static ExprNode FromWire(string wireName, IReadOnlyList<int> 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);
/// <summary>Equal keys are the same expression: the builder's hash-consing identity.</summary>
internal string Key => WireName + "|" + string.Join(",", operandArray) + "|" + Payload;
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fc3cb4abaa27482aa98a76cd876b8805

View file

@ -0,0 +1,86 @@
using static CultMath.math;
namespace CultMath.Expr;
/// <summary>One entry of the op table: a stable name, its arity, its result count and its C# implementation.</summary>
public sealed class ExprOp
{
/// <summary>Writes <paramref name="results"/> (<see cref="ResultCount"/> floats) from <paramref name="operands"/>
/// (<see cref="Arity"/> floats).</summary>
public delegate void Function(ReadOnlySpan<float> operands, Span<float> results);
private readonly Function function;
internal ExprOp(string name, int arity, int resultCount, Function function)
{
Name = name;
Arity = arity;
ResultCount = resultCount;
this.function = function;
}
/// <summary>The persisted name. Append-only: a released name keeps its meaning.</summary>
public string Name { get; }
public int Arity { get; }
/// <summary>One for a scalar op; more for an op read only through <see cref="ExprBuilder.Pick"/>.</summary>
public int ResultCount { get; }
public void Evaluate(ReadOnlySpan<float> operands, Span<float> results) => function(operands, results);
}
/// <summary>
/// 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.
/// </summary>
public static class ExprOps
{
/// <summary>The largest arity in the table.</summary>
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<string, ExprOp> ByName = Table.ToDictionary(op => op.Name);
/// <summary>Every op, in declaration order.</summary>
public static IReadOnlyList<ExprOp> All => Table;
/// <summary>The op with this persisted name, or null.</summary>
public static ExprOp? Find(string name) => ByName.TryGetValue(name, out var op) ? op : null;
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f2cb6ebc7a4f43d59009e613a1b75888

View file

@ -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);

View file

@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using CultMath.Expr;
using MessagePack;
using MessagePack.Formatters;
namespace GameCult.Caching.MessagePack;
/// <summary>
/// The canonical MessagePack encoding of <see cref="ExprGraph"/>, beside <see cref="CultMathResolver"/> in the
/// default chain. A graph is <c>[version, inputs, params, outputs, nodes]</c>: <c>inputs</c> are names; a param is
/// <c>[name, default float bits]</c>; an output is <c>[name, node]</c>; a node is <c>[wire name, operands, payload]</c>
/// (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 <see cref="ExprGraph.Create"/>, the one validator, so a graph that breaks a rule
/// (unknown op, newer version, forward operand) is refused whole with an <see cref="ExprGraphException"/> and never
/// partly loaded. A nil graph is refused both ways.
/// </summary>
public sealed class CultMathExprResolver : IFormatterResolver
{
public static readonly CultMathExprResolver Instance = new();
private CultMathExprResolver() { }
public IMessagePackFormatter<T>? GetFormatter<T>() => Cache<T>.Formatter;
private static class Cache<T>
{
public static readonly IMessagePackFormatter<T>? Formatter =
typeof(T) == typeof(ExprGraph) ? (IMessagePackFormatter<T>)(object)new GraphFormatter() : null;
}
private sealed class GraphFormatter : IMessagePackFormatter<ExprGraph>
{
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<string>();
for (int i = 0, n = reader.ReadArrayHeader(); i < n; i++) inputs.Add(ReadName(ref reader));
var paramNames = new List<string>();
var paramBits = new List<int>();
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<string>();
var outputNodes = new List<int>();
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<ExprNode>();
for (int i = 0, n = reader.ReadArrayHeader(); i < n; i++)
{
Expect(ref reader, 3);
var wireName = ReadName(ref reader);
var operands = new List<int>();
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");
}
}