Merge hands/cultmath-expr-ir

This commit is contained in:
Meta Vens 2026-10-14 13:35:46 +02:00
commit 4784c25cc6
21 changed files with 1744 additions and 1 deletions

View file

@ -834,6 +834,39 @@ interval, under the affine bound and under the oracle mask (one logarithmic
scale) and the transmittance. Its run: 4.23, 3.60 and 2.02 cells per ray on
average, 44 the most, mean transmittance 0.278.
## Expressions
`CultMath.Expr` holds a scalar SSA expression graph as a persisted value, a builder
for it, and a C# evaluator.
- **Graph (`ExprGraph`)**: a sealed immutable class. It has no name of its own; the
consumer record that holds it names it. Nodes are in topological order, an operand
names a lower node, and an edit builds a new graph. A persisted graph carries a
format version (1); one naming an unknown op, a newer format or a forward operand is
refused whole at decode. `ExprGraph.Create` is the one validator, run by the builder
and by decode.
- **Node**: its index in the topological order. The builder hash-conses identical
(op, operands, constant bits) nodes and drops nodes no output reaches, so one
expression always builds the same bytes; any rebuild may renumber nodes.
- **Port**: graph inputs vary per point, params are uniform per batch and carry a
default, outputs name a node. Names are `[A-Za-z_][A-Za-z0-9_]*`, unique per kind.
An op's operands are positional; a multi-result op (`cellular3`: nearest.xyzw,
edge.xyzw, id) is read only through `Pick`.
- **Op vocabulary**: one table (`ExprOps`), a stable string per op, append-only.
Vector arguments are decomposed operands: `snoise2`, `snoise3`, `cellular3`. An op
calls its CultMath function; a function's value change is a Breaking release note.
- **Evaluation**: `ExprEvaluator.Evaluate` runs the nodes in order through the table,
one IEEE single per op, so a result equals the direct composition of the same math
calls bit for bit. Params bind by name (`ExprBindings`): a missing name takes its
default and an unknown name is refused.
- **Stencils** are substitution at build time (`ExprBuilder.Substitute`, Fidget's
`remap_xyz`): N substitutions of the position inputs combined by ops give an ordinary
flat graph. There is no loop construct.
The MessagePack shape belongs to `GameCult.Caching.MessagePack` (`CultMathExprResolver`).
Interval, affine and gradient evaluation over the graph, bytecode, the HLSL
interpreter and pruning are not built.
## Rules
- Keep type names HLSL-shaped: `float2`, `float3`, `float4`, `float3x3`, and `math`.

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,57 @@
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.NodeArray;
var outputNodes = graph.OutputNodeArray;
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.Length; i++)
{
var node = nodes[i];
switch (node.Kind)
{
case ExprNodeKind.Input:
slots[start[i]] = inputs[node.Payload * points + point];
break;
case ExprNodeKind.Param:
slots[start[i]] = bindings.Values[node.Payload];
break;
case ExprNodeKind.Const:
slots[start[i]] = BitConverter.Int32BitsToSingle(node.Payload);
break;
case ExprNodeKind.Pick:
slots[start[i]] = slots[start[node.OperandArray[0]] + node.Payload];
break;
default:
var op = node.Op!;
for (var k = 0; k < op.Arity; k++) operands[k] = slots[start[node.OperandArray[k]]];
op.Evaluate(operands.Slice(0, op.Arity), new Span<float>(slots, start[i], op.ResultCount));
break;
}
}
for (var o = 0; o < outputCount; o++) outputs[o * points + point] = slots[start[outputNodes[o]]];
}
}
}

View file

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

View file

@ -0,0 +1,180 @@
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;
InputNames = Array.AsReadOnly(inputNames);
ParamNames = Array.AsReadOnly(paramNames);
ParamDefaultBits = Array.AsReadOnly(paramDefaultBits);
OutputNames = Array.AsReadOnly(outputNames);
OutputNodes = Array.AsReadOnly(outputNodes);
Nodes = Array.AsReadOnly(nodes);
SlotStart = new int[nodes.Length];
var slots = 0;
for (var i = 0; i < nodes.Length; i++)
{
SlotStart[i] = slots;
slots += nodes[i].ResultCount;
}
SlotCount = slots;
}
public IReadOnlyList<string> InputNames { get; }
public IReadOnlyList<string> ParamNames { get; }
/// <summary>The default of each param as float bits, so a NaN payload or a signed zero survives.</summary>
public IReadOnlyList<int> ParamDefaultBits { get; }
public IReadOnlyList<string> OutputNames { get; }
/// <summary>The node each output reads.</summary>
public IReadOnlyList<int> OutputNodes { get; }
public IReadOnlyList<ExprNode> Nodes { get; }
/// <summary>The evaluator's view of the nodes: the private array, never handed out.</summary>
internal ExprNode[] NodeArray => nodes;
/// <summary>The evaluator's view of the output nodes: the private array, never handed out.</summary>
internal int[] OutputNodeArray => outputNodes;
/// <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,83 @@
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;
Operands = Array.AsReadOnly(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 { get; }
/// <summary>The evaluator's view of the operands: the private array, never handed out.</summary>
internal int[] OperandArray => 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

@ -0,0 +1,611 @@
using CultMath.Expr;
using Xunit;
using static CultMath.math;
namespace CultMath.Tests;
/// <summary>
/// CultMath.Expr: the evaluator equals the direct composition of the same math calls bit for bit (every op and a
/// corpus imitating Aetheria's trophic compositions), the builder is canonical and refuses at the call, substitution
/// builds stencils, constants and param defaults keep their bits, bindings resolve by name, the validator's rules
/// refuse each one by name, and no refusal echoes a value it was given.
///
/// Hand mutations these must kill: an op's implementation or operand order swapped in the table (the per-op
/// comparison); hash-consing disabled or the dead-node pass removed (BuildIsCanonical); Substitute leaving an input
/// unmapped (SubstituteEqualsOffsetEvaluation); a validator rule deleted (ValidatorRefusesEachRuleByName and the
/// builder refusals); a constant routed through float arithmetic (ConstantsKeepTheirBits).
/// </summary>
public sealed class ExprTests
{
private const int Points = 4096;
private const string Canary = "CANARY";
private static readonly float[] Edge =
{
0f, -0f, 1f, -1f, 0.5f, 3f, 1e-30f, float.Epsilon, -float.Epsilon, 1.17549435e-38f / 2f,
float.NaN, float.PositiveInfinity, float.NegativeInfinity, float.MaxValue, -float.MaxValue, 1e30f, -1e30f,
};
private static bool Same(float a, float b) =>
(float.IsNaN(a) && float.IsNaN(b)) || BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b);
private static float[] Column(System.Random rng, int points, float lo, float hi, double edgeShare)
{
var column = new float[points];
for (var i = 0; i < points; i++)
column[i] = rng.NextDouble() < edgeShare ? Edge[rng.Next(Edge.Length)] : lo + (hi - lo) * rng.NextSingle();
return column;
}
private static float[] Pack(params float[][] columns) => columns.SelectMany(column => column).ToArray();
private static float[] Run(ExprGraph graph, ExprBindings bindings, params float[][] columns)
{
var points = columns[0].Length;
var outputs = new float[points * graph.OutputNames.Count];
ExprEvaluator.Evaluate(graph, bindings, Pack(columns), outputs);
return outputs;
}
private static void AssertSame(float[] expected, float[] actual, int offset, string what)
{
for (var i = 0; i < expected.Length; i++)
Assert.True(Same(expected[i], actual[offset + i]), $"{what} point {i}: expected bits {BitConverter.SingleToInt32Bits(expected[i]):X8}, got {BitConverter.SingleToInt32Bits(actual[offset + i]):X8}");
}
// The reference side is written by hand, beside the table and not from it.
private static readonly Dictionary<string, Func<float[], float[]>> Reference = new()
{
["add"] = a => new[] { a[0] + a[1] },
["sub"] = a => new[] { a[0] - a[1] },
["mul"] = a => new[] { a[0] * a[1] },
["div"] = a => new[] { a[0] / a[1] },
["neg"] = a => new[] { -a[0] },
["min"] = a => new[] { min(a[0], a[1]) },
["max"] = a => new[] { max(a[0], a[1]) },
["abs"] = a => new[] { abs(a[0]) },
["floor"] = a => new[] { floor(a[0]) },
["frac"] = a => new[] { frac(a[0]) },
["sqrt"] = a => new[] { sqrt(a[0]) },
["exp"] = a => new[] { exp(a[0]) },
["log"] = a => new[] { log(a[0]) },
["pow"] = a => new[] { pow(a[0], a[1]) },
["sin"] = a => new[] { sin(a[0]) },
["cos"] = a => new[] { cos(a[0]) },
["clamp"] = a => new[] { clamp(a[0], a[1], a[2]) },
["saturate"] = a => new[] { saturate(a[0]) },
["lerp"] = a => new[] { lerp(a[0], a[1], a[2]) },
["step"] = a => new[] { step(a[0], a[1]) },
["smoothstep"] = a => new[] { smoothstep(a[0], a[1], a[2]) },
["select"] = a => new[] { a[0] > a[1] ? a[2] : a[3] },
["snoise2"] = a => new[] { snoise(new float2(a[0], a[1])) },
["snoise3"] = a => new[] { snoise(new float3(a[0], a[1], a[2])) },
["cellular3"] = a =>
{
var c = cellular(new float3(a[0], a[1], a[2]));
return new[] { c.nearest.x, c.nearest.y, c.nearest.z, c.nearest.w, c.edge.x, c.edge.y, c.edge.z, c.edge.w, c.id };
},
};
[Fact]
public void OpTableIsTheStableVocabulary()
{
var expected = new (string Name, int Arity, int Results)[]
{
("add", 2, 1), ("sub", 2, 1), ("mul", 2, 1), ("div", 2, 1), ("neg", 1, 1), ("min", 2, 1), ("max", 2, 1),
("abs", 1, 1), ("floor", 1, 1), ("frac", 1, 1), ("sqrt", 1, 1), ("exp", 1, 1), ("log", 1, 1), ("pow", 2, 1),
("sin", 1, 1), ("cos", 1, 1), ("clamp", 3, 1), ("saturate", 1, 1), ("lerp", 3, 1), ("step", 2, 1),
("smoothstep", 3, 1), ("select", 4, 1), ("snoise2", 2, 1), ("snoise3", 3, 1), ("cellular3", 3, 9),
};
Assert.Equal(expected, ExprOps.All.Select(op => (op.Name, op.Arity, op.ResultCount)).ToArray());
Assert.Equal(ExprOps.All.Count, Reference.Count);
Assert.Equal(expected.Select(op => op.Name).OrderBy(name => name), Reference.Keys.OrderBy(name => name));
Assert.Null(ExprOps.Find("input"));
Assert.Null(ExprOps.Find("nope"));
Assert.Same(ExprOps.All[3], ExprOps.Find("div"));
}
[Fact]
public void EvaluatorEqualsDirectComposition()
{
var rng = new System.Random(20261014);
foreach (var op in ExprOps.All)
{
var builder = new ExprBuilder();
var inputs = Enumerable.Range(0, op.Arity).Select(i => builder.Input("i" + i)).ToArray();
var call = builder.Call(op.Name, inputs);
if (op.ResultCount == 1) builder.Output("r", call);
else for (var k = 0; k < op.ResultCount; k++) builder.Output("r" + k, builder.Pick(call, k));
var graph = builder.Build();
var columns = Enumerable.Range(0, op.Arity).Select(_ => Column(rng, Points, -8f, 8f, 0.25)).ToArray();
var outputs = Run(graph, ExprBindings.Bind(graph), columns);
for (var k = 0; k < op.ResultCount; k++)
{
var expected = new float[Points];
for (var p = 0; p < Points; p++)
expected[p] = Reference[op.Name](columns.Select(column => column[p]).ToArray())[k];
AssertSame(expected, outputs, k * Points, op.Name + " result " + k);
}
}
}
[Fact]
public void EvaluatorEqualsDirectCompositionOnTheCorpus()
{
var rng = new System.Random(77);
var x = Column(rng, Points, -2f, 3f, 0.2);
var z = Column(rng, Points, -500f, 500f, 0.1);
// smooth: smoothstep(0.2, 0.8, v)
var b = new ExprBuilder();
b.Output("o", b.Call("smoothstep", b.Const(0.2f), b.Const(0.8f), b.Input("v")));
var smooth = b.Build();
AssertSame(x.Select(v => smoothstep(0.2f, 0.8f, v)).ToArray(), Run(smooth, ExprBindings.Bind(smooth), x), 0, "smooth");
// holling: p * (1 + k) / (p + k), k a param
b = new ExprBuilder();
var p = b.Input("p");
var k = b.Param("k", 0.25f);
b.Output("o", b.Call("div", b.Call("mul", p, b.Call("add", b.Const(1f), k)), b.Call("add", p, k)));
var holling = b.Build();
AssertSame(x.Select(v => v * (1f + 0.25f) / (v + 0.25f)).ToArray(), Run(holling, ExprBindings.Bind(holling), x), 0, "holling");
var overrides = new Dictionary<string, float> { ["k"] = 0.4f };
AssertSame(x.Select(v => v * (1f + 0.4f) / (v + 0.4f)).ToArray(), Run(holling, ExprBindings.Bind(holling, overrides), x), 0, "holling k=0.4");
// envelope: b^0.75
b = new ExprBuilder();
b.Output("o", b.Call("pow", b.Input("b"), b.Const(0.75f)));
var envelope = b.Build();
AssertSame(x.Select(v => pow(v, 0.75f)).ToArray(), Run(envelope, ExprBindings.Bind(envelope), x), 0, "envelope");
// an 8-tap ring stencil of snoise2, with the centre, summed in order
var (ring, offsets) = RingStencil(48f);
var expected = new float[Points];
for (var i = 0; i < Points; i++)
{
var sum = snoise(new float2(x[i] * 0.013f, z[i] * 0.017f));
for (var t = 0; t < 8; t++) sum += snoise(new float2((x[i] + offsets[t].x) * 0.013f, (z[i] + offsets[t].y) * 0.017f));
expected[i] = sum / 9f;
}
AssertSame(expected, Run(ring, ExprBindings.Bind(ring), x, z), 0, "ring stencil");
// a cellular3 Pick chain: (edge.w - nearest.w) * id
b = new ExprBuilder();
var cell = b.Call("cellular3", b.Input("x"), b.Input("y"), b.Input("z"));
b.Output("o", b.Call("mul", b.Call("sub", b.Pick(cell, 7), b.Pick(cell, 3)), b.Pick(cell, 8)));
var chain = b.Build();
var y = Column(rng, Points, -6f, 6f, 0.05);
var cx = Column(rng, Points, -6f, 6f, 0.05);
var cz = Column(rng, Points, -6f, 6f, 0.05);
AssertSame(
Enumerable.Range(0, Points).Select(i =>
{
var c = cellular(new float3(cx[i], y[i], cz[i]));
return (c.edge.w - c.nearest.w) * c.id;
}).ToArray(),
Run(chain, ExprBindings.Bind(chain), cx, y, cz), 0, "cellular chain");
}
private static ExprGraph Field()
{
var b = new ExprBuilder();
b.Output("f", b.Call("snoise2", b.Call("mul", b.Input("x"), b.Const(0.013f)), b.Call("mul", b.Input("z"), b.Const(0.017f))));
return b.Build();
}
private static (ExprGraph Graph, (float x, float y)[] Offsets) RingStencil(float radius)
{
var field = Field();
var offsets = Enumerable.Range(0, 8)
.Select(t => (MathF.Cos(2f * MathF.PI * t / 8f) * radius, MathF.Sin(2f * MathF.PI * t / 8f) * radius))
.ToArray();
var b = new ExprBuilder();
var x = b.Input("x");
var z = b.Input("z");
var sum = b.Substitute(field, new Dictionary<string, ExprValue> { ["x"] = x, ["z"] = z })["f"];
foreach (var (ox, oz) in offsets)
{
var tap = b.Substitute(field, new Dictionary<string, ExprValue>
{
["x"] = b.Call("add", x, b.Const(ox)),
["z"] = b.Call("add", z, b.Const(oz)),
})["f"];
sum = b.Call("add", sum, tap);
}
b.Output("o", b.Call("div", sum, b.Const(9f)));
return (b.Build(), offsets);
}
[Fact]
public void SubstituteEqualsOffsetEvaluation()
{
var rng = new System.Random(5);
var x = Column(rng, Points, -500f, 500f, 0.05);
var z = Column(rng, Points, -500f, 500f, 0.05);
var field = Field();
var (stencil, offsets) = RingStencil(48f);
var centre = Run(field, ExprBindings.Bind(field), x, z);
var sum = (float[])centre.Clone();
for (var t = 0; t < 8; t++)
{
var tap = Run(field, ExprBindings.Bind(field), x.Select(v => v + offsets[t].x).ToArray(), z.Select(v => v + offsets[t].y).ToArray());
for (var i = 0; i < Points; i++) sum[i] += tap[i];
}
var expected = sum.Select(s => s / 9f).ToArray();
AssertSame(expected, Run(stencil, ExprBindings.Bind(stencil), x, z), 0, "substituted stencil");
// The wrong input substituted (z only) is a different graph and differs.
var b = new ExprBuilder();
b.Input("x");
var wz = b.Input("z");
var wrong = b.Substitute(field, new Dictionary<string, ExprValue> { ["z"] = b.Call("add", wz, b.Const(offsets[1].y)) })["f"];
b.Output("o", wrong);
var wrongGraph = b.Build();
var wrongOut = Run(wrongGraph, ExprBindings.Bind(wrongGraph), x, z);
var offsetOut = Run(field, ExprBindings.Bind(field), x, z.Select(v => v + offsets[1].y).ToArray());
AssertSame(offsetOut, wrongOut, 0, "z-only substitution equals z-only offset");
var xOffset = Run(field, ExprBindings.Bind(field), x.Select(v => v + offsets[1].x).ToArray(), z.Select(v => v + offsets[1].y).ToArray());
Assert.Contains(Enumerable.Range(0, Points), i => !Same(xOffset[i], wrongOut[i]));
// Substitute declares the graph's ports it does not replace, and refuses a name the graph lacks.
Assert.Equal(new[] { "x", "z" }, wrongGraph.InputNames);
var half = new ExprBuilder();
half.Output("f", half.Substitute(field, new Dictionary<string, ExprValue> { ["z"] = half.Const(1f) })["f"]);
Assert.Equal(new[] { "x" }, half.Build().InputNames);
var c = new ExprBuilder();
var ex = Assert.Throws<ExprGraphException>(() => c.Substitute(field, new Dictionary<string, ExprValue> { [Canary] = c.Const(1f) }));
Assert.Equal("unknown-input", ex.Rule);
Assert.DoesNotContain(Canary, ex.Message);
var d = new ExprBuilder();
var foreign = Assert.Throws<ExprGraphException>(() => d.Substitute(field, new Dictionary<string, ExprValue> { ["x"] = c.Const(1f) }));
Assert.Equal("foreign-operand", foreign.Rule);
var cell = d.Call("cellular3", d.Const(0f), d.Const(0f), d.Const(0f));
Assert.Equal("bare-multi-result", Assert.Throws<ExprGraphException>(() => d.Substitute(field, new Dictionary<string, ExprValue> { ["x"] = cell })).Rule);
// The checks are the call's own: a graph that is only its input still refuses a foreign or multi-result replacement.
var identity = new ExprBuilder();
identity.Output("o", identity.Input("x"));
var passThrough = identity.Build();
var e = new ExprBuilder();
Assert.Equal("foreign-operand", Refusal(() => e.Substitute(passThrough, new Dictionary<string, ExprValue> { ["x"] = c.Const(1f) })).Rule);
Assert.Equal("bare-multi-result", Refusal(() => e.Substitute(passThrough, new Dictionary<string, ExprValue> { ["x"] = e.Call("cellular3", e.Const(0f), e.Const(0f), e.Const(0f)) })).Rule);
Assert.Same(c.Const(1f), c.Substitute(passThrough, new Dictionary<string, ExprValue> { ["x"] = c.Const(1f) })["o"]);
}
[Fact]
public void SubstituteCarriesParamsConstantsAndPicks()
{
// o = p * k + cellular(p, p, k).edge.w - 0.5, with k a param of default 0.25.
var g = new ExprBuilder();
var gp = g.Input("p");
var gk = g.Param("k", 0.25f);
var cell = g.Call("cellular3", gp, gp, gk);
g.Output("o", g.Call("sub", g.Call("add", g.Call("mul", gp, gk), g.Pick(cell, 7)), g.Const(0.5f)));
var graph = g.Build();
var b = new ExprBuilder();
var shifted = b.Call("add", b.Input("x"), b.Const(1.5f));
b.Output("o", b.Substitute(graph, new Dictionary<string, ExprValue> { ["p"] = shifted })["o"]);
var rebuilt = b.Build();
Assert.Equal(new[] { "x" }, rebuilt.InputNames);
Assert.Equal(graph.ParamNames, rebuilt.ParamNames);
Assert.Equal(graph.ParamDefaultBits, rebuilt.ParamDefaultBits);
var x = Column(new System.Random(3), 512, -5f, 5f, 0.1);
foreach (var k in new[] { 0.25f, 0.9f })
{
var expected = x.Select(v =>
{
var p = v + 1.5f;
return p * k + cellular(new float3(p, p, k)).edge.w - 0.5f;
}).ToArray();
var bindings = ExprBindings.Bind(rebuilt, new Dictionary<string, float> { ["k"] = k });
AssertSame(expected, Run(rebuilt, bindings, x), 0, "substituted graph, k=" + k);
}
// A param met again with other default bits is a conflict, not a silent pick.
var c = new ExprBuilder();
c.Param("k", 0.5f);
Assert.Equal("port-conflict", Refusal(() => c.Substitute(graph, new Dictionary<string, ExprValue>())).Rule);
}
private static string Signature(ExprGraph g) =>
string.Join(";", g.InputNames) + "|" + string.Join(";", g.ParamNames.Zip(g.ParamDefaultBits, (n, v) => n + "=" + v)) + "|" +
string.Join(";", g.OutputNames.Zip(g.OutputNodes, (n, v) => n + "=" + v)) + "|" +
string.Join(";", g.Nodes.Select(n => n.WireName + "(" + string.Join(",", n.Operands) + ")" + n.Payload));
private static ExprGraph Sample(bool withDeadNode)
{
var b = new ExprBuilder();
var x = b.Input("x");
var y = b.Input("y");
b.Param("unused", 2f);
var once = b.Call("mul", x, y);
var twice = b.Call("mul", x, y);
if (withDeadNode) b.Call("sin", b.Call("neg", y));
b.Output("o", b.Call("add", once, twice));
return b.Build();
}
[Fact]
public void BuildIsCanonical()
{
var graph = Sample(false);
Assert.Equal(Signature(graph), Signature(Sample(false)));
Assert.Single(graph.Nodes, n => n.WireName == "mul");
Assert.Equal(new[] { "input", "input", "mul", "add" }, graph.Nodes.Select(n => n.WireName));
Assert.Equal(new[] { 2, 2 }, graph.Nodes[3].Operands);
var withDead = Sample(true);
Assert.Equal(Signature(graph), Signature(withDead));
Assert.DoesNotContain(withDead.Nodes, n => n.WireName == "sin" || n.WireName == "neg");
// The ports are the graph's contract: an unused param stays, and constants are told apart by bits.
Assert.Equal(new[] { "unused" }, graph.ParamNames);
var b = new ExprBuilder();
var sameZero = b.Call("add", b.Const(0f), b.Const(0f));
var minusZero = b.Const(-0f);
b.Output("a", sameZero);
b.Output("b", minusZero);
var zeros = b.Build();
Assert.Equal(new[] { 0, int.MinValue }, zeros.Nodes.Where(n => n.Kind == ExprNodeKind.Const).Select(n => n.Payload));
Assert.Equal(3, zeros.Nodes.Count);
}
[Fact]
public void ConstantsKeepTheirBits()
{
var nanBits = 0x7FC12345;
var constants = new[] { -0f, BitConverter.Int32BitsToSingle(nanBits), BitConverter.Int32BitsToSingle(1) };
var b = new ExprBuilder();
for (var i = 0; i < constants.Length; i++) b.Output("c" + i, b.Const(constants[i]));
b.Output("p", b.Param("p", BitConverter.Int32BitsToSingle(nanBits)));
var graph = b.Build();
Assert.Equal(new[] { unchecked((int)0x80000000), nanBits, 1 }, graph.Nodes.Where(n => n.Kind == ExprNodeKind.Const).Select(n => n.Payload));
Assert.Equal(new[] { nanBits }, graph.ParamDefaultBits);
var outputs = new float[graph.OutputNames.Count];
ExprEvaluator.Evaluate(graph, ExprBindings.Bind(graph), ReadOnlySpan<float>.Empty, outputs);
Assert.Equal(new[] { unchecked((int)0x80000000), nanBits, 1, nanBits }, outputs.Select(BitConverter.SingleToInt32Bits));
}
[Fact]
public void BindingsResolveByName()
{
var b = new ExprBuilder();
b.Output("o", b.Call("sub", b.Param("a", 1.5f), b.Param("b", 4f)));
var graph = b.Build();
float Eval(ExprBindings bindings)
{
var o = new float[1];
ExprEvaluator.Evaluate(graph, bindings, ReadOnlySpan<float>.Empty, o);
return o[0];
}
Assert.Equal(1.5f - 4f, Eval(ExprBindings.Bind(graph)));
Assert.Equal(1.5f - 4f, Eval(ExprBindings.Bind(graph, new Dictionary<string, float>())));
Assert.Equal(10f - 4f, Eval(ExprBindings.Bind(graph, new Dictionary<string, float> { ["a"] = 10f })));
Assert.Equal(1.5f - 7f, Eval(ExprBindings.Bind(graph, new Dictionary<string, float> { ["b"] = 7f })));
var ex = Assert.Throws<ExprGraphException>(() => ExprBindings.Bind(graph, new Dictionary<string, float> { [Canary] = 1f }));
Assert.Equal("unknown-param", ex.Rule);
Assert.DoesNotContain(Canary, ex.Message);
var other = new ExprBuilder();
other.Output("o", other.Const(1f));
var foreign = other.Build();
Assert.Equal("foreign-bindings", Assert.Throws<ExprGraphException>(() =>
ExprEvaluator.Evaluate(graph, ExprBindings.Bind(foreign), ReadOnlySpan<float>.Empty, new float[1])).Rule);
}
[Fact]
public void EvaluateRefusesWrongColumnShapes()
{
var b = new ExprBuilder();
b.Output("o", b.Input("x"));
b.Output("p", b.Call("neg", b.Input("x")));
var graph = b.Build();
var bindings = ExprBindings.Bind(graph);
Assert.Equal("column-shape", Assert.Throws<ExprGraphException>(() => ExprEvaluator.Evaluate(graph, bindings, new float[2], new float[5])).Rule);
Assert.Equal("column-shape", Assert.Throws<ExprGraphException>(() => ExprEvaluator.Evaluate(graph, bindings, new float[3], new float[4])).Rule);
var short_ = new float[4];
Assert.Equal("column-shape", Assert.Throws<ExprGraphException>(() => ExprEvaluator.Evaluate(graph, bindings, new float[1], short_)).Rule);
Assert.Equal("column-shape", Assert.Throws<ExprGraphException>(() => ExprEvaluator.Evaluate(graph, bindings, ReadOnlySpan<float>.Empty, short_)).Rule);
Assert.Equal(new float[4], short_);
var outputs = new float[6];
ExprEvaluator.Evaluate(graph, bindings, new[] { 1f, 2f, 3f }, outputs);
var none = new ExprBuilder().Build();
ExprEvaluator.Evaluate(none, ExprBindings.Bind(none), ReadOnlySpan<float>.Empty, Span<float>.Empty);
Assert.Equal(new[] { 1f, 2f, 3f, -1f, -2f, -3f }, outputs);
}
[Fact]
public void AValidatedGraphCannotBeChangedThroughItsLists()
{
var b = new ExprBuilder();
var x = b.Input("x");
var k = b.Param("k", 2f);
b.Output("o", b.Call("mul", x, k));
var graph = b.Build();
var input = new[] { 1f, 2f, 3f };
var before = new float[3];
ExprEvaluator.Evaluate(graph, ExprBindings.Bind(graph), input, before);
var operands = graph.Nodes[2].Operands;
Assert.False(graph.InputNames is string[] || graph.ParamNames is string[] || graph.OutputNames is string[]);
Assert.False(graph.ParamDefaultBits is int[] || graph.OutputNodes is int[] || operands is int[] || graph.Nodes is ExprNode[]);
Assert.Throws<NotSupportedException>(() => ((IList<int>)operands)[0] = 7);
Assert.Throws<NotSupportedException>(() => ((IList<int>)graph.OutputNodes)[0] = 99);
Assert.Throws<NotSupportedException>(() => ((IList<int>)graph.ParamDefaultBits)[0] = 0);
Assert.Throws<NotSupportedException>(() => ((IList<string>)graph.InputNames)[0] = "bad name");
Assert.Throws<NotSupportedException>(() => ((IList<string>)graph.ParamNames)[0] = "bad name");
Assert.Throws<NotSupportedException>(() => ((IList<string>)graph.OutputNames)[0] = "bad name");
Assert.Throws<NotSupportedException>(() => ((IList<ExprNode>)graph.Nodes)[0] = graph.Nodes[1]);
var after = new float[3];
ExprEvaluator.Evaluate(graph, ExprBindings.Bind(graph), input, after);
Assert.Equal(before, after);
Assert.Equal(new[] { 2f, 4f, 6f }, after);
}
[Fact]
public void CreateCopiesWhatItIsGiven()
{
var inputs = new[] { "x" };
var outputs = new[] { "o" };
var outputNodes = new[] { 0 };
var nodes = new[] { ExprNode.Input(0) };
var graph = ExprGraph.Create(ExprGraph.FormatVersion, inputs, Array.Empty<string>(), Array.Empty<int>(), outputs, outputNodes, nodes);
inputs[0] = "bad name";
outputs[0] = "bad name";
outputNodes[0] = 99;
nodes[0] = ExprNode.Const(0);
Assert.Equal(new[] { "x" }, graph.InputNames);
Assert.Equal(new[] { "o" }, graph.OutputNames);
Assert.Equal(new[] { 0 }, graph.OutputNodes);
Assert.Equal(ExprNodeKind.Input, graph.Nodes[0].Kind);
var operands = new[] { 0 };
var call = ExprNode.Call("neg", operands);
operands[0] = 5;
Assert.Equal(new[] { 0 }, call.Operands);
}
private static ExprGraphException Refusal(Action action) => Assert.Throws<ExprGraphException>(action);
[Fact]
public void TheBuilderRefusesAtTheCall()
{
var b = new ExprBuilder();
var x = b.Input("x");
var cell = b.Call("cellular3", x, x, x);
var unknown = Refusal(() => b.Call(Canary + "op", x));
Assert.Equal("unknown-op", unknown.Rule);
Assert.Equal(2, unknown.NodeIndex);
Assert.DoesNotContain(Canary, unknown.Message);
Assert.Equal("arity", Refusal(() => b.Call("add", x)).Rule);
Assert.Equal("arity", Refusal(() => b.Call("neg", x, x)).Rule);
Assert.Equal("pick-range", Refusal(() => b.Pick(x, 0)).Rule);
Assert.Equal("pick-range", Refusal(() => b.Pick(cell, 9)).Rule);
Assert.Equal("pick-range", Refusal(() => b.Pick(cell, -1)).Rule);
Assert.Equal("bare-multi-result", Refusal(() => b.Call("neg", cell)).Rule);
Assert.Equal("bare-multi-result", Refusal(() => b.Output("o", cell)).Rule);
Assert.Equal("foreign-operand", Refusal(() => b.Call("neg", new ExprBuilder().Const(1f))).Rule);
Assert.Equal("port-name", Refusal(() => b.Input(Canary + "-bad")).Rule);
Assert.Equal("port-name", Refusal(() => b.Input("1x")).Rule);
Assert.Equal("port-name", Refusal(() => b.Input("")).Rule);
Assert.Equal("port-name", Refusal(() => b.Param("a b", 1f)).Rule);
Assert.Equal("port-name", Refusal(() => b.Output("a.b", x)).Rule);
Assert.Equal(b.Pick(cell, 8).ResultCount, 1);
Assert.Equal(9, cell.ResultCount);
b.Output("o", x);
Assert.Equal("duplicate-port-name", Refusal(() => b.Output("o", x)).Rule);
Assert.Same(x, b.Input("x"));
b.Param("p", 1f);
Assert.Equal("port-conflict", Refusal(() => b.Param("p", 2f)).Rule);
Assert.Same(b.Param("p", 1f), b.Param("p", 1f));
Assert.NotNull(b.Input("p"));
Assert.Equal(new[] { "x", "p" }, b.Build().InputNames);
}
private static ExprNode[] N(params ExprNode[] nodes) => nodes;
private static ExprGraph Make(
ExprNode[] nodes,
int version = 1,
string[]? inputs = null,
string[]? paramNames = null,
int[]? paramBits = null,
string[]? outputNames = null,
int[]? outputNodes = null) =>
ExprGraph.Create(
version,
inputs ?? new[] { "x" },
paramNames ?? Array.Empty<string>(),
paramBits ?? Array.Empty<int>(),
outputNames ?? new[] { "o" },
outputNodes ?? new[] { 0 },
nodes);
[Fact]
public void ValidatorRefusesEachRuleByName()
{
void Rule(string rule, Action act, int node = -1)
{
var ex = Assert.Throws<ExprGraphException>(act);
Assert.Equal(rule, ex.Rule);
Assert.Equal(node, ex.NodeIndex);
}
var x = ExprNode.Input(0);
Assert.Equal(1, Make(nodes: N(x)).Nodes.Count);
Rule("version", () => Make(version: 2, nodes: N(x)));
Rule("version", () => Make(version: 0, nodes: N(x)));
Rule("port-count", () => Make(paramNames: new[] { "a" }, nodes: N(x)));
Rule("port-count", () => Make(outputNames: new[] { "a", "b" }, nodes: N(x)));
Rule("port-name", () => Make(inputs: new[] { "9" }, nodes: N(x)));
Rule("duplicate-port-name", () => Make(inputs: new[] { "x", "x" }, nodes: N(x)));
Rule("duplicate-port-name", () => Make(paramNames: new[] { "a", "a" }, paramBits: new[] { 0, 0 }, nodes: N(x)));
Rule("duplicate-port-name", () => Make(outputNames: new[] { "a", "a" }, outputNodes: new[] { 0, 0 }, nodes: N(x)));
Assert.Equal(1, Make(inputs: new[] { "x" }, paramNames: new[] { "x" }, paramBits: new[] { 0 }, nodes: N(x)).Nodes.Count);
Rule("port-index", () => Make(nodes: N(ExprNode.Input(1))), 0);
Rule("port-index", () => Make(nodes: N(ExprNode.Input(-1))), 0);
Rule("port-index", () => Make(nodes: N(x, ExprNode.Param(0))), 1);
Rule("arity", () => Make(nodes: N(ExprNode.FromWire("input", new[] { 0 }, 0))), 0);
Rule("arity", () => Make(nodes: N(ExprNode.FromWire("const", new[] { 0 }, 0))), 0);
Rule("arity", () => Make(nodes: N(x, ExprNode.FromWire("pick", new int[0], 0))), 1);
Rule("payload", () => Make(nodes: N(x, ExprNode.FromWire("neg", new[] { 0 }, 7))), 1);
Rule("output-node", () => Make(outputNodes: new[] { 1 }, nodes: N(x)));
Rule("output-node", () => Make(outputNodes: new[] { -1 }, nodes: N(x)));
Rule("bare-multi-result", () => Make(outputNodes: new[] { 1 }, nodes: N(x, ExprNode.Call("cellular3", new[] { 0, 0, 0 }))), 1);
Rule("pick-range", () => Make(outputNodes: new[] { 1 }, nodes: N(x, ExprNode.Pick(0, 0))), 1);
Rule("operand-order", () => Make(nodes: N(x, ExprNode.Pick(5, 0))), 1);
Rule("operand-order", () => Make(nodes: N(x, ExprNode.Call("neg", new[] { -1 }))), 1);
Rule("operand-order", () => Make(nodes: N(x, ExprNode.Call("neg", new[] { 1 }))), 1);
}
[Fact]
public void PortNamesAcceptTheirWholeAlphabetAndNothingBeside()
{
foreach (var name in new[] { "a", "z", "A", "Z", "_", "_9", "a0", "Z9", "x_Y_z_0" })
Assert.Equal(new[] { name }, Make(nodes: N(ExprNode.Input(0)), inputs: new[] { name }).InputNames);
foreach (var name in new[] { "", "9", "@", "[", "`", "{", "/", ":", "a b", "a-b", "é" })
Assert.Equal("port-name", Refusal(() => Make(nodes: N(ExprNode.Input(0)), inputs: new[] { name })).Rule);
foreach (var name in new[] { "a@", "a[", "a`", "a{", "a/", "a:" })
Assert.Equal("port-name", Refusal(() => Make(nodes: N(ExprNode.Input(0)), inputs: new[] { name })).Rule);
}
[Fact]
public void RefusalsNameTheirRuleAndNodeAndNothingElse()
{
var b = new ExprBuilder();
var x = b.Input("x");
Assert.Equal("unknown-op at node 1", Refusal(() => b.Call("nope", x)).Message);
Assert.Equal("port-name", Refusal(() => b.Input("1")).Message);
Assert.Equal("version", Refusal(() => Make(nodes: N(ExprNode.Input(0)), version: 3)).Message);
Assert.Equal(-1, Refusal(() => b.Input("1")).NodeIndex);
Assert.Equal(1, Refusal(() => b.Call("nope", x)).NodeIndex);
Assert.Equal(0, new ExprGraphException("r", 0).NodeIndex);
Assert.Equal("r at node 0", new ExprGraphException("r", 0).Message);
}
[Fact]
public void FromWireNamesTheFourLeafKindsAndCalls()
{
foreach (var kind in new[] { "input", "param", "const", "pick" })
Assert.Equal(kind, ExprNode.FromWire(kind, Array.Empty<int>(), 0).WireName);
Assert.Equal(ExprNodeKind.Call, ExprNode.FromWire("add", new[] { 0, 0 }, 0).Kind);
Assert.Equal(ExprNodeKind.Call, ExprNode.FromWire("nope", Array.Empty<int>(), 0).Kind);
Assert.Equal(new[] { ExprNodeKind.Input, ExprNodeKind.Param, ExprNodeKind.Const, ExprNodeKind.Pick },
new[] { "input", "param", "const", "pick" }.Select(name => ExprNode.FromWire(name, Array.Empty<int>(), 0).Kind));
}
}

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,152 @@
using System;
using System.Buffers;
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. Decoding also refuses any encoding other than the one <c>Serialize</c> writes (a wider integer,
/// a longer length prefix), so a graph has one byte string. A nil graph is nil, as in every reference slot.
/// </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)
{
writer.WriteNil();
return;
}
Write(ref writer, value);
}
private static void Write(ref MessagePackWriter writer, ExprGraph value)
{
writer.WriteArrayHeader(5);
writer.Write(ExprGraph.FormatVersion);
writer.WriteArrayHeader(value.InputNames.Count);
foreach (var name in value.InputNames) writer.Write(name);
writer.WriteArrayHeader(value.ParamNames.Count);
for (var i = 0; i < value.ParamNames.Count; i++)
{
writer.WriteArrayHeader(2);
writer.Write(value.ParamNames[i]);
writer.Write(value.ParamDefaultBits[i]);
}
writer.WriteArrayHeader(value.OutputNames.Count);
for (var i = 0; i < value.OutputNames.Count; i++)
{
writer.WriteArrayHeader(2);
writer.Write(value.OutputNames[i]);
writer.Write(value.OutputNodes[i]);
}
writer.WriteArrayHeader(value.Nodes.Count);
foreach (var node in value.Nodes)
{
writer.WriteArrayHeader(3);
writer.Write(node.WireName);
writer.WriteArrayHeader(node.Operands.Count);
foreach (var operand in node.Operands) writer.Write(operand);
writer.Write(node.Payload);
}
}
public ExprGraph? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
if (reader.TryReadNil()) return null;
options.Security.DepthStep(ref reader);
try
{
var start = reader.Position;
Expect(ref reader, 5);
var version = reader.ReadInt32();
var inputs = new List<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()));
}
var graph = ExprGraph.Create(version, inputs, paramNames, paramBits, outputNames, outputNodes, nodes);
RequireCanonical(ref reader, start, graph);
return graph;
}
finally
{
reader.Depth--;
}
}
/// <summary>The bytes just read must be the bytes <see cref="Write"/> produces for the graph they decoded to.</summary>
private static void RequireCanonical(ref MessagePackReader reader, SequencePosition start, ExprGraph graph)
{
var canonical = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(canonical);
Write(ref writer, graph);
writer.Flush();
var read = reader.Sequence.Slice(start, reader.Position);
if (read.Length != canonical.WrittenCount || !read.ToArray().AsSpan().SequenceEqual(canonical.WrittenSpan))
throw new ExprGraphException("non-canonical");
}
private static void Expect(ref MessagePackReader reader, int count)
{
if (reader.ReadArrayHeader() != count) throw new ExprGraphException("shape");
}
private static string ReadName(ref MessagePackReader reader) =>
reader.ReadString() ?? throw new ExprGraphException("shape");
}
}

View file

@ -10,6 +10,7 @@ format for the attribute-first `GameCult.Caching` stack.
- `CultDocumentResolver`
- `CultRecordRefFormatter<T>`
- `CultMathResolver`: the canonical component-array encoding of every public CultMath value type, part of the default resolver chain
- `CultMathExprResolver`: the canonical encoding of `CultMath.Expr.ExprGraph`, decoded through its validator, part of the default resolver chain
Cult document payloads serialize through `MessagePackSerializer` with the
options of the document's assembly (`CultDocumentMessagePackSerialization.OptionsFor`),
@ -83,6 +84,13 @@ refusal on write is a `JsonException` where System.Text.Json throws
`ArgumentException`. A consumer resolver or converter
registered ahead of these wins.
`CultMath.Expr.ExprGraph` is a class, not a value type, and has its own shape
(`CultMathExprResolver`, in the default chain): `[version, inputs, params, outputs, nodes]`,
with a param `[name, default float bits]`, an output `[name, node]` and a node
`[op name, operands, payload]`. Decoding runs the graph's one validator and refuses a
broken graph whole (an `ExprGraphException` naming the rule), so a consumer
`[CultDocument]` record with an `ExprGraph` field needs no registration. A nil graph is nil, as in every reference slot; decoding refuses any encoding but the canonical one.
| Type | MessagePack | JSON |
| --- | --- | --- |
| `float2`, `double2`, `int2`, `bool2` | `[x, y]` | `{"x":..,"y":..}` |

View file

@ -0,0 +1,266 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CultMath.Expr;
using FluentAssertions;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
/// <summary>
/// The MessagePack shape of <c>CultMath.Expr.ExprGraph</c> (<c>CultMathExprResolver</c>, in the default chain): a
/// consumer record with a graph field needs no registration, a graph encodes to canonical bytes and decodes to a
/// graph that evaluates bit for bit the same, and decode runs the one validator so a broken encoding is refused
/// whole, naming its rule and never echoing what it was given.
/// </summary>
public sealed class CultMathExprSerializationTests
{
private static readonly MessagePackSerializerOptions Options = CultDocumentMessagePackSerialization.Options;
private const string Canary = "CANARY_NAME";
[CultDocument("tests.expr_holder", "tests.expr_holder.v1")]
[MessagePackObject(AllowPrivate = true)]
[CultGlobal]
internal sealed class ExprHolder
{
[Key(0)] public ExprGraph Graph = default!;
[Key(1)] public float Scale;
}
private static ExprGraph Sample()
{
var b = new ExprBuilder();
var x = b.Input("x");
var z = b.Input("z");
var k = b.Param("k", 1.5f);
var cell = b.Call("cellular3", x, z, k);
var field = b.Call("snoise2", b.Call("mul", x, k), z);
b.Output("o", b.Call("add", field, b.Call("sub", b.Pick(cell, 7), b.Const(-0f))));
return b.Build();
}
private static byte[] Encode(ExprGraph graph) => MessagePackSerializer.Serialize(graph, Options);
private static ExprGraph Decode(byte[] bytes) => MessagePackSerializer.Deserialize<ExprGraph>(bytes, Options);
private static float[] Evaluate(ExprGraph graph, float[] x, float[] z)
{
var outputs = new float[x.Length * graph.OutputNames.Count];
ExprEvaluator.Evaluate(graph, ExprBindings.Bind(graph), x.Concat(z).ToArray(), outputs);
return outputs;
}
[Test]
public void ConsumerRecordRoundTrips()
{
var graph = Sample();
var record = new ExprHolder { Graph = graph, Scale = 2f };
var bytes = MessagePackSerializer.Serialize(record, Options);
var decoded = MessagePackSerializer.Deserialize<ExprHolder>(bytes, Options);
decoded.Scale.Should().Be(2f);
Encode(decoded.Graph).Should().Equal(Encode(graph));
var rng = new Random(11);
var x = Enumerable.Range(0, 256).Select(_ => (float)(rng.NextDouble() * 20 - 10)).ToArray();
var z = Enumerable.Range(0, 256).Select(_ => (float)(rng.NextDouble() * 20 - 10)).ToArray();
var expected = Evaluate(graph, x, z);
var actual = Evaluate(decoded.Graph, x, z);
actual.Select(BitConverter.SingleToInt32Bits).Should().Equal(expected.Select(BitConverter.SingleToInt32Bits));
}
[Test]
public void BuildIsCanonical_TheSameCallsEncodeToTheSameBytes()
{
Encode(Sample()).Should().Equal(Encode(Sample()));
var b = new ExprBuilder();
var x = b.Input("x");
var k = b.Param("k", 1.5f);
b.Output("o", b.Call("add", x, k));
MessagePackSerializer.ConvertToJson(Encode(b.Build()), Options).Should().Be(
"[1,[\"x\"],[[\"k\",1069547520]],[[\"o\",2]],[[\"input\",[],0],[\"param\",[],0],[\"add\",[0,1],0]]]");
var dead = new ExprBuilder();
var dx = dead.Input("x");
var dk = dead.Param("k", 1.5f);
dead.Call("sin", dx);
dead.Output("o", dead.Call("add", dx, dk));
Encode(dead.Build()).Should().Equal(Encode(b.Build()));
}
[Test]
public void ConstantsKeepTheirBits()
{
const int nanBits = 0x7FC12345;
var b = new ExprBuilder();
b.Output("a", b.Const(-0f));
b.Output("b", b.Const(BitConverter.Int32BitsToSingle(nanBits)));
b.Output("c", b.Const(BitConverter.Int32BitsToSingle(1)));
b.Output("d", b.Param("p", BitConverter.Int32BitsToSingle(nanBits)));
var graph = b.Build();
var decoded = Decode(Encode(graph));
Encode(decoded).Should().Equal(Encode(graph));
decoded.Nodes.Where(n => n.Kind == ExprNodeKind.Const).Select(n => n.Payload)
.Should().Equal(int.MinValue, nanBits, 1);
decoded.ParamDefaultBits.Should().Equal(nanBits);
var outputs = new float[4];
ExprEvaluator.Evaluate(decoded, ExprBindings.Bind(decoded), ReadOnlySpan<float>.Empty, outputs);
outputs.Select(BitConverter.SingleToInt32Bits).Should().Equal(int.MinValue, nanBits, 1, nanBits);
}
private static object[] Input() => new object[] { "input", new object[0], 0 };
private static void Put(ref MessagePackWriter writer, object? value)
{
switch (value)
{
case null: writer.WriteNil(); break;
case int i: writer.Write(i); break;
case string s: writer.Write(s); break;
case System.Collections.IEnumerable items:
var all = items.Cast<object>().ToArray();
writer.WriteArrayHeader(all.Length);
foreach (var item in all) Put(ref writer, item);
break;
}
}
private static byte[] Bytes(object? value)
{
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
Put(ref writer, value);
writer.Flush();
return buffer.WrittenMemory.ToArray();
}
private static byte[] Wire(object version, object[] inputs, object[] nodes, int outputNode) =>
Bytes(new object[] { version, inputs, new object[0], new object[] { new object[] { "o", outputNode } }, nodes });
private static void Refused(byte[] bytes, string rule, int node = -1)
{
var act = () => Decode(bytes);
var thrown = act.Should().Throw<MessagePackSerializationException>().Which;
var inner = thrown.InnerException.Should().BeOfType<ExprGraphException>().Which;
inner.Rule.Should().Be(rule);
inner.NodeIndex.Should().Be(node);
thrown.ToString().Should().NotContain(Canary);
}
[Test]
public void DecodeRefusesInvalidGraphs()
{
var x = new object[] { "x" };
var cell = new object[] { "cellular3", new object[] { 0, 0, 0 }, 0 };
Decode(Wire(1, x, new[] { Input() }, 0)).Nodes.Should().HaveCount(1);
Refused(Wire(1, x, new object[] { Input(), new object[] { Canary + "_op", new object[] { 0 }, 0 } }, 1), "unknown-op", 1);
Refused(Wire(1, x, new object[] { Input(), new object[] { "add", new object[] { 0 }, 0 } }, 1), "arity", 1);
Refused(Wire(1, x, new object[] { Input(), new object[] { "neg", new object[] { 2 }, 0 } }, 1), "operand-order", 1);
Refused(Wire(1, x, new object[] { Input(), new object[] { "neg", new object[] { 1 }, 0 } }, 1), "operand-order", 1);
Refused(Wire(1, x, new object[] { Input(), new object[] { "pick", new object[] { 0 }, 0 } }, 1), "pick-range", 1);
Refused(Wire(1, x, new object[] { Input(), cell, new object[] { "pick", new object[] { 1 }, 9 } }, 2), "pick-range", 2);
Refused(Wire(1, x, new object[] { Input(), cell, new object[] { "neg", new object[] { 1 }, 0 } }, 2), "bare-multi-result", 2);
Refused(Wire(1, new object[] { "x", "x" }, new[] { Input() }, 0), "duplicate-port-name");
Refused(Wire(1, new object[] { Canary + "-bad" }, new[] { Input() }, 0), "port-name");
Refused(Wire(2, x, new[] { Input() }, 0), "version");
Refused(Wire(1, x, new[] { Input() }, 3), "output-node");
}
[Test]
public void DecodeRefusesNilAndWrongShapes()
{
Refused(Bytes(new object[] { 1, new object[0] }), "shape");
}
[Test]
public void ANilGraphIsNilLikeEveryOtherReferenceSlot()
{
MessagePackSerializer.Serialize<ExprGraph?>(null, Options).Should().Equal(new byte[] { 0xc0 });
MessagePackSerializer.Deserialize<ExprGraph?>(new byte[] { 0xc0 }, Options).Should().BeNull();
var bytes = MessagePackSerializer.Serialize(new ExprHolder { Graph = null!, Scale = 3f }, Options);
var decoded = MessagePackSerializer.Deserialize<ExprHolder>(bytes, Options);
decoded.Graph.Should().BeNull();
decoded.Scale.Should().Be(3f);
}
// [1, ["x"], [], [["o", 0]], [["input", [], 0]]]
private static readonly byte[] Minimal =
{
0x95, 0x01, 0x91, 0xa1, 0x78, 0x90, 0x91, 0x92, 0xa1, 0x6f, 0x00, 0x91, 0x93, 0xa5, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x90, 0x00,
};
private static byte[] Replace(byte[] bytes, int at, int length, params byte[] with) =>
bytes.Take(at).Concat(with).Concat(bytes.Skip(at + length)).ToArray();
[Test]
public void DecodeRefusesEveryEncodingButTheOneEncodeWrites()
{
Encode(Decode(Minimal)).Should().Equal(Minimal);
Refused(Replace(Minimal, 1, 1, 0xd2, 0, 0, 0, 1), "non-canonical"); // version as int32
Refused(Replace(Minimal, 1, 1, 0xcc, 1), "non-canonical"); // version as uint8
Refused(Replace(Minimal, 3, 2, 0xd9, 1, 0x78), "non-canonical"); // "x" as str8
Refused(Replace(Minimal, 2, 1, 0xdc, 0, 1), "non-canonical"); // input list header as array16
Refused(Replace(Minimal, 5, 1, 0xdd, 0, 0, 0, 0), "non-canonical"); // empty param list as array32
Refused(Replace(Minimal, Minimal.Length - 1, 1, 0xcc, 0), "non-canonical"); // payload as uint8
Refused(Replace(Minimal, 10, 1, 0xd0, 0), "non-canonical"); // output node as int8
}
[Test]
public void DecodeRefusesNestedShapesAndNilNames()
{
var node = new object[] { "input", new object[0], 0 };
object[] Graph(object[]? param = null, object[]? output = null, object? node0 = null) =>
new object[]
{
1,
new object[] { "x" },
param is null ? new object[0] : new object[] { param },
new object[] { output ?? new object[] { "o", 0 } },
new object[] { node0 ?? node },
};
Decode(Bytes(Graph(param: new object[] { "k", 0 }, node0: new object[] { "param", new object[0], 0 })))
.ParamNames.Should().Equal("k");
Refused(Bytes(Graph(param: new object[] { "k", 0, 0 })), "shape");
Refused(Bytes(Graph(param: new object[] { "k" })), "shape");
Refused(Bytes(Graph(output: new object[] { "o", 0, 0 })), "shape");
Refused(Bytes(Graph(output: new object[] { "o" })), "shape");
Refused(Bytes(Graph(node0: new object[] { "input", new object[0], 0, 0 })), "shape");
Refused(Bytes(Graph(node0: new object[] { "input", new object[0] })), "shape");
Refused(Bytes(new object[] { 1, new object?[] { null }, new object[0], new object[0], new object[0] }), "shape");
Refused(Bytes(Graph(param: new object?[] { null, 0 })), "shape");
Refused(Bytes(Graph(output: new object?[] { null, 0 })), "shape");
Refused(Bytes(Graph(node0: new object?[] { null, new object[0], 0 })), "shape");
Refused(Bytes(new object[] { 1, new object[0], new object[0], new object[0], new object[0], 0 }), "shape");
}
[Test]
public void DepthIsReleasedAfterEachGraph()
{
var shallow = Options.WithSecurity(MessagePackSecurity.UntrustedData.WithMaximumObjectGraphDepth(4));
var graphs = Enumerable.Range(0, 40).Select(_ => Sample()).ToArray();
var bytes = MessagePackSerializer.Serialize(graphs, shallow);
var decoded = MessagePackSerializer.Deserialize<ExprGraph[]>(bytes, shallow);
decoded.Should().HaveCount(40);
Encode(decoded[39]).Should().Equal(Encode(graphs[0]));
var tooDeep = Options.WithSecurity(MessagePackSecurity.UntrustedData.WithMaximumObjectGraphDepth(1));
var act = () => MessagePackSerializer.Deserialize<ExprGraph[]>(bytes, tooDeep);
act.Should().Throw<Exception>();
}
}
}