Merge hands/union-golden: the recursive-union and arm-refusal goldens, written reproducibly by the C# reference

Soul holds at cultlib-gaps:verdict:cut-union-golden.s2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 03:45:15 +02:00
commit b0f4057704
18 changed files with 607 additions and 2 deletions

View file

@ -2337,9 +2337,12 @@ namespace GameCult.Caching
throw new ArgumentException($"Expect({key.Value}) was given an instance this cache does not hold at that key.", nameof(current));
}
private static string MintStoredAt(string? previous)
// The wall clock behind every storedAt. Internal: a test that writes a golden store pins it, so the bytes reproduce.
internal Func<DateTimeOffset> Clock { get; set; } = () => DateTimeOffset.UtcNow;
private string MintStoredAt(string? previous)
{
var now = DateTimeOffset.UtcNow;
var now = Clock();
if (previous != null &&
DateTimeOffset.TryParse(previous, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var last) &&
now <= last)

View file

@ -8,5 +8,6 @@
<PackageReference Include="MessagePack.Annotations" Version="3.1.7" />
<PackageReference Include="R3" Version="1.3.0" />
<PackageReference Include="ConcurrentHashSet " Version="1.3.0" />
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,69 @@
#nullable enable
using System;
using System.Globalization;
using System.IO;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// The clock behind every storedAt: the wall clock by default, and a pinned one when a golden store has to reproduce.
public class CultCacheClockTests
{
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(UnionParityVectorTests.UnionSurface) });
private static readonly CultRecordKey Key = new("surface");
private string _directory = string.Empty;
[SetUp]
public void CreateDirectory()
{
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-clock-{Guid.NewGuid():N}");
Directory.CreateDirectory(_directory);
}
[TearDown]
public void DeleteDirectory()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
private CultCache Open() => CultCacheMessagePack.Create(Path.Combine(_directory, "clock.cc"), new CultCacheOpenOptions { Registry = Registry });
private static string Upsert(CultCache cache)
{
cache.UpsertAsync(typeof(UnionParityVectorTests.UnionSurface), new UnionParityVectorTests.UnionSurface(), Key).GetAwaiter().GetResult();
return cache.GetStored(Key)!.StoredAt;
}
[Test]
public void ADefaultCacheStampsTheWallClock()
{
using var cache = Open();
var before = DateTimeOffset.UtcNow;
var stamp = DateTimeOffset.Parse(Upsert(cache), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
var after = DateTimeOffset.UtcNow;
Assert.That(stamp, Is.InRange(before, after));
}
[Test]
public void APinnedClockIsTheStoredAt()
{
var pinned = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
using var cache = Open();
cache.Clock = () => pinned;
Assert.That(Upsert(cache), Is.EqualTo("2026-01-01T00:00:00.0000000+00:00"));
}
[Test]
public void APinnedClockStillIncreasesStoredAtPerKey()
{
var pinned = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
using var cache = Open();
cache.Clock = () => pinned;
Upsert(cache);
Assert.That(Upsert(cache), Is.EqualTo("2026-01-01T00:00:00.0000001+00:00"), "the same instant again is one tick later");
}
}
}

View file

@ -0,0 +1,470 @@
#nullable enable
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// The recursive-union golden and the arm refusal goldens in tests/vectors/union-parity. The C# reference is the only
// writer (CULTCACHE_WRITE_VECTORS=1); every other run reads the committed bytes and compares them byte for byte, so a
// vector is never regenerated by an ordinary run. Other runtimes are held to these bytes (docs/cultcache-union-parity-map.md U11).
public class UnionParityVectorTests
{
private const string WriteVariable = "CULTCACHE_WRITE_VECTORS";
private const string Canary = "CANARY-union-golden-4c2a";
// The stored-at of the one record in union-surface.cc: the store's clock is pinned to it, so the store reproduces.
private static readonly DateTimeOffset StoredAt = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(UnionSurface) });
private static bool Writing => Environment.GetEnvironmentVariable(WriteVariable) == "1";
// ---- the vector directory: written only under the write variable, otherwise read and compared ----
private static string VectorDirectory()
{
for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
{
var vectors = Path.Combine(dir.FullName, "tests", "vectors");
if (Directory.Exists(vectors)) return Path.Combine(vectors, "union-parity");
}
throw new DirectoryNotFoundException($"tests/vectors not found above {TestContext.CurrentContext.TestDirectory}.");
}
private static string VectorPath(string name) => Path.Combine(VectorDirectory(), name);
// Under the write variable the reference's bytes become the file; otherwise they must equal the committed file.
private static void Check(string name, byte[] reference)
{
var path = VectorPath(name);
if (Writing)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, reference);
return;
}
Assert.That(File.Exists(path), Is.True, $"vector {name} is committed");
Assert.That(Convert.ToHexString(reference), Is.EqualTo(Convert.ToHexString(File.ReadAllBytes(path))), $"vector {name}");
}
private static byte[] Committed(string name)
{
var path = VectorPath(name);
Assert.That(File.Exists(path), Is.True, $"vector {name} is committed");
return File.ReadAllBytes(path);
}
// ---- the fixture: a recursive union, a unit arm, a union inside a record inside a union, a list of unions ----
// Keys 0, 1, 2 and 5: 3 and 4 are retired, so the key map is not sequential.
[Union(0, typeof(Box)), Union(1, typeof(Text)), Union(2, typeof(Use)), Union(5, typeof(Image))]
public abstract class Node { }
[MessagePackObject]
public sealed class Box : Node
{
[Key(0)] public string Id = string.Empty;
[Key(1)] public List<Node> Children = new();
}
[MessagePackObject]
public sealed class Text : Node
{
[Key(0)] public string Id = string.Empty;
[Key(1)] public string Content = string.Empty;
}
[MessagePackObject]
public sealed class Use : Node
{
[Key(0)] public string Id = string.Empty;
[Key(1)] public Composition Composition = new();
[Key(2)] public List<Node> Slots = new();
}
// Slot 2 is a retired gap, written nil.
[MessagePackObject]
public sealed class Image : Node
{
[Key(0)] public string Id = string.Empty;
[Key(1)] public string Uri = string.Empty;
[Key(3)] public string Alt = string.Empty;
}
// A record, not an arm, holding a union.
[MessagePackObject]
public sealed class Composition
{
[Key(0)] public string Name = string.Empty;
[Key(1)] public Node? Body;
}
[Union(0, typeof(Pending)), Union(1, typeof(Completed)), Union(2, typeof(Denied))]
public abstract class Outcome { }
[MessagePackObject] public sealed class Pending : Outcome { [Key(0)] public string Message = string.Empty; }
[MessagePackObject] public sealed class Completed : Outcome { [Key(0)] public int SourceVersion; }
// A unit arm: written [2, []].
[MessagePackObject] public sealed class Denied : Outcome { }
// Item names Tool and Part under keys 1 and 2; CraftedItem, the inner union both derive from, names them under 0 and 1.
[Union(0, typeof(Gem)), Union(1, typeof(Tool)), Union(2, typeof(Part))]
public abstract class Item { }
[Union(0, typeof(Tool)), Union(1, typeof(Part))]
public abstract class CraftedItem : Item { }
[MessagePackObject] public sealed class Gem : Item { [Key(0)] public string Name = string.Empty; }
[MessagePackObject] public sealed class Tool : CraftedItem { [Key(0)] public string Name = string.Empty; [Key(1)] public int Uses; }
[MessagePackObject] public sealed class Part : CraftedItem { [Key(0)] public string Name = string.Empty; [Key(1)] public int Count; }
[CultDocument("tests.union_surface", "tests.union_surface.v1")]
[MessagePackObject]
public sealed class UnionSurface
{
[Key(0)] [CultName] public string Id = string.Empty;
[Key(1)] public int Version;
[Key(2)] public Node? Root;
[Key(3)] public List<Outcome> Outcomes = new();
[Key(4)] public Item? Part;
[Key(5)] public CraftedItem? Crafted;
[Key(6)] public Node? Missing;
}
private static UnionSurface Constructed() => new()
{
Id = "surface",
Version = 3,
Root = new Box
{
Id = "root",
Children =
{
new Text { Id = "t1", Content = "hello" },
new Use
{
Id = "u1",
Composition = new Composition
{
Name = "comp",
Body = new Box { Id = "inner", Children = { new Text { Id = "deep", Content = "x" }, new Image { Id = "img", Uri = "uri://a", Alt = "alt" } } }
},
Slots = { new Text { Id = "s1", Content = "slot" }, null!, new Box { Id = "b2" } }
},
new Image { Id = "i2", Uri = "uri://b", Alt = "" }
}
},
Outcomes = { new Pending { Message = "wait" }, new Completed { SourceVersion = 7 }, new Denied() },
Part = new Tool { Name = "hammer", Uses = 12 },
Crafted = new Part { Name = "gear", Count = 4 },
Missing = null
};
// A readable tree of a decoded value, for comparing what was read without re-encoding it.
private static string Describe(object? value) => value switch
{
null => "nil",
UnionSurface s => $"Surface({s.Id},{s.Version},{Describe(s.Root)},[{string.Join(";", s.Outcomes.Select(Describe))}],{Describe(s.Part)},{Describe(s.Crafted)},{Describe(s.Missing)})",
Box b => $"Box({b.Id},[{string.Join(";", b.Children.Select(Describe))}])",
Text t => $"Text({t.Id},{t.Content})",
Use u => $"Use({u.Id},{Describe(u.Composition)},[{string.Join(";", u.Slots.Select(Describe))}])",
Image i => $"Image({i.Id},{i.Uri},{i.Alt})",
Composition c => $"Composition({c.Name},{Describe(c.Body)})",
Pending p => $"Pending({p.Message})",
Completed c => $"Completed({c.SourceVersion})",
Denied => "Denied",
Gem g => $"Gem({g.Name})",
Tool t => $"Tool({t.Name},{t.Uses})",
Part p => $"Part({p.Name},{p.Count})",
_ => throw new InvalidOperationException(value.GetType().Name)
};
// ---- the hand-written emitter: the expected bytes, written with MessagePackWriter, not captured from the code under test ----
private delegate void Emit(ref MessagePackWriter writer);
private static byte[] Raw(Emit emit)
{
var buffer = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
emit(ref writer);
writer.Flush();
return buffer.WrittenSpan.ToArray();
}
private static readonly Emit Nil = (ref MessagePackWriter w) => w.WriteNil();
private static readonly Emit ExtraSlot = (ref MessagePackWriter w) => w.Write("extra-slot");
private static Emit Str(string s) => (ref MessagePackWriter w) => w.Write(s);
private static Emit Num(int n) => (ref MessagePackWriter w) => w.Write(n);
private static Emit Slots(params Emit[] slots) => (ref MessagePackWriter w) =>
{
w.WriteArrayHeader(slots.Length);
foreach (var slot in slots) slot(ref w);
};
private static Emit ArmOf(int key, params Emit[] slots) => (ref MessagePackWriter w) =>
{
w.WriteArrayHeader(2); w.Write(key);
Slots(slots)(ref w);
};
// The slots, with one slot past the last declared key when extra.
private static Emit Plus(bool extra, params Emit[] slots) => Slots(extra ? slots.Append(ExtraSlot).ToArray() : slots);
private sealed record Extra(bool Record = false, bool Composition = false, bool RootArm = false, bool ListElement = false);
private static Emit BoxArm(string id, Emit children, bool extra = false) => ArmOf(0, extra
? new[] { Str(id), children, ExtraSlot } : new[] { Str(id), children });
private static Emit TextArm(string id, string content, bool extra = false) => ArmOf(1, extra
? new[] { Str(id), Str(content), ExtraSlot } : new[] { Str(id), Str(content) });
private static Emit ImageArm(string id, string uri, string alt) => ArmOf(5, Str(id), Str(uri), Nil, Str(alt));
// The constructed value's payload, with a part of the structure replaceable to build a refusal or an extra-slot case.
private static byte[] Surface(Emit? root = null, Emit? outcomes = null, Emit? crafted = null, Extra? extra = null)
{
extra ??= new Extra();
root ??= BoxArm("root", Slots(
TextArm("t1", "hello"),
ArmOf(2, Str("u1"),
Plus(extra.Composition, Str("comp"), BoxArm("inner", Slots(TextArm("deep", "x"), ImageArm("img", "uri://a", "alt")))),
Slots(TextArm("s1", "slot", extra.ListElement), Nil, BoxArm("b2", Slots()))),
ImageArm("i2", "uri://b", "")), extra.RootArm);
outcomes ??= Slots(ArmOf(0, Str("wait")), ArmOf(1, Num(7)), ArmOf(2));
crafted ??= ArmOf(1, Str("gear"), Num(4));
return Raw(Plus(extra.Record,
Str("surface"), Num(3), root, outcomes,
ArmOf(1, Str("hammer"), Num(12)),
crafted, Nil));
}
private static byte[] SerializeValue(UnionSurface value) => CultDocumentMessagePackSerialization.SerializeUntyped(value, typeof(UnionSurface), Registry);
private static UnionSurface Decode(byte[] payload) =>
(UnionSurface)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(UnionSurface), payload, Registry);
// ---- the refusal and extra-slot vectors, each with its expected outcome as data ----
// file, outcome ("refused" | "read-only"), union (logical name), arm key (-1 for none), detail.
private sealed record Case(string File, string Outcome, string Union, int Key, string Detail, byte[] Payload);
private static IReadOnlyList<Case> Cases() => new[]
{
new Case("unknown-arm-field.msgpack", "refused", "Node", 3, "unknown-arm",
Surface(root: ArmOf(3, Str("r"), Str(Canary)))),
new Case("unknown-arm-list.msgpack", "refused", "Outcome", 7, "unknown-arm",
Surface(outcomes: Slots(ArmOf(0, Str("wait")), ArmOf(7, Str(Canary))))),
new Case("unknown-arm-nested.msgpack", "refused", "Node", 9, "unknown-arm",
Surface(root: BoxArm("root", Slots(BoxArm("l1", Slots(BoxArm("l2", Slots(ArmOf(9, Str("deep"), Str(Canary)))))))))),
new Case("inner-union-outer-key.msgpack", "refused", "CraftedItem", 2, "unknown-arm",
Surface(crafted: ArmOf(2, Str("gear"), Num(4)))),
new Case("arm-arity.msgpack", "refused", "Node", 1, "arity",
Surface(root: (ref MessagePackWriter w) => { w.WriteArrayHeader(1); w.Write(1); })),
new Case("extra-slot-record.msgpack", "read-only", "UnionSurface", -1, "record",
Surface(extra: new Extra(Record: true))),
new Case("extra-slot-nested-record.msgpack", "read-only", "Composition", -1, "nested-record",
Surface(extra: new Extra(Composition: true))),
new Case("extra-slot-arm.msgpack", "read-only", "Node", 0, "arm",
Surface(extra: new Extra(RootArm: true))),
new Case("extra-slot-list-element.msgpack", "read-only", "Node", 1, "list-element",
Surface(extra: new Extra(ListElement: true))),
};
private static byte[] OutcomesFile() => Raw((ref MessagePackWriter w) =>
{
var cases = Cases();
w.WriteArrayHeader(cases.Count);
foreach (var c in cases)
{
w.WriteArrayHeader(5);
w.Write(c.File); w.Write(c.Outcome); w.Write(c.Union);
if (c.Key < 0) w.WriteNil(); else w.Write(c.Key);
w.Write(c.Detail);
}
});
private static string UnionName(string logical) => logical switch
{
"Node" => typeof(Node).FullName!,
"Outcome" => typeof(Outcome).FullName!,
"CraftedItem" => typeof(CraftedItem).FullName!,
_ => throw new ArgumentOutOfRangeException(nameof(logical), logical, null)
};
private static string Chain(Exception exception)
{
var parts = new List<string>();
for (var cause = exception; cause != null; cause = cause.InnerException) parts.Add(cause.Message);
return string.Join(" <- ", parts);
}
// ---- tests ----
[Test]
public void UnionSurfaceVectorMatches()
{
var value = Constructed();
var written = SerializeValue(value);
Assert.That(Convert.ToHexString(written), Is.EqualTo(Convert.ToHexString(Surface())), "the hand-written bytes are what the reference writes");
Assert.That(Convert.ToHexString(MessagePackSerializer.Serialize(value, MessagePackSerializerOptions.Standard)), Is.EqualTo(Convert.ToHexString(written)));
Check("union-surface.msgpack", written);
var committed = Committed("union-surface.msgpack");
var decoded = Decode(committed);
Assert.That(Describe(decoded), Is.EqualTo(Describe(value)));
Assert.That(Convert.ToHexString(SerializeValue(decoded)), Is.EqualTo(Convert.ToHexString(committed)), "the committed payload re-encodes byte-identical");
var work = Path.Combine(Path.GetTempPath(), $"cultlib-unionparity-{Guid.NewGuid():N}");
Directory.CreateDirectory(work);
try
{
var scratch = Path.Combine(work, "union-surface.cc");
using (var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry, StoreFlushOnDispose = true }))
{
cache.Clock = () => StoredAt;
cache.UpsertAsync(typeof(UnionSurface), value, new CultRecordKey("surface")).GetAwaiter().GetResult();
cache.FlushAsync().GetAwaiter().GetResult();
}
Check("union-surface.cc", File.ReadAllBytes(scratch));
// The committed store: one record whose payload is the committed payload, holding the constructed value, and rewritten unchanged.
var store = Committed("union-surface.cc");
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(store);
Assert.That(snapshot.Records, Has.Length.EqualTo(1));
Assert.That(Convert.ToHexString(snapshot.Records[0].Payload), Is.EqualTo(Convert.ToHexString(committed)));
File.WriteAllBytes(scratch, store);
using (var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry }))
{
Assert.That(Describe(cache.Get<UnionSurface>(new CultRecordKey("surface"))), Is.EqualTo(Describe(value)));
cache.BackingStores[0].PushAll();
}
Assert.That(Convert.ToHexString(File.ReadAllBytes(scratch)), Is.EqualTo(Convert.ToHexString(store)));
}
finally
{
Directory.Delete(work, recursive: true);
}
}
// The value names every arm shape the vectors exist to hold other runtimes to.
[Test]
public void ConstructedValueExercisesEveryArmShape()
{
var value = Decode(Committed("union-surface.msgpack"));
var root = (Box)value.Root!;
var use = root.Children.OfType<Use>().Single();
var inner = (Box)use.Composition.Body!;
Assert.Multiple(() =>
{
Assert.That(value.Outcomes.OfType<Denied>().Count(), Is.EqualTo(1), "a unit arm");
Assert.That(root.Children, Has.Count.GreaterThan(1), "a list of unions");
Assert.That(use.Slots.Any(s => s is Box) && use.Slots.Any(s => s is null), Is.True, "a list of unions holding a nested union arm and nil");
Assert.That(inner.Children.OfType<Image>().Count(), Is.EqualTo(1), "a union inside a record inside a union, four arms deep");
Assert.That(value.Part, Is.TypeOf<Tool>());
Assert.That(value.Crafted, Is.TypeOf<Part>(), "an inner union keyed apart from its outer");
Assert.That(value.Missing, Is.Null);
});
}
[Test]
public void UnionRefusalVectorsRefused()
{
foreach (var c in Cases()) Check(c.File, c.Payload);
Check("outcomes.msgpack", OutcomesFile());
// From here only the committed files speak: the outcomes file is the data the other runtimes read.
var golden = Describe(Constructed());
var reader = new MessagePackReader(Committed("outcomes.msgpack"));
var count = reader.ReadArrayHeader();
Assert.That(count, Is.EqualTo(Cases().Count));
for (var i = 0; i < count; i++)
{
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(5));
var file = reader.ReadString()!;
var outcome = reader.ReadString()!;
var union = reader.ReadString()!;
int? key = reader.TryReadNil() ? null : reader.ReadInt32();
var detail = reader.ReadString()!;
var payload = Committed(file);
if (outcome == "refused")
{
UnionSurface? accepted = null;
Exception? thrown = null;
try { accepted = Decode(payload); } catch (Exception exception) { thrown = exception; }
Assert.That(thrown, Is.Not.Null, $"{file} was accepted");
var message = Chain(thrown!);
Assert.That(message, Does.Not.Contain(Canary), $"{file}: a refusal never echoes the payload");
if (detail == "arity")
{
Assert.That(message, Does.Contain("Invalid Union data"), file);
}
else
{
Assert.That(message, Does.Contain(UnionName(union)).And.Contain($"key {key} "), file);
Assert.That(accepted, Is.Null, file);
}
}
else
{
Assert.That(outcome, Is.EqualTo("read-only"), file);
// The record is read whole; what makes it read-only is the slot rule of the slot-tolerance cut, not asserted here.
Assert.That(Describe(Decode(payload)), Is.EqualTo(golden), $"{file}: the extra slot is read past");
}
}
}
// Runs whatever the switch reads when the test starts: it sets the switch itself, and writes only a probe file.
[Test, NonParallelizable]
public void VectorsNotRewrittenByDefault()
{
string Fingerprint() => string.Join("|", Directory.EnumerateFiles(VectorDirectory()).OrderBy(f => f, StringComparer.Ordinal)
.Select(f => Path.GetFileName(f) + "=" + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(f)))));
const string probe = "gate-probe.msgpack";
var path = VectorPath(probe);
var before = Fingerprint();
var inherited = Environment.GetEnvironmentVariable(WriteVariable);
try
{
foreach (var off in new string?[] { null, string.Empty, "0", "true", "11" })
{
Environment.SetEnvironmentVariable(WriteVariable, off);
Assert.Throws<AssertionException>(() => Check(probe, new byte[] { 0x90 }), $"a missing vector fails the run, switch {off ?? "unset"}");
Assert.That(File.Exists(path), Is.False, $"switch {off ?? "unset"} writes nothing");
File.WriteAllBytes(path, new byte[] { 0x91 });
Assert.Throws<AssertionException>(() => Check(probe, new byte[] { 0x90 }), $"bytes that differ from the vector fail the run, switch {off ?? "unset"}");
Assert.That(File.ReadAllBytes(path), Is.EqualTo(new byte[] { 0x91 }), $"switch {off ?? "unset"} rewrites nothing");
File.Delete(path);
}
Environment.SetEnvironmentVariable(WriteVariable, "1");
Check(probe, new byte[] { 0x90 });
Assert.That(File.ReadAllBytes(path), Is.EqualTo(new byte[] { 0x90 }), "1 is the one way to write a vector");
}
finally
{
Environment.SetEnvironmentVariable(WriteVariable, inherited);
if (File.Exists(path)) File.Delete(path);
}
Assert.That(Fingerprint(), Is.EqualTo(before), "no committed vector changed");
}
}
}

View file

@ -0,0 +1,2 @@
*.msgpack binary
*.cc binary

View file

@ -0,0 +1,60 @@
# union-parity vectors
Written only by the C# reference, `tests/GameCult.Caching.Tests/UnionParityVectorTests.cs`, with
`CULTCACHE_WRITE_VECTORS=1`. Every other run reads these files and compares them byte for byte; a changed
vector is a reviewed commit. Other runtimes decode them and are held to the same outcomes.
Document `tests.union_surface` (schema `tests.union_surface.v1`), a positional array of
`[Id, Version, Root, Outcomes, Part, Crafted, Missing]`. A union value is `nil` or `[armKey, [armSlots...]]`.
| Union | Arm keys | Arm slots |
|---|---|---|
| Node | 0 Box, 1 Text, 2 Use, 5 Image (3 and 4 retired) | Box `[Id, Children: Node list]`; Text `[Id, Content]`; Use `[Id, Composition, Slots: Node list]`; Image `[Id, Uri, nil (retired 2), Alt]` |
| Outcome | 0 Pending, 1 Completed, 2 Denied | Pending `[Message]`; Completed `[SourceVersion]`; Denied is a unit arm, `[2, []]` |
| Item | 0 Gem, 1 Tool, 2 Part | Gem `[Name]`; Tool `[Name, Uses]`; Part `[Name, Count]` |
| CraftedItem | 0 Tool, 1 Part | the same arms as Item under its own keys; a key only Item declares is unknown to it |
Composition is a record, not an arm: `[Name, Body: Node]`.
## Files
- `union-surface.msgpack`: the payload. Value: Id `surface`, Version 3; Root is a Box `root` holding Text `t1`, a Use
`u1` and Image `i2`; the Use's Composition `comp` holds Box `inner` (Text `deep`, Image `img`), and its Slots are
Text `s1`, nil, Box `b2` (empty); Outcomes are Pending, Completed, Denied; Part is Tool `hammer`; Crafted (a
CraftedItem field) is Part `gear`; Missing is nil. A union inside a record inside a union, a unit arm, a list of
unions with a nil element, an inner union keyed apart from its outer. Written by C#.
- `union-surface.cc`: a single-file store written by the C# store holding that one record under key `surface`. Its
payload equals `union-surface.msgpack`; rewriting the loaded store is byte-identical. The writer pins the store's
clock, so the record's stored-at is `2026-01-01T00:00:00.0000000+00:00` and the whole file reproduces byte for byte.
- `outcomes.msgpack`: the expected outcome of every file below, as data. An array of entries
`[file, outcome, union, key, detail]`: `outcome` is `refused` or `read-only`; `union` is the union (or record)
named; `key` is the arm key or nil; `detail` is as in the tables below.
## Refusals (outcome `refused`)
A refusal names the union and the arm key and never echoes the payload. Each payload is `union-surface.msgpack`
with one part replaced.
| File | Replaced part | detail | Must name |
|---|---|---|---|
| `unknown-arm-field.msgpack` | Root is an arm with the retired key 3 | unknown-arm | Node, key 3 |
| `unknown-arm-list.msgpack` | an Outcomes element has key 7 | unknown-arm | Outcome, key 7 |
| `unknown-arm-nested.msgpack` | key 9 three Boxes deep in Children | unknown-arm | Node, key 9 |
| `inner-union-outer-key.msgpack` | the Crafted field carries key 2, declared only by Item | unknown-arm | CraftedItem, key 2 |
| `arm-arity.msgpack` | Root is written `[1]`, an arm with no slots array | arity | a refusal of the arm as malformed (C#: MessagePack's "Invalid Union data") |
## Extra trailing slot (outcome `read-only`)
Each payload is `union-surface.msgpack` with one slot appended past the reader's last declared slot of one
record (ruling `cultlib-gaps:ruling:extra-trailing-slots`: the record is read, read-only at any depth, every write
of it refused). A reader decodes the value of `union-surface.msgpack` from each; `detail` says where the slot is.
| File | detail | Record with the extra slot |
|---|---|---|
| `extra-slot-record.msgpack` | record | the document (slot 7) |
| `extra-slot-nested-record.msgpack` | nested-record | Composition `comp` |
| `extra-slot-arm.msgpack` | arm | the Root Box arm |
| `extra-slot-list-element.msgpack` | list-element | Text `s1`, an element of Use.Slots |
The C# reference reads these today and its slot-tolerance cut is what makes them read-only; this cut asserts only
that the value is read.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.