From 3c75cae4f0eb0b6eb8c339ce68cf71ca73f95df6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:26:15 +0200 Subject: [PATCH 1/4] Union parity vectors: the recursive-union fixture, refusal and extra-slot cases, written only under CULTCACHE_WRITE_VECTORS=1 Co-Authored-By: Claude Opus 5.5 --- .../UnionParityVectorTests.cs | 446 ++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 tests/GameCult.Caching.Tests/UnionParityVectorTests.cs diff --git a/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs b/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs new file mode 100644 index 00000000..381024c7 --- /dev/null +++ b/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs @@ -0,0 +1,446 @@ +#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"; + + 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 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 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 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(); + 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 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(); + 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"); + if (Writing) + { + using (var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry, StoreFlushOnDispose = true })) + { + 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(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().Single(); + var inner = (Box)use.Composition.Body!; + Assert.Multiple(() => + { + Assert.That(value.Outcomes.OfType().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().Count(), Is.EqualTo(1), "a union inside a record inside a union, four arms deep"); + Assert.That(value.Part, Is.TypeOf()); + Assert.That(value.Crafted, Is.TypeOf(), "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"); + } + } + } + + [Test] + public void VectorsNotRewrittenByDefault() + { + if (Writing) Assert.Ignore($"{WriteVariable}=1 is the one way to write the vectors."); + + string Fingerprint() => string.Join("|", Directory.EnumerateFiles(VectorDirectory()).OrderBy(f => f, StringComparer.Ordinal) + .Select(f => Path.GetFileName(f) + "=" + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(f))))); + + var before = Fingerprint(); + Assert.Throws(() => Check("union-surface.msgpack", new byte[] { 0x90 }), "bytes that differ from the vector fail the run"); + Assert.Throws(() => Check("not-a-vector.msgpack", new byte[] { 0x90 }), "a missing vector fails the run"); + Assert.That(Fingerprint(), Is.EqualTo(before), "an ordinary run writes nothing"); + Assert.That(File.Exists(VectorPath("not-a-vector.msgpack")), Is.False); + } + } +} From e5cfb10674134846a68823245a53da07b0d626d6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:28:20 +0200 Subject: [PATCH 2/4] Union parity vectors: committed bytes written by the C# reference, outcomes as data, README Co-Authored-By: Claude Opus 5.5 --- tests/vectors/union-parity/.gitattributes | 2 + tests/vectors/union-parity/README.md | 59 ++++++++++++++++++ tests/vectors/union-parity/arm-arity.msgpack | Bin 0 -> 49 bytes .../union-parity/extra-slot-arm.msgpack | Bin 0 -> 167 bytes .../extra-slot-list-element.msgpack | Bin 0 -> 167 bytes .../extra-slot-nested-record.msgpack | Bin 0 -> 167 bytes .../union-parity/extra-slot-record.msgpack | Bin 0 -> 167 bytes .../inner-union-outer-key.msgpack | Bin 0 -> 156 bytes tests/vectors/union-parity/outcomes.msgpack | Bin 0 -> 502 bytes tests/vectors/union-parity/union-surface.cc | Bin 0 -> 2282 bytes .../union-parity/union-surface.msgpack | Bin 0 -> 156 bytes .../union-parity/unknown-arm-field.msgpack | Bin 0 -> 77 bytes .../union-parity/unknown-arm-list.msgpack | Bin 0 -> 177 bytes .../union-parity/unknown-arm-nested.msgpack | Bin 0 -> 103 bytes 14 files changed, 61 insertions(+) create mode 100644 tests/vectors/union-parity/.gitattributes create mode 100644 tests/vectors/union-parity/README.md create mode 100644 tests/vectors/union-parity/arm-arity.msgpack create mode 100644 tests/vectors/union-parity/extra-slot-arm.msgpack create mode 100644 tests/vectors/union-parity/extra-slot-list-element.msgpack create mode 100644 tests/vectors/union-parity/extra-slot-nested-record.msgpack create mode 100644 tests/vectors/union-parity/extra-slot-record.msgpack create mode 100644 tests/vectors/union-parity/inner-union-outer-key.msgpack create mode 100644 tests/vectors/union-parity/outcomes.msgpack create mode 100644 tests/vectors/union-parity/union-surface.cc create mode 100644 tests/vectors/union-parity/union-surface.msgpack create mode 100644 tests/vectors/union-parity/unknown-arm-field.msgpack create mode 100644 tests/vectors/union-parity/unknown-arm-list.msgpack create mode 100644 tests/vectors/union-parity/unknown-arm-nested.msgpack diff --git a/tests/vectors/union-parity/.gitattributes b/tests/vectors/union-parity/.gitattributes new file mode 100644 index 00000000..8af77cfe --- /dev/null +++ b/tests/vectors/union-parity/.gitattributes @@ -0,0 +1,2 @@ +*.msgpack binary +*.cc binary diff --git a/tests/vectors/union-parity/README.md b/tests/vectors/union-parity/README.md new file mode 100644 index 00000000..04499e04 --- /dev/null +++ b/tests/vectors/union-parity/README.md @@ -0,0 +1,59 @@ +# 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. +- `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. diff --git a/tests/vectors/union-parity/arm-arity.msgpack b/tests/vectors/union-parity/arm-arity.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..337398c1fad01ace7dd1b4a56a4f5147256b7ca5 GIT binary patch literal 49 zcmbQfytuR|EipNjc_QQFNemO0lqY7EOk$kKK8b0&tLQgSJgWx%b8Q!PajSJ9l%=Pa2J5))zkhyaak@M@^g|fKQZwOw5e-_ v(msrAo(;iSp8Z+wpC9NoveQZoMWeeht|=Z5v>8JDT`?&EkLynf40rwu>{~~< literal 0 HcmV?d00001 diff --git a/tests/vectors/union-parity/extra-slot-list-element.msgpack b/tests/vectors/union-parity/extra-slot-list-element.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..8151cd14ae8d32ccb1f1d56fc4c712e2c5014c3e GIT binary patch literal 167 zcmXYqK@Ng25JdqsZoG;iu=F+qj?ko~q?E`pU~1x4OOeD=^k|mE-Cw--{&Vemb%PRj z6p>R>YC|zuoh>zTE-5j#&&ZWhv;kOH)1ab2xGQ4&9_a9xnW)xYu}hkQpMh@yW4p|C wT1Je{wM@q&J|I{D^s?+vUA*j9o0qclIxc~qF literal 0 HcmV?d00001 diff --git a/tests/vectors/union-parity/extra-slot-nested-record.msgpack b/tests/vectors/union-parity/extra-slot-nested-record.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..736d8f3ee585ffda4369143390c94df6aa9ed53b GIT binary patch literal 167 zcmXYqOAdlC6h*;LoVXQ3VCZfh_=F}cC8b1`0k0-bwG>HQMVDqtnBBoS=f0MaLv)KVnz6g`?Hx_|#a)~3Z9%V9?W zSyW0-6q3=&Qi2eQqRF%g*<8umgN0WWAbDZ00QIY~P-1*7d4*A$LNiUxmtmrP2)(>5=F;og0q*T~M>=L_I}N4Wq1 literal 0 HcmV?d00001 diff --git a/tests/vectors/union-parity/inner-union-outer-key.msgpack b/tests/vectors/union-parity/inner-union-outer-key.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..721b506af137edb77a5f883196ac32479e8d9db3 GIT binary patch literal 156 zcmXZUK@Ng26h%=$jT84mVCZfNexXTANh#4~z^jQmg4MiG6kPx{Bx52v+ z!MlOZ*8KP9f6YVO1geSe2$Wc;G@A0ly4$HL>V;qx9F|X)T;fTS>Ec`V0i&HCGK;I= z3$}RL!`^d@(0TlO*;$YJ&_pN@NHYNkpWZdjh8vr)s45vwJlf+Pziyi;(f)wxnvI^j zS=`{g7X{9+2f>16pKU3qN?6)Y33N?sp65XlWstOI;RduxvYTY$}*iOS>ih&{m ziri?JgqpId(jTj9uWK9&3g|Wspk~#sNm$z`CHoL_gu7maSSSo2uu)aBo?%=+5;aTS zsD(x7mV*M(J;>AScDaB++7+=EwsG0xJK#bLA;Y&W^*LZH8g{Ecg2rtSyH`-d#P@My z=)mCv-=mnbT;WA2B2M6FV~U|v7}F~LWNC#$B{zdIDd-mqsaSR*Y*7WWg?(zG#iS;B z4ErMOO&swwcf=DKd!oA!42?HgUf+%!m|g#cy`kg%o4J`qo|R#KWoG5f9arpM9hD{=o3oXA#k)nMTcgrcv%=(NnuO?`jLMJg ze>?o~9U>Fst|H^JM~Fr0maI#fS=Tg^tjn?`%irIePSY-@3+XV%bML2%=|CtK9q|1q zUFr`KB^~0tJwU;Q7vfY+Q89D?4&EK6t0(7P&>khRXKilM?40^V0Im%44{wH>1~xfH o-M}NI%+B8S(}mvf74<~A*t?xB9b?4z9U24>ujRqpK;xC{C(NQdiU0rr literal 0 HcmV?d00001 diff --git a/tests/vectors/union-parity/union-surface.msgpack b/tests/vectors/union-parity/union-surface.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..94ee6c5231f57fd2f3508a0977663da8cfc1f617 GIT binary patch literal 156 zcmXZUOAdlC6hKixjT84m%Fx{ud_t3!l71q~fL9ZzT8eC=OS4Aj=ibMrt-8_%c9>yM zQW}RjdPCL@IG0#BZwZD*iXnlO^*ty|B3uB5*NBJv!bH1S#m;3(<3jmwph?hNnwTPA lS5tDHRex5;r^9RxH_8m=^AU?F9p4=l0@Ud{Z-J_P`~cx2Li+#! literal 0 HcmV?d00001 diff --git a/tests/vectors/union-parity/unknown-arm-field.msgpack b/tests/vectors/union-parity/unknown-arm-field.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..aed497c3c45be92ba7a80ba20b770b7fde60688e GIT binary patch literal 77 zcmbQfytuR|EipNjc@p!ag+)7@9sL}GB6UmiGV}9v)AMsuQuB08l8q84Phyz3q&zXR hWD?^<_DM_=CNWN0mXVm7n_9#JWG+chO)O$L005fG9ti*d literal 0 HcmV?d00001 diff --git a/tests/vectors/union-parity/unknown-arm-list.msgpack b/tests/vectors/union-parity/unknown-arm-list.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..0fe58548af3bf443045f42a7e901c860a227d9a7 GIT binary patch literal 177 zcmXZVyAFad7=~ewaq(6NfjGGlcLz7e0>99t2U6PTWyp_-tCk|S;K-#}qxZmV)F^RL*nh0GcdF4S*g#d+KxE!E*UT}HqC@%)1=r#<=*b(+S zs3TW1sYgGuX?*q0()iEP@qsvL+?h~~tLD8+_Q~NG7(t~73dwR1!6u7|&%8>)IjHrw JSO8Ir@dK$uN}d1! literal 0 HcmV?d00001 diff --git a/tests/vectors/union-parity/unknown-arm-nested.msgpack b/tests/vectors/union-parity/unknown-arm-nested.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..66213f656523828daa1d1cc234cb68746ab8de88 GIT binary patch literal 103 zcmbQfytuR|EipNjc@o289uBq@?EQnj{+~PM*XtaY=b%X2~SRiR_b@CQM?Sv@9brH#fD22gqEKo|;(1asU95 C94SNq literal 0 HcmV?d00001 From b4edbb1eeb6254b681816e53fb42d1bf006d92d1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 02:58:54 +0200 Subject: [PATCH 3/4] Union golden: the store's clock is an internal seam, the writer pins it, the gate test runs under any switch CultCache mints storedAt from an internal Clock (default the wall clock); the vector writer pins it, so union-surface.cc is regenerated in process and byte-compared on every run. The gate test sets the switch itself and writes only a probe, so it neither skips nor touches a committed vector. This commit does not pass: union-surface.cc still holds the old wall-clock stored-at; the next commit regenerates it. Builds. Co-Authored-By: Claude Opus 5.5 --- src/GameCult.Caching/CultCache.cs | 7 +- src/GameCult.Caching/GameCult.Caching.csproj | 1 + .../CultCacheClockTests.cs | 69 +++++++++++++++++++ .../UnionParityVectorTests.cs | 54 +++++++++++---- tests/vectors/union-parity/README.md | 3 +- 5 files changed, 116 insertions(+), 18 deletions(-) create mode 100644 tests/GameCult.Caching.Tests/CultCacheClockTests.cs diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 3de040b9..a10685a2 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -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 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) diff --git a/src/GameCult.Caching/GameCult.Caching.csproj b/src/GameCult.Caching/GameCult.Caching.csproj index a34cdfd3..7bc41ccc 100644 --- a/src/GameCult.Caching/GameCult.Caching.csproj +++ b/src/GameCult.Caching/GameCult.Caching.csproj @@ -8,5 +8,6 @@ + \ No newline at end of file diff --git a/tests/GameCult.Caching.Tests/CultCacheClockTests.cs b/tests/GameCult.Caching.Tests/CultCacheClockTests.cs new file mode 100644 index 00000000..c93b91fd --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultCacheClockTests.cs @@ -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"); + } + } +} diff --git a/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs b/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs index 381024c7..a710a96b 100644 --- a/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs @@ -20,6 +20,9 @@ namespace GameCult.Caching.Tests 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"; @@ -329,17 +332,15 @@ namespace GameCult.Caching.Tests try { var scratch = Path.Combine(work, "union-surface.cc"); - if (Writing) + using (var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry, StoreFlushOnDispose = true })) { - using (var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry, StoreFlushOnDispose = true })) - { - cache.UpsertAsync(typeof(UnionSurface), value, new CultRecordKey("surface")).GetAwaiter().GetResult(); - cache.FlushAsync().GetAwaiter().GetResult(); - } - - Check("union-surface.cc", File.ReadAllBytes(scratch)); + 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); @@ -428,19 +429,42 @@ namespace GameCult.Caching.Tests } } - [Test] + // 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() { - if (Writing) Assert.Ignore($"{WriteVariable}=1 is the one way to write the vectors."); - 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(); - Assert.Throws(() => Check("union-surface.msgpack", new byte[] { 0x90 }), "bytes that differ from the vector fail the run"); - Assert.Throws(() => Check("not-a-vector.msgpack", new byte[] { 0x90 }), "a missing vector fails the run"); - Assert.That(Fingerprint(), Is.EqualTo(before), "an ordinary run writes nothing"); - Assert.That(File.Exists(VectorPath("not-a-vector.msgpack")), Is.False); + var inherited = Environment.GetEnvironmentVariable(WriteVariable); + try + { + foreach (var off in new string?[] { null, string.Empty, "0", "true", "11" }) + { + Environment.SetEnvironmentVariable(WriteVariable, off); + Assert.Throws(() => 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(() => 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"); } } } diff --git a/tests/vectors/union-parity/README.md b/tests/vectors/union-parity/README.md index 04499e04..277c15ad 100644 --- a/tests/vectors/union-parity/README.md +++ b/tests/vectors/union-parity/README.md @@ -24,7 +24,8 @@ Composition is a record, not an arm: `[Name, Body: Node]`. 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. + 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. From e63f93e4a53e4b682b2c66291cd9a8cfd39a6c01 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 02:59:51 +0200 Subject: [PATCH 4/4] Union golden: union-surface.cc regenerated with the pinned store clock Written by the C# reference with CULTCACHE_WRITE_VECTORS=1; two regenerations on Yggdrasil were byte-identical. The 12 payload vectors and outcomes.msgpack are unchanged. Builds and passes. Co-Authored-By: Claude Opus 5.5 --- tests/vectors/union-parity/union-surface.cc | Bin 2282 -> 2282 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/vectors/union-parity/union-surface.cc b/tests/vectors/union-parity/union-surface.cc index ad74e41529ff7d9d1d1a210bd2b6ff7009c4de9b..ff370e714c4c0579f50c910f52ac7d1936765300 100644 GIT binary patch delta 34 hcmaDQ_)2ht0f(4@p{{{ph=G9>80i^6z+^X$B><(z2+sfj delta 34 pcmaDQ_)2ht0f(5Ofv%xJh>?+%k-3$Dsh)|YxrvFT(PTG{B><=^2?ziH