From dc302a96a7d0a61ab6289e37f8be1b8b6ee00dfc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 06:05:18 +0200 Subject: [PATCH] CultMath.Expr: scalar SSA expression graph, builder with substitution, one validator, evaluator, MessagePack shape ExprGraph (immutable class, one validator in Create), ExprBuilder (hash-consing, dead-node removal, Substitute), ExprOps (the one op table), ExprEvaluator, ExprBindings, ExprGraphException; CultMathExprResolver appended to the default chain after CultMathResolver. Co-Authored-By: Claude Opus 5.5 --- packages/cultmath/src/CultMath/Expr.meta | 8 + .../src/CultMath/Expr/ExprBindings.cs | 39 ++++ .../src/CultMath/Expr/ExprBindings.cs.meta | 2 + .../cultmath/src/CultMath/Expr/ExprBuilder.cs | 184 ++++++++++++++++++ .../src/CultMath/Expr/ExprBuilder.cs.meta | 2 + .../src/CultMath/Expr/ExprEvaluator.cs | 56 ++++++ .../src/CultMath/Expr/ExprEvaluator.cs.meta | 2 + .../cultmath/src/CultMath/Expr/ExprGraph.cs | 168 ++++++++++++++++ .../src/CultMath/Expr/ExprGraph.cs.meta | 2 + .../src/CultMath/Expr/ExprGraphException.cs | 22 +++ .../CultMath/Expr/ExprGraphException.cs.meta | 2 + .../cultmath/src/CultMath/Expr/ExprNode.cs | 79 ++++++++ .../src/CultMath/Expr/ExprNode.cs.meta | 2 + .../cultmath/src/CultMath/Expr/ExprOps.cs | 86 ++++++++ .../src/CultMath/Expr/ExprOps.cs.meta | 2 + .../CultDocumentMessagePackSerialization.cs | 2 +- .../CultMathExprResolver.cs | 125 ++++++++++++ 17 files changed, 782 insertions(+), 1 deletion(-) create mode 100644 packages/cultmath/src/CultMath/Expr.meta create mode 100644 packages/cultmath/src/CultMath/Expr/ExprBindings.cs create mode 100644 packages/cultmath/src/CultMath/Expr/ExprBindings.cs.meta create mode 100644 packages/cultmath/src/CultMath/Expr/ExprBuilder.cs create mode 100644 packages/cultmath/src/CultMath/Expr/ExprBuilder.cs.meta create mode 100644 packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs create mode 100644 packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs.meta create mode 100644 packages/cultmath/src/CultMath/Expr/ExprGraph.cs create mode 100644 packages/cultmath/src/CultMath/Expr/ExprGraph.cs.meta create mode 100644 packages/cultmath/src/CultMath/Expr/ExprGraphException.cs create mode 100644 packages/cultmath/src/CultMath/Expr/ExprGraphException.cs.meta create mode 100644 packages/cultmath/src/CultMath/Expr/ExprNode.cs create mode 100644 packages/cultmath/src/CultMath/Expr/ExprNode.cs.meta create mode 100644 packages/cultmath/src/CultMath/Expr/ExprOps.cs create mode 100644 packages/cultmath/src/CultMath/Expr/ExprOps.cs.meta create mode 100644 src/GameCult.Caching.MessagePack/CultMathExprResolver.cs diff --git a/packages/cultmath/src/CultMath/Expr.meta b/packages/cultmath/src/CultMath/Expr.meta new file mode 100644 index 00000000..6bab6f53 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a99b96b837a3454781b426cec6bfe837 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/cultmath/src/CultMath/Expr/ExprBindings.cs b/packages/cultmath/src/CultMath/Expr/ExprBindings.cs new file mode 100644 index 00000000..0382e026 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprBindings.cs @@ -0,0 +1,39 @@ +namespace CultMath.Expr; + +/// +/// The param values of one graph for an evaluation batch: every param at its default unless overridden by name. +/// +public sealed class ExprBindings +{ + private ExprBindings(ExprGraph graph, float[] values) + { + Graph = graph; + Values = values; + } + + public ExprGraph Graph { get; } + + /// One value per param, in the graph's param order. + internal float[] Values { get; } + + /// + /// Binds 's params: a name in replaces the param's default + /// and every other param keeps it. A name the graph has no param for is refused. + /// + public static ExprBindings Bind(ExprGraph graph, IReadOnlyDictionary? overrides = null) + { + var values = new float[graph.ParamNames.Count]; + for (var i = 0; i < values.Length; i++) values[i] = BitConverter.Int32BitsToSingle(graph.ParamDefaultBits[i]); + if (overrides is not null) + { + foreach (var pair in overrides) + { + var index = graph.ParamIndex(pair.Key); + if (index < 0) throw new ExprGraphException("unknown-param"); + values[index] = pair.Value; + } + } + + return new ExprBindings(graph, values); + } +} diff --git a/packages/cultmath/src/CultMath/Expr/ExprBindings.cs.meta b/packages/cultmath/src/CultMath/Expr/ExprBindings.cs.meta new file mode 100644 index 00000000..f306a3b1 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprBindings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c9dea1bb6c854e8f80a5e0f05902fd17 diff --git a/packages/cultmath/src/CultMath/Expr/ExprBuilder.cs b/packages/cultmath/src/CultMath/Expr/ExprBuilder.cs new file mode 100644 index 00000000..7ffca144 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprBuilder.cs @@ -0,0 +1,184 @@ +namespace CultMath.Expr; + +/// A node of an under construction. Only the builder that made it accepts it. +public sealed class ExprValue +{ + internal ExprValue(ExprBuilder owner, int index, int resultCount) + { + Owner = owner; + Index = index; + ResultCount = resultCount; + } + + internal ExprBuilder Owner { get; } + + internal int Index { get; } + + /// More than one for a multi-result op, whose results are read only through . + public int ResultCount { get; } +} + +/// +/// Builds an . Identical (op, operands, constant bits) nodes are one node, and +/// drops nodes no output reaches and renumbers densely, so the same call sequence builds +/// byte-identical graphs. Every call runs the validator's rules and refuses at the call. +/// +public sealed class ExprBuilder +{ + private readonly List nodes = new(); + private readonly List handles = new(); + private readonly Dictionary interned = new(); + private readonly List inputNames = new(); + private readonly List paramNames = new(); + private readonly List paramDefaultBits = new(); + private readonly List outputNames = new(); + private readonly List outputNodes = new(); + + /// The graph input of this name, declared on first use. + public ExprValue Input(string name) + { + var port = inputNames.IndexOf(name); + if (port < 0) + { + ExprGraph.CheckPortName(name); + port = inputNames.Count; + inputNames.Add(name); + } + + return Intern(ExprNode.Input(port)); + } + + /// The graph param of this name, declared on first use. Declaring it again with other default bits is refused. + public ExprValue Param(string name, float defaultValue) => ParamBits(name, BitConverter.SingleToInt32Bits(defaultValue)); + + /// A constant; its bits (a signed zero, a NaN payload, a subnormal) are kept. + public ExprValue Const(float value) => ConstBits(BitConverter.SingleToInt32Bits(value)); + + public ExprValue Call(string op, params ExprValue[] operands) + { + var indices = new int[operands.Length]; + for (var i = 0; i < operands.Length; i++) indices[i] = Own(operands[i]); + return Intern(ExprNode.Call(op, indices)); + } + + /// Result of a multi-result node. + public ExprValue Pick(ExprValue node, int k) => Intern(ExprNode.Pick(Own(node), k)); + + /// Names a single-result node as an output. + public void Output(string name, ExprValue value) + { + ExprGraph.CheckPortName(name); + if (outputNames.Contains(name)) throw new ExprGraphException("duplicate-port-name"); + var node = Own(value); + ExprGraph.CheckOutput(node, nodes); + outputNames.Add(name); + outputNodes.Add(node); + } + + /// The graph of the outputs declared so far: only nodes an output reaches, renumbered densely in build order. + public ExprGraph Build() + { + var live = new bool[nodes.Count]; + foreach (var node in outputNodes) live[node] = true; + for (var i = nodes.Count - 1; i >= 0; i--) + if (live[i]) + foreach (var operand in nodes[i].Operands) live[operand] = true; + + var renumbered = new int[nodes.Count]; + var kept = new List(); + for (var i = 0; i < nodes.Count; i++) + { + if (!live[i]) continue; + renumbered[i] = kept.Count; + kept.Add(nodes[i].WithOperands(nodes[i].Operands.Select(operand => renumbered[operand]).ToArray())); + } + + return ExprGraph.Create( + ExprGraph.FormatVersion, inputNames, paramNames, paramDefaultBits, outputNames, outputNodes.Select(node => renumbered[node]).ToList(), kept); + } + + /// + /// Rebuilds inside this builder with the named inputs replaced by the given values + /// (Fidget's remap_xyz) and returns each output's value by name. An input not named is this builder's input + /// of that name; every param is this builder's param of that name. A stencil is N substitutions combined by + /// ops: an ordinary flat graph, with no loop construct. + /// + public IReadOnlyDictionary Substitute(ExprGraph graph, IReadOnlyDictionary replacements) + { + foreach (var pair in replacements) + { + if (!graph.InputNames.Contains(pair.Key)) throw new ExprGraphException("unknown-input"); + Own(pair.Value); + if (pair.Value.ResultCount != 1) throw new ExprGraphException("bare-multi-result"); + } + + var mapped = new ExprValue[graph.Nodes.Count]; + for (var i = 0; i < mapped.Length; i++) + { + var node = graph.Nodes[i]; + switch (node.Kind) + { + case ExprNodeKind.Input: + var input = graph.InputNames[node.Payload]; + mapped[i] = replacements.TryGetValue(input, out var replacement) ? replacement : Input(input); + break; + case ExprNodeKind.Param: + mapped[i] = ParamBits(graph.ParamNames[node.Payload], graph.ParamDefaultBits[node.Payload]); + break; + case ExprNodeKind.Const: + mapped[i] = ConstBits(node.Payload); + break; + case ExprNodeKind.Pick: + mapped[i] = Pick(mapped[node.Operands[0]], node.Payload); + break; + default: + mapped[i] = Call(node.WireName, node.Operands.Select(operand => mapped[operand]).ToArray()); + break; + } + } + + var outputs = new Dictionary(); + for (var o = 0; o < graph.OutputNames.Count; o++) outputs[graph.OutputNames[o]] = mapped[graph.OutputNodes[o]]; + return outputs; + } + + private ExprValue ParamBits(string name, int bits) + { + var port = paramNames.IndexOf(name); + if (port < 0) + { + ExprGraph.CheckPortName(name); + port = paramNames.Count; + paramNames.Add(name); + paramDefaultBits.Add(bits); + } + else if (paramDefaultBits[port] != bits) + { + throw new ExprGraphException("port-conflict"); + } + + return Intern(ExprNode.Param(port)); + } + + private ExprValue ConstBits(int bits) => Intern(ExprNode.Const(bits)); + + private int Own(ExprValue value) + { + if (value.Owner != this) throw new ExprGraphException("foreign-operand", nodes.Count); + return value.Index; + } + + private ExprValue Intern(ExprNode node) + { + ExprGraph.CheckNode(nodes.Count, node, nodes, inputNames.Count, paramNames.Count); + if (!interned.TryGetValue(node.Key, out var index)) + { + index = nodes.Count; + nodes.Add(node); + handles.Add(new ExprValue(this, index, node.ResultCount)); + interned[node.Key] = index; + } + + return handles[index]; + } +} diff --git a/packages/cultmath/src/CultMath/Expr/ExprBuilder.cs.meta b/packages/cultmath/src/CultMath/Expr/ExprBuilder.cs.meta new file mode 100644 index 00000000..a65eb534 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7326eeabc2d04a43b3d0a1fe6e45b018 diff --git a/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs b/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs new file mode 100644 index 00000000..3a7496af --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs @@ -0,0 +1,56 @@ +namespace CultMath.Expr; + +/// +/// Evaluates an per point: nodes in order through , IEEE single per op, +/// so a result equals the direct composition of the same math calls bit for bit. Allocates once per call, never per point. +/// +public static class ExprEvaluator +{ + /// + /// Columns are contiguous: input i of point p is inputs[i * n + p], and output o is + /// outputs[o * n + p], where n = outputs.Length / graph.OutputNames.Count. + /// + public static void Evaluate(ExprGraph graph, ExprBindings bindings, ReadOnlySpan inputs, Span outputs) + { + if (bindings.Graph != graph) throw new ExprGraphException("foreign-bindings"); + var outputCount = graph.OutputNames.Count; + if (outputCount == 0) return; + if (outputs.Length % outputCount != 0) throw new ExprGraphException("column-shape"); + var points = outputs.Length / outputCount; + if (inputs.Length != points * graph.InputNames.Count) throw new ExprGraphException("column-shape"); + + var nodes = graph.Nodes; + var start = graph.SlotStart; + var slots = new float[graph.SlotCount]; + Span operands = stackalloc float[ExprOps.MaxArity]; + for (var point = 0; point < points; point++) + { + for (var i = 0; i < nodes.Count; i++) + { + var node = nodes[i]; + switch (node.Kind) + { + case ExprNodeKind.Input: + slots[start[i]] = inputs[node.Payload * points + point]; + break; + case ExprNodeKind.Param: + slots[start[i]] = bindings.Values[node.Payload]; + break; + case ExprNodeKind.Const: + slots[start[i]] = BitConverter.Int32BitsToSingle(node.Payload); + break; + case ExprNodeKind.Pick: + slots[start[i]] = slots[start[node.Operands[0]] + node.Payload]; + break; + default: + var op = node.Op!; + for (var k = 0; k < op.Arity; k++) operands[k] = slots[start[node.Operands[k]]]; + op.Evaluate(operands.Slice(0, op.Arity), new Span(slots, start[i], op.ResultCount)); + break; + } + } + + for (var o = 0; o < outputCount; o++) outputs[o * points + point] = slots[start[graph.OutputNodes[o]]]; + } + } +} diff --git a/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs.meta b/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs.meta new file mode 100644 index 00000000..f89bacf4 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 38108254b5b4430b8f87094e6905dc59 diff --git a/packages/cultmath/src/CultMath/Expr/ExprGraph.cs b/packages/cultmath/src/CultMath/Expr/ExprGraph.cs new file mode 100644 index 00000000..a75bb941 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprGraph.cs @@ -0,0 +1,168 @@ +namespace CultMath.Expr; + +/// +/// An immutable scalar SSA expression graph: nodes in topological order (an operand names a lower node), named +/// inputs that vary per point, named params that are uniform per batch and carry a default, and named outputs. +/// A graph is a value; an edit builds a new one. is the one validator: the builder runs +/// it on and a decoder runs it on persisted bytes, so no partial graph is ever returned. +/// +public sealed class ExprGraph +{ + /// The persisted format version this build reads and writes. + public const int FormatVersion = 1; + + private readonly string[] inputNames; + private readonly string[] paramNames; + private readonly int[] paramDefaultBits; + private readonly string[] outputNames; + private readonly int[] outputNodes; + private readonly ExprNode[] nodes; + + private ExprGraph(string[] inputNames, string[] paramNames, int[] paramDefaultBits, string[] outputNames, int[] outputNodes, ExprNode[] nodes) + { + this.inputNames = inputNames; + this.paramNames = paramNames; + this.paramDefaultBits = paramDefaultBits; + this.outputNames = outputNames; + this.outputNodes = outputNodes; + this.nodes = nodes; + SlotStart = new int[nodes.Length]; + var slots = 0; + for (var i = 0; i < nodes.Length; i++) + { + SlotStart[i] = slots; + slots += nodes[i].ResultCount; + } + + SlotCount = slots; + } + + public IReadOnlyList InputNames => inputNames; + + public IReadOnlyList ParamNames => paramNames; + + /// The default of each param as float bits, so a NaN payload or a signed zero survives. + public IReadOnlyList ParamDefaultBits => paramDefaultBits; + + public IReadOnlyList OutputNames => outputNames; + + /// The node each output reads. + public IReadOnlyList OutputNodes => outputNodes; + + public IReadOnlyList Nodes => nodes; + + /// Where each node's results start in an evaluator's value array. + internal int[] SlotStart { get; } + + internal int SlotCount { get; } + + /// The index of the param of this name, or -1. + internal int ParamIndex(string name) => Array.IndexOf(paramNames, name); + + /// + /// Validates and returns a graph, or throws naming the rule and the node. + /// The rules: format version, port counts, port names (syntax, unique per kind), and per node its kind's + /// operand count, a known op and its arity, operands below the node and never a bare multi-result node, + /// pick range, port index and payload; and per output a node in range that is not a bare multi-result node. + /// + public static ExprGraph Create( + int version, + IReadOnlyList inputNames, + IReadOnlyList paramNames, + IReadOnlyList paramDefaultBits, + IReadOnlyList outputNames, + IReadOnlyList outputNodes, + IReadOnlyList nodes) + { + if (version != FormatVersion) throw new ExprGraphException("version"); + if (paramNames.Count != paramDefaultBits.Count || outputNames.Count != outputNodes.Count) throw new ExprGraphException("port-count"); + CheckPortNames(inputNames); + CheckPortNames(paramNames); + CheckPortNames(outputNames); + + var before = new List(nodes.Count); + for (var i = 0; i < nodes.Count; i++) + { + CheckNode(i, nodes[i], before, inputNames.Count, paramNames.Count); + before.Add(nodes[i]); + } + + foreach (var node in outputNodes) CheckOutput(node, before); + + return new ExprGraph( + inputNames.ToArray(), paramNames.ToArray(), paramDefaultBits.ToArray(), outputNames.ToArray(), outputNodes.ToArray(), nodes.ToArray()); + } + + /// A port name is [A-Za-z_][A-Za-z0-9_]*. + internal static void CheckPortName(string name) + { + if (name.Length == 0 || !IsHead(name[0])) throw new ExprGraphException("port-name"); + for (var i = 1; i < name.Length; i++) + if (!IsHead(name[i]) && !(name[i] >= '0' && name[i] <= '9')) throw new ExprGraphException("port-name"); + } + + private static bool IsHead(char c) => c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + + private static void CheckPortNames(IReadOnlyList names) + { + var seen = new HashSet(); + foreach (var name in names) + { + CheckPortName(name); + if (!seen.Add(name)) throw new ExprGraphException("duplicate-port-name"); + } + } + + /// The rules for one node given the nodes before it. The builder runs this per call, the validator per node. + internal static void CheckNode(int index, ExprNode node, IReadOnlyList before, int inputCount, int paramCount) + { + var operands = node.Operands; + switch (node.Kind) + { + case ExprNodeKind.Input: + CheckLeaf(index, node, inputCount); + break; + case ExprNodeKind.Param: + CheckLeaf(index, node, paramCount); + break; + case ExprNodeKind.Const: + if (operands.Count != 0) throw new ExprGraphException("arity", index); + break; + case ExprNodeKind.Pick: + if (operands.Count != 1) throw new ExprGraphException("arity", index); + CheckOperandOrder(index, operands[0]); + var results = before[operands[0]].ResultCount; + if (results <= 1 || node.Payload < 0 || node.Payload >= results) throw new ExprGraphException("pick-range", index); + break; + default: + if (node.Op is null) throw new ExprGraphException("unknown-op", index); + if (operands.Count != node.Op.Arity) throw new ExprGraphException("arity", index); + if (node.Payload != 0) throw new ExprGraphException("payload", index); + foreach (var operand in operands) + { + CheckOperandOrder(index, operand); + if (before[operand].ResultCount != 1) throw new ExprGraphException("bare-multi-result", index); + } + + break; + } + } + + private static void CheckLeaf(int index, ExprNode node, int portCount) + { + if (node.Operands.Count != 0) throw new ExprGraphException("arity", index); + if (node.Payload < 0 || node.Payload >= portCount) throw new ExprGraphException("port-index", index); + } + + private static void CheckOperandOrder(int index, int operand) + { + if (operand < 0 || operand >= index) throw new ExprGraphException("operand-order", index); + } + + /// An output reads a node in range that is single-result. + internal static void CheckOutput(int node, IReadOnlyList nodes) + { + if (node < 0 || node >= nodes.Count) throw new ExprGraphException("output-node"); + if (nodes[node].ResultCount != 1) throw new ExprGraphException("bare-multi-result", node); + } +} diff --git a/packages/cultmath/src/CultMath/Expr/ExprGraph.cs.meta b/packages/cultmath/src/CultMath/Expr/ExprGraph.cs.meta new file mode 100644 index 00000000..d6d57122 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprGraph.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 04d707ad2a4e4e0299407ce1d86afea3 diff --git a/packages/cultmath/src/CultMath/Expr/ExprGraphException.cs b/packages/cultmath/src/CultMath/Expr/ExprGraphException.cs new file mode 100644 index 00000000..7d10a002 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprGraphException.cs @@ -0,0 +1,22 @@ +namespace CultMath.Expr; + +/// +/// The one refusal of the expression graph: a build call, a decoded encoding, a binding or an evaluation +/// that breaks a rule. names the rule and the node it broke at +/// (-1 when no node is involved). The message names the rule and never echoes a value it was given. +/// +public sealed class ExprGraphException : Exception +{ + public ExprGraphException(string rule, int nodeIndex = -1) + : base(nodeIndex < 0 ? rule : rule + " at node " + nodeIndex) + { + Rule = rule; + NodeIndex = nodeIndex; + } + + /// The rule broken, a short stable name such as unknown-op or operand-order. + public string Rule { get; } + + /// The index of the node the rule broke at, or -1. + public int NodeIndex { get; } +} diff --git a/packages/cultmath/src/CultMath/Expr/ExprGraphException.cs.meta b/packages/cultmath/src/CultMath/Expr/ExprGraphException.cs.meta new file mode 100644 index 00000000..02d5feca --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprGraphException.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 88e355a7be3f44ada7f1d9464c60428e diff --git a/packages/cultmath/src/CultMath/Expr/ExprNode.cs b/packages/cultmath/src/CultMath/Expr/ExprNode.cs new file mode 100644 index 00000000..719eac66 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprNode.cs @@ -0,0 +1,79 @@ +namespace CultMath.Expr; + +/// What a node is: a graph input, a graph param, a constant, a pick of one result of a multi-result node, or a call of an op. +public enum ExprNodeKind +{ + Input, + Param, + Const, + Pick, + Call, +} + +/// +/// One node of an : a kind, the operand node indices (always lower than its own), +/// and one integer payload: the port index of an input or param, the float bits of a constant, the result +/// index of a pick, and zero for a call. Immutable. +/// +public sealed class ExprNode +{ + private const string InputName = "input"; + private const string ParamName = "param"; + private const string ConstName = "const"; + private const string PickName = "pick"; + + private static readonly int[] NoOperands = Array.Empty(); + + private readonly int[] operandArray; + + private ExprNode(ExprNodeKind kind, string wireName, int[] operands, int payload, ExprOp? op) + { + Kind = kind; + WireName = wireName; + operandArray = operands; + Payload = payload; + Op = op; + ResultCount = op?.ResultCount ?? 1; + } + + public ExprNodeKind Kind { get; } + + /// The persisted name: an op name for a call, otherwise input, param, const or pick. + public string WireName { get; } + + public IReadOnlyList Operands => operandArray; + + public int Payload { get; } + + /// How many floats the node produces: the op's result count for a call, otherwise one. + public int ResultCount { get; } + + /// The op of a known call; null for every other node and for a call naming an unknown op. + internal ExprOp? Op { get; } + + public static ExprNode Input(int port) => new(ExprNodeKind.Input, InputName, NoOperands, port, null); + + public static ExprNode Param(int port) => new(ExprNodeKind.Param, ParamName, NoOperands, port, null); + + public static ExprNode Const(int bits) => new(ExprNodeKind.Const, ConstName, NoOperands, bits, null); + + public static ExprNode Pick(int node, int result) => new(ExprNodeKind.Pick, PickName, new[] { node }, result, null); + + public static ExprNode Call(string op, IReadOnlyList operands) => + new(ExprNodeKind.Call, op, operands.ToArray(), 0, ExprOps.Find(op)); + + /// The node a persisted names. A name that is none of the four kinds is a call, which the validator checks against the op table. + public static ExprNode FromWire(string wireName, IReadOnlyList operands, int payload) => wireName switch + { + InputName => new ExprNode(ExprNodeKind.Input, wireName, operands.ToArray(), payload, null), + ParamName => new ExprNode(ExprNodeKind.Param, wireName, operands.ToArray(), payload, null), + ConstName => new ExprNode(ExprNodeKind.Const, wireName, operands.ToArray(), payload, null), + PickName => new ExprNode(ExprNodeKind.Pick, wireName, operands.ToArray(), payload, null), + _ => new ExprNode(ExprNodeKind.Call, wireName, operands.ToArray(), payload, ExprOps.Find(wireName)), + }; + + internal ExprNode WithOperands(int[] operands) => new(Kind, WireName, operands, Payload, Op); + + /// Equal keys are the same expression: the builder's hash-consing identity. + internal string Key => WireName + "|" + string.Join(",", operandArray) + "|" + Payload; +} diff --git a/packages/cultmath/src/CultMath/Expr/ExprNode.cs.meta b/packages/cultmath/src/CultMath/Expr/ExprNode.cs.meta new file mode 100644 index 00000000..99f5388e --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprNode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc3cb4abaa27482aa98a76cd876b8805 diff --git a/packages/cultmath/src/CultMath/Expr/ExprOps.cs b/packages/cultmath/src/CultMath/Expr/ExprOps.cs new file mode 100644 index 00000000..f8a63d9c --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprOps.cs @@ -0,0 +1,86 @@ +using static CultMath.math; + +namespace CultMath.Expr; + +/// One entry of the op table: a stable name, its arity, its result count and its C# implementation. +public sealed class ExprOp +{ + /// Writes ( floats) from + /// ( floats). + public delegate void Function(ReadOnlySpan operands, Span results); + + private readonly Function function; + + internal ExprOp(string name, int arity, int resultCount, Function function) + { + Name = name; + Arity = arity; + ResultCount = resultCount; + this.function = function; + } + + /// The persisted name. Append-only: a released name keeps its meaning. + public string Name { get; } + + public int Arity { get; } + + /// One for a scalar op; more for an op read only through . + public int ResultCount { get; } + + public void Evaluate(ReadOnlySpan operands, Span results) => function(operands, results); +} + +/// +/// The one op table of the expression graph: each op names a CultMath function and calls it, wrapping no type +/// and copying no body. The C# evaluator reads it, and so does any later lowering. +/// +public static class ExprOps +{ + /// The largest arity in the table. + internal const int MaxArity = 4; + + private static ExprOp Op(string name, int arity, ExprOp.Function function) => new(name, arity, 1, function); + + private static readonly ExprOp[] Table = + { + Op("add", 2, (a, r) => r[0] = a[0] + a[1]), + Op("sub", 2, (a, r) => r[0] = a[0] - a[1]), + Op("mul", 2, (a, r) => r[0] = a[0] * a[1]), + Op("div", 2, (a, r) => r[0] = a[0] / a[1]), + Op("neg", 1, (a, r) => r[0] = -a[0]), + Op("min", 2, (a, r) => r[0] = min(a[0], a[1])), + Op("max", 2, (a, r) => r[0] = max(a[0], a[1])), + Op("abs", 1, (a, r) => r[0] = abs(a[0])), + Op("floor", 1, (a, r) => r[0] = floor(a[0])), + Op("frac", 1, (a, r) => r[0] = frac(a[0])), + Op("sqrt", 1, (a, r) => r[0] = sqrt(a[0])), + Op("exp", 1, (a, r) => r[0] = exp(a[0])), + Op("log", 1, (a, r) => r[0] = log(a[0])), + Op("pow", 2, (a, r) => r[0] = pow(a[0], a[1])), + Op("sin", 1, (a, r) => r[0] = sin(a[0])), + Op("cos", 1, (a, r) => r[0] = cos(a[0])), + Op("clamp", 3, (a, r) => r[0] = clamp(a[0], a[1], a[2])), + Op("saturate", 1, (a, r) => r[0] = saturate(a[0])), + Op("lerp", 3, (a, r) => r[0] = lerp(a[0], a[1], a[2])), + Op("step", 2, (a, r) => r[0] = step(a[0], a[1])), + Op("smoothstep", 3, (a, r) => r[0] = smoothstep(a[0], a[1], a[2])), + Op("select", 4, (a, r) => r[0] = select(a[0] > a[1], a[2], a[3])), + Op("snoise2", 2, (a, r) => r[0] = snoise(new float2(a[0], a[1]))), + Op("snoise3", 3, (a, r) => r[0] = snoise(new float3(a[0], a[1], a[2]))), + new ExprOp("cellular3", 3, 9, (a, r) => + { + var c = cellular(new float3(a[0], a[1], a[2])); + r[0] = c.nearest.x; r[1] = c.nearest.y; r[2] = c.nearest.z; r[3] = c.nearest.w; + r[4] = c.edge.x; r[5] = c.edge.y; r[6] = c.edge.z; r[7] = c.edge.w; + r[8] = c.id; + }), + }; + + private static readonly Dictionary ByName = Table.ToDictionary(op => op.Name); + + /// Every op, in declaration order. + public static IReadOnlyList All => Table; + + /// The op with this persisted name, or null. + public static ExprOp? Find(string name) => ByName.TryGetValue(name, out var op) ? op : null; +} diff --git a/packages/cultmath/src/CultMath/Expr/ExprOps.cs.meta b/packages/cultmath/src/CultMath/Expr/ExprOps.cs.meta new file mode 100644 index 00000000..d2f541e5 --- /dev/null +++ b/packages/cultmath/src/CultMath/Expr/ExprOps.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f2cb6ebc7a4f43d59009e613a1b75888 diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 2a3353de..df7302de 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -58,7 +58,7 @@ public static class CultDocumentMessagePackSerialization // The guard goes first and wraps whatever formatter the rest of the composite would hand out, so a consumer // resolver still encodes an arm while the key check runs. var inner = CompositeResolver.Create( - consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(StandardResolver.Instance).ToArray()); + consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(CultMathExprResolver.Instance).Append(StandardResolver.Instance).ToArray()); return MessagePackSerializerOptions.Standard .WithResolver(CompositeResolver.Create(new CultUnionGuard(inner), inner)) .WithSecurity(CultMessagePackSecurity.Instance); diff --git a/src/GameCult.Caching.MessagePack/CultMathExprResolver.cs b/src/GameCult.Caching.MessagePack/CultMathExprResolver.cs new file mode 100644 index 00000000..4c70b2a8 --- /dev/null +++ b/src/GameCult.Caching.MessagePack/CultMathExprResolver.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using CultMath.Expr; +using MessagePack; +using MessagePack.Formatters; + +namespace GameCult.Caching.MessagePack; + +/// +/// The canonical MessagePack encoding of , beside in the +/// default chain. A graph is [version, inputs, params, outputs, nodes]: inputs are names; a param is +/// [name, default float bits]; an output is [name, node]; a node is [wire name, operands, payload] +/// (the payload is the port index of an input or param, the float bits of a constant, the result index of a pick, +/// zero for a call). Decoding runs , the one validator, so a graph that breaks a rule +/// (unknown op, newer version, forward operand) is refused whole with an and never +/// partly loaded. A nil graph is refused both ways. +/// +public sealed class CultMathExprResolver : IFormatterResolver +{ + public static readonly CultMathExprResolver Instance = new(); + private CultMathExprResolver() { } + + public IMessagePackFormatter? GetFormatter() => Cache.Formatter; + + private static class Cache + { + public static readonly IMessagePackFormatter? Formatter = + typeof(T) == typeof(ExprGraph) ? (IMessagePackFormatter)(object)new GraphFormatter() : null; + } + + private sealed class GraphFormatter : IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, ExprGraph value, MessagePackSerializerOptions options) + { + if (value is null) throw new ExprGraphException("shape"); + writer.WriteArrayHeader(5); + writer.Write(ExprGraph.FormatVersion); + + writer.WriteArrayHeader(value.InputNames.Count); + foreach (var name in value.InputNames) writer.Write(name); + + writer.WriteArrayHeader(value.ParamNames.Count); + for (var i = 0; i < value.ParamNames.Count; i++) + { + writer.WriteArrayHeader(2); + writer.Write(value.ParamNames[i]); + writer.Write(value.ParamDefaultBits[i]); + } + + writer.WriteArrayHeader(value.OutputNames.Count); + for (var i = 0; i < value.OutputNames.Count; i++) + { + writer.WriteArrayHeader(2); + writer.Write(value.OutputNames[i]); + writer.Write(value.OutputNodes[i]); + } + + writer.WriteArrayHeader(value.Nodes.Count); + foreach (var node in value.Nodes) + { + writer.WriteArrayHeader(3); + writer.Write(node.WireName); + writer.WriteArrayHeader(node.Operands.Count); + foreach (var operand in node.Operands) writer.Write(operand); + writer.Write(node.Payload); + } + } + + public ExprGraph Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + if (reader.TryReadNil()) throw new ExprGraphException("shape"); + options.Security.DepthStep(ref reader); + try + { + Expect(ref reader, 5); + var version = reader.ReadInt32(); + + var inputs = new List(); + for (int i = 0, n = reader.ReadArrayHeader(); i < n; i++) inputs.Add(ReadName(ref reader)); + + var paramNames = new List(); + var paramBits = new List(); + for (int i = 0, n = reader.ReadArrayHeader(); i < n; i++) + { + Expect(ref reader, 2); + paramNames.Add(ReadName(ref reader)); + paramBits.Add(reader.ReadInt32()); + } + + var outputNames = new List(); + var outputNodes = new List(); + for (int i = 0, n = reader.ReadArrayHeader(); i < n; i++) + { + Expect(ref reader, 2); + outputNames.Add(ReadName(ref reader)); + outputNodes.Add(reader.ReadInt32()); + } + + var nodes = new List(); + for (int i = 0, n = reader.ReadArrayHeader(); i < n; i++) + { + Expect(ref reader, 3); + var wireName = ReadName(ref reader); + var operands = new List(); + for (int k = 0, m = reader.ReadArrayHeader(); k < m; k++) operands.Add(reader.ReadInt32()); + nodes.Add(ExprNode.FromWire(wireName, operands, reader.ReadInt32())); + } + + return ExprGraph.Create(version, inputs, paramNames, paramBits, outputNames, outputNodes, nodes); + } + finally + { + reader.Depth--; + } + } + + private static void Expect(ref MessagePackReader reader, int count) + { + if (reader.ReadArrayHeader() != count) throw new ExprGraphException("shape"); + } + + private static string ReadName(ref MessagePackReader reader) => + reader.ReadString() ?? throw new ExprGraphException("shape"); + } +}