Merge hands/cultmath-expr-ir
This commit is contained in:
commit
4784c25cc6
21 changed files with 1744 additions and 1 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
8
packages/cultmath/src/CultMath/Expr.meta
Normal file
8
packages/cultmath/src/CultMath/Expr.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a99b96b837a3454781b426cec6bfe837
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
39
packages/cultmath/src/CultMath/Expr/ExprBindings.cs
Normal file
39
packages/cultmath/src/CultMath/Expr/ExprBindings.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/Expr/ExprBindings.cs.meta
Normal file
2
packages/cultmath/src/CultMath/Expr/ExprBindings.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c9dea1bb6c854e8f80a5e0f05902fd17
|
||||
184
packages/cultmath/src/CultMath/Expr/ExprBuilder.cs
Normal file
184
packages/cultmath/src/CultMath/Expr/ExprBuilder.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/Expr/ExprBuilder.cs.meta
Normal file
2
packages/cultmath/src/CultMath/Expr/ExprBuilder.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7326eeabc2d04a43b3d0a1fe6e45b018
|
||||
57
packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs
Normal file
57
packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs
Normal 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]]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 38108254b5b4430b8f87094e6905dc59
|
||||
180
packages/cultmath/src/CultMath/Expr/ExprGraph.cs
Normal file
180
packages/cultmath/src/CultMath/Expr/ExprGraph.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/Expr/ExprGraph.cs.meta
Normal file
2
packages/cultmath/src/CultMath/Expr/ExprGraph.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 04d707ad2a4e4e0299407ce1d86afea3
|
||||
22
packages/cultmath/src/CultMath/Expr/ExprGraphException.cs
Normal file
22
packages/cultmath/src/CultMath/Expr/ExprGraphException.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 88e355a7be3f44ada7f1d9464c60428e
|
||||
83
packages/cultmath/src/CultMath/Expr/ExprNode.cs
Normal file
83
packages/cultmath/src/CultMath/Expr/ExprNode.cs
Normal 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;
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/Expr/ExprNode.cs.meta
Normal file
2
packages/cultmath/src/CultMath/Expr/ExprNode.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: fc3cb4abaa27482aa98a76cd876b8805
|
||||
86
packages/cultmath/src/CultMath/Expr/ExprOps.cs
Normal file
86
packages/cultmath/src/CultMath/Expr/ExprOps.cs
Normal 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;
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/Expr/ExprOps.cs.meta
Normal file
2
packages/cultmath/src/CultMath/Expr/ExprOps.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f2cb6ebc7a4f43d59009e613a1b75888
|
||||
611
packages/cultmath/tests/CultMath.Tests/ExprTests.cs
Normal file
611
packages/cultmath/tests/CultMath.Tests/ExprTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
152
src/GameCult.Caching.MessagePack/CultMathExprResolver.cs
Normal file
152
src/GameCult.Caching.MessagePack/CultMathExprResolver.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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":..}` |
|
||||
|
|
|
|||
266
tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs
Normal file
266
tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue