From 71a44d5269712652e15ee218489953cb3b1223aa Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 08:48:52 +0000 Subject: [PATCH 1/4] cultcache C#: a unique-index refusal names the least violation of the resulting state; write the shared refusals vector Co-Authored-By: Claude Sonnet 5.5 --- src/GameCult.Caching/CultCache.cs | 36 ++- .../UniqueIndexVectorTests.cs | 272 ++++++++++++++++++ tests/vectors/unique-index/README.md | 25 ++ tests/vectors/unique-index/pair-store.cc | Bin 0 -> 1056 bytes tests/vectors/unique-index/refusals.msgpack | 1 + tests/vectors/unique-index/repull-after.cc | Bin 0 -> 1201 bytes tests/vectors/unique-index/repull-before.cc | Bin 0 -> 955 bytes tests/vectors/unique-index/surrogate-store.cc | Bin 0 -> 1070 bytes tests/vectors/unique-index/types-store.cc | Bin 0 -> 1844 bytes 9 files changed, 325 insertions(+), 9 deletions(-) create mode 100644 tests/vectors/unique-index/pair-store.cc create mode 100644 tests/vectors/unique-index/refusals.msgpack create mode 100644 tests/vectors/unique-index/repull-after.cc create mode 100644 tests/vectors/unique-index/repull-before.cc create mode 100644 tests/vectors/unique-index/surrogate-store.cc create mode 100644 tests/vectors/unique-index/types-store.cc diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 709fc64d..6f04d99c 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -3495,10 +3495,11 @@ namespace GameCult.Caching } } - // The one index rule, judged on the commit's result whatever the write order: within one document type and one - // [CultIndex] alias, no two records hold one non-blank value. The holders of a value are the held key (unless this - // admission replaces or evicts it) plus every landing key projecting it; two distinct holders refuse, naming both - // keys and the index and never the value. One pass over what lands, one lookup per (index, value). + // The one index rule, a function of the state the admission would leave and never of staging, file or pull order: + // within one document type and one [CultIndex] alias, no two records hold one non-blank value. The holders of a + // value are the held key (unless this admission replaces or evicts it) plus every landing key projecting it. Over + // every (type, alias, value) held at two or more distinct keys, the refusal names the least by (alias, its least + // key, its second-least key, schema name), each compared by UTF-16 code unit, and never names the value. private void RefuseIndexSharing(VariantPlan plan, IReadOnlyList evicted) { var landing = plan.Admitted.Concat(plan.Dependents).ToList(); @@ -3520,17 +3521,34 @@ namespace GameCult.Caching } } - foreach (var ((index, value), landedKeys) in landed.OrderBy(pair => pair.Key.Index.Alias, StringComparer.Ordinal)) + (string Alias, string First, string Second, string Schema)? least = null; + foreach (var ((index, value), landedKeys) in landed) { var holders = landedKeys.AsEnumerable(); if (_indexes.TryGetValue(index, out var held) && held.TryGetValue(value, out var heldKey) && !replaced.Contains(heldKey)) holders = holders.Append(heldKey); var distinct = holders.Distinct(StringComparer.Ordinal).OrderBy(key => key, StringComparer.Ordinal).Take(2).ToArray(); - if (distinct.Length > 1) - throw new InvalidOperationException( - $"Records {distinct[0]} and {distinct[1]} ({schemaNames[index.Type]}) " + - $"both hold unique index '{index.Alias}'; an index value names one record."); + if (distinct.Length < 2) + continue; + var candidate = (index.Alias, distinct[0], distinct[1], schemaNames[index.Type]); + if (least == null || CompareRefusals(candidate, least.Value) < 0) + least = candidate; } + + if (least is { } pair) + throw new InvalidOperationException( + $"Records {pair.First} and {pair.Second} ({pair.Schema}) " + + $"both hold unique index '{pair.Alias}'; an index value names one record."); + } + + private static int CompareRefusals( + (string Alias, string First, string Second, string Schema) left, + (string Alias, string First, string Second, string Schema) right) + { + var order = StringComparer.Ordinal.Compare(left.Alias, right.Alias); + if (order == 0) order = StringComparer.Ordinal.Compare(left.First, right.First); + if (order == 0) order = StringComparer.Ordinal.Compare(left.Second, right.Second); + return order != 0 ? order : StringComparer.Ordinal.Compare(left.Schema, right.Schema); } private static InvalidOperationException CycleRefusal(IEnumerable keys) => diff --git a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs index d7c68459..257c4175 100644 --- a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs @@ -290,5 +290,277 @@ namespace GameCult.Caching.Tests } } } + + // ---- refusals.msgpack: [label, ops, refusal]; the pair a refusal names is a function of the resulting state ---- + + private sealed record Schema(string Name, Type Role, Type Index, string SecondField); + + private static Schema Plain(string suffix, string name) => new( + name, + EmittedDocumentTypes.Emit("UqVec" + suffix + "Role", name, name + ".v1", RoleFields), + EmittedDocumentTypes.Emit("UqVec" + suffix + "Index", name, name + ".v1", IndexFields), + "Category"); + + private static readonly Schema[] Schemas = + { + new("tests.unique_item", ItemRole, ItemIndex, "Category"), + new("tests.uq_pair", + EmittedDocumentTypes.Emit("UqVecPairRole", "tests.uq_pair", "tests.uq_pair.v1", + new EmittedDocumentTypes.Field[] { new("Sku", typeof(string), 0, Role: "Sku"), new("Alt", typeof(string), 1, Role: "Alt") }), + EmittedDocumentTypes.Emit("UqVecPairIndex", "tests.uq_pair", "tests.uq_pair.v1", + new EmittedDocumentTypes.Field[] { new("Sku", typeof(string), 0, Index: "Sku"), new("Alt", typeof(string), 1, Index: "Alt") }), + "Alt"), + Plain("Alpha", "tests.uq_alpha"), + Plain("Beta", "tests.uq_beta"), + }; + + private static Schema SchemaNamed(string name) => Schemas.Single(schema => schema.Name == name); + + private sealed record Rec(string Key, string Schema, string Sku, string Second = ""); + + private sealed record Op(string Kind, string? File = null, Rec[]? Records = null); + + private sealed record Refusal(string K1, string K2, string Schema, string Alias); + + private sealed record Case(string Label, Op[] Ops, Refusal? Refusal); + + private const string Item = "tests.unique_item"; + private const string Pair = "tests.uq_pair"; + private const string Alpha = "tests.uq_alpha"; + private const string Beta = "tests.uq_beta"; + + private static Rec[] Items_(params (string Key, string Sku)[] rows) => rows.Select(row => new Rec(row.Key, Item, row.Sku)).ToArray(); + private static Op Open(string? file = null) => new("open", file); + private static Op Pull(string file) => new("pull", file); + private static Op Commit(params Rec[] records) => new("commit", Records: records); + + private static readonly Rec[] PairRecords = { new("item:a", Pair, "S", "X"), new("item:b", Pair, "S", "Y"), new("item:c", Pair, "T", "Y") }; + private static readonly Rec[] SurrogateRecords = Items_(("k\uFF01", "S"), ("k\U0001F600", "S"), ("k\uE000", "S")); + private static readonly Rec[] TypeRecords = + { + new("b:1", Alpha, "P"), new("a:1", Beta, "P"), new("b:2", Alpha, "P"), new("a:2", Beta, "P") + }; + private static readonly Rec[] RepullBefore = Items_(("item:a1", "A"), ("item:b1", "C")); + private static readonly Rec[] RepullAfter = Items_(("item:a1", "A"), ("item:b1", "B"), ("item:b2", "B"), ("item:c1", "A")); + + private static readonly (string File, Rec[] Records)[] RefusalStores = + { + ("pair-store.cc", PairRecords), ("surrogate-store.cc", SurrogateRecords), ("types-store.cc", TypeRecords), + ("repull-before.cc", RepullBefore), ("repull-after.cc", RepullAfter), + }; + + private static readonly Refusal SurrogateRefusal = new("k\U0001F600", "k\uE000", Item, "Sku"); + private static readonly Refusal TypesRefusal = new("a:1", "a:2", Beta, "Sku"); + private static readonly Refusal PairRefusal = new("item:b", "item:c", Pair, "Alt"); + private static readonly Refusal RepullRefusal = new("item:a1", "item:c1", Item, "Sku"); + + private static readonly Case[] Cases = + { + new("batch-later-value-staged-first", new[] { Open(), Commit(Items_(("x", "A"), ("y", "A"), ("a", "B"), ("b", "B"))) }, + new Refusal("a", "b", Item, "Sku")), + new("batch-surrogate-keys", new[] { Open(), Commit(SurrogateRecords) }, SurrogateRefusal), + new("batch-interleaved-types", new[] { Open(), Commit(TypeRecords) }, TypesRefusal), + new("batch-two-aliases", new[] { Open(), Commit(PairRecords) }, PairRefusal), + new("held-then-landing", new[] { Open(), Commit(Items_(("m", "S"))), Commit(Items_(("a", "S"))) }, + new Refusal("a", "m", Item, "Sku")), + new("held-in-the-least-group", new[] { Open(), Commit(Items_(("m", "A"))), Commit(Items_(("z", "B"), ("y", "B"), ("a", "A"))) }, + new Refusal("a", "m", Item, "Sku")), + new("swap-off-a-held-value", new[] { Open(), Commit(Items_(("a1", "A"))), Commit(Items_(("a1", "B"), ("c1", "A"))) }, null), + new("load-two-aliases", new[] { Open("pair-store.cc") }, PairRefusal), + new("load-surrogate-keys", new[] { Open("surrogate-store.cc") }, SurrogateRefusal), + new("load-interleaved-types", new[] { Open("types-store.cc") }, TypesRefusal), + new("repull-held-pair", new[] { Open("repull-before.cc"), Pull("repull-after.cc") }, RepullRefusal), + new("load-repull-after", new[] { Open("repull-after.cc") }, RepullRefusal), + new("commit-onto-a-loaded-holder", new[] { Open("repull-before.cc"), Commit(Items_(("item:z", "A"))) }, + new Refusal("item:a1", "item:z", Item, "Sku")), + }; + + private static byte[] RefusalStoreBytes(Rec[] records) + { + var directory = Path.Combine(Path.GetTempPath(), $"cultlib-uidxr-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var path = Path.Combine(directory, "store.cc"); + var roles = records.Select(record => SchemaNamed(record.Schema).Role).Distinct().ToArray(); + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions + { + Registry = CultDocumentRegistry.ForTypes(roles), StoreFlushOnDispose = true + })) + { + cache.Clock = () => StoredAt; + foreach (var record in records) + { + var schema = SchemaNamed(record.Schema); + cache.UpsertAsync(schema.Role, EmittedDocumentTypes.New(schema.Role, ("Sku", record.Sku), (schema.SecondField, record.Second)), new CultRecordKey(record.Key)) + .GetAwaiter().GetResult(); + } + + cache.FlushAsync().GetAwaiter().GetResult(); + } + + return File.ReadAllBytes(path); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static byte[] RefusalsFile() + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(Cases.Length); + foreach (var row in Cases) + { + writer.WriteArrayHeader(3); + writer.Write(row.Label); + writer.WriteArrayHeader(row.Ops.Length); + foreach (var op in row.Ops) + { + writer.WriteArrayHeader(2); + writer.Write(op.Kind); + if (op.Records != null) + { + writer.WriteArrayHeader(op.Records.Length); + foreach (var record in op.Records) + { + writer.WriteArrayHeader(4); + writer.Write(record.Key); + writer.Write(record.Schema); + writer.Write(record.Sku); + writer.Write(record.Second); + } + } + else if (op.File == null) writer.WriteNil(); + else writer.Write(op.File); + } + + if (row.Refusal == null) writer.WriteNil(); + else + { + writer.WriteArrayHeader(4); + writer.Write(row.Refusal.K1); + writer.Write(row.Refusal.K2); + writer.Write(row.Refusal.Schema); + writer.Write(row.Refusal.Alias); + } + } + + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + // Performs one op of a row against the open cache; the cache's own file is the only state it leaves behind. + private static void Perform(ref CultCache? cache, string path, Op op) + { + var indexTypes = Schemas.Select(schema => schema.Index).ToArray(); + switch (op.Kind) + { + case "open": + if (op.File != null) File.WriteAllBytes(path, Committed(op.File)); + cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = CultDocumentRegistry.ForTypes(indexTypes) }); + break; + case "pull": + File.WriteAllBytes(path, Committed(op.File!)); + cache!.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + break; + case "commit": + cache!.Commit(batch => + { + foreach (var record in op.Records!) + { + var schema = SchemaNamed(record.Schema); + batch.Upsert(schema.Index, EmittedDocumentTypes.New(schema.Index, ("Sku", record.Sku), (schema.SecondField, record.Second)), new CultRecordKey(record.Key)); + } + }); + break; + default: + throw new InvalidOperationException(op.Kind); + } + } + + [Test] + public void RefusalsHoldTheRule() + { + Check("refusals.msgpack", RefusalsFile()); + foreach (var (file, records) in RefusalStores) + Check(file, RefusalStoreBytes(records)); + + // The committed file is what is walked, not the in-memory table. + var reader = new MessagePackReader(Committed("refusals.msgpack")); + var count = reader.ReadArrayHeader(); + Assert.That(count, Is.EqualTo(Cases.Length)); + var walked = 0; + for (var i = 0; i < count; i++) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(3)); + var label = reader.ReadString()!; + var ops = new List(); + for (var n = reader.ReadArrayHeader(); n > 0; n--) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2)); + var kind = reader.ReadString()!; + if (kind == "commit") + { + var records = new List(); + for (var m = reader.ReadArrayHeader(); m > 0; m--) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(4)); + records.Add(new Rec(reader.ReadString()!, reader.ReadString()!, reader.ReadString()!, reader.ReadString()!)); + } + + ops.Add(new Op(kind, Records: records.ToArray())); + } + else ops.Add(new Op(kind, NextText(ref reader))); + } + + Refusal? refusal = null; + if (!reader.TryReadNil()) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(4)); + refusal = new Refusal(reader.ReadString()!, reader.ReadString()!, reader.ReadString()!, reader.ReadString()!); + } + + var directory = Path.Combine(Path.GetTempPath(), $"cultlib-uidxf-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + CultCache? cache = null; + try + { + var path = Path.Combine(directory, "store.cc"); + foreach (var op in ops.Take(ops.Count - 1)) + Perform(ref cache, path, op); + + var last = ops[^1]; + if (refusal == null) + { + Assert.DoesNotThrow(() => Perform(ref cache, path, last), label); + } + else + { + cache?.FlushAsync().GetAwaiter().GetResult(); + var before = last.Kind == "open" && last.File != null ? Committed(last.File) + : last.Kind == "pull" ? Committed(last.File!) + : File.Exists(path) ? File.ReadAllBytes(path) : Array.Empty(); + CultCache? attempt = cache; + var refused = Assert.Throws(() => Perform(ref attempt, path, last), label)!; + Assert.That(refused.Message, Is.EqualTo( + $"Records {refusal.K1} and {refusal.K2} ({refusal.Schema}) both hold unique index '{refusal.Alias}'; an index value names one record."), label); + if (last.Kind != "open") cache?.FlushAsync().GetAwaiter().GetResult(); + Assert.That(File.Exists(path) ? File.ReadAllBytes(path) : Array.Empty(), Is.EqualTo(before), $"{label}: the file is untouched"); + } + + walked++; + } + finally + { + cache?.Dispose(); + Directory.Delete(directory, recursive: true); + } + } + + Assert.That(walked, Is.EqualTo(13)); + } } } diff --git a/tests/vectors/unique-index/README.md b/tests/vectors/unique-index/README.md index 548c8845..3717067c 100644 --- a/tests/vectors/unique-index/README.md +++ b/tests/vectors/unique-index/README.md @@ -18,7 +18,32 @@ reviewed commit. Other runtimes are held to the same canonical text and the same stored-at pinned to `2026-01-01T00:00:00Z`. Records `item:a` and `item:b` both hold Sku `S-1`; `item:c` holds Sku `" "` and `item:d` holds Sku `U+0085`. Written through a role Sku. - `blank-store.cc`: the same, only `item:c` and `item:d`. +- `pair-store.cc`, `surrogate-store.cc`, `types-store.cc`, `repull-before.cc`, `repull-after.cc`: single-file stores + written like `duplicate-store.cc`, through roles, for the rows of `refusals.msgpack` that open or pull them. Schemas + `tests.unique_item`, `tests.uq_pair` (Sku and Alt both unique), `tests.uq_alpha` and `tests.uq_beta`, each slots + `[0: Sku string, 1: second string]`. - `outcomes.msgpack`: rows `[file, unique alias or nil, open|refused, k1 or nil, k2 or nil]`. Opening `file` with that alias a unique index (nil: a role, no unique alias) yields the verdict. A refusal reads, byte for byte, `Records and (tests.unique_item) both hold unique index ''; an index value names one record.` with `k1 < k2` ordinal, never contains the value, and leaves the file byte-identical. + +## refusals.msgpack + +RULE: a unique-index refusal is a function of the state the admission would leave, never of staging, file or pull +order. Over every (type, unique alias, non-blank value) that state holds at two or more distinct keys, name the one +least by (alias, its least key, its second-least key, schema name), each compared by UTF-16 code unit (C# +`StringComparer.Ordinal`): `Records and () both hold unique index ''; an index value names one +record.` with `k1 < k2` its two least keys. One rule for a single write, a batch, a load, a re-pull and an index +registration. + +Rows `[label, ops, refusal]`. Each op is `["open", file-or-nil]` (copy that vector file to a fresh path, or nil for a +fresh path with no file, and open a cache on it), `["pull", file]` (overwrite the open cache's file with that vector +file, then pull every backing store again) or `["commit", [[key, schema, sku, second], ...]]` (one batch, staged in row +order; schema is the schema name). `refusal` is nil (every op succeeds) or `[k1, k2, schema, alias]`: every op but the +last succeeds and the last refuses with exactly the RULE text built from those fields, leaving the file +byte-identical. Schemas are those of the store files above; `second` is `Category` (plain) except in `tests.uq_pair`, +where it is `Alt` (unique). + +A runtime performs every row its API can perform and asserts the count it walked: C# and Rust 13; Python 12 (not +`batch-interleaved-types`: `put_envelopes` takes one type); TypeScript 7 (`held-then-landing` and the five load and +re-pull rows 8-13: no batch commit, `variants:idea:unique-index-runtimes.ts-batch-commit`). diff --git a/tests/vectors/unique-index/pair-store.cc b/tests/vectors/unique-index/pair-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..96a180fff7d1120cde1cc0876a8bd0824e1ffd36 GIT binary patch literal 1056 zcmchV&raJw5XQ@)4}z}^*8Z_Kwr`wz>j9$*R8`gN?rgAXJJh>DM2Lp~DwS&#sW+sm zPk^Gn1brGNaT+zKx3*Y&nfcb=@0;Dd`EDk4FkntF_F>#%e8#W7JiPWNAW~I`RA$B? zIIXPF28BS!xz43PqcfI(OI@zCkpO6&Q@MatSg8`0NQsKw1@ytklTRN`q3XVERYclw zn6ksna-o4ZAQ^X346Q?%fRa+>oFz)=G|y9!7@jf4d1_HAlMS;xwf5%@TtuFRXy6Sr z7!}bLDjKZCx7hh=+&Au8A)YJ0o#FLtDoce%GT!yY{ zN7SzT0XpokHKaPIJ(3x;(@hbe8(}Da%hyiontp68p6}Nc5H#u$mG1{w*<22OCE=SS z`*rx=NqWR~lJ}Qao7?)^S8;v&VouZEZJpy{alcr0Z+GUz|AR)|o6lb>I{5@u4XUbZ`+A5-GHvXjjw&94AE_*O(R5J>O5}~NkMDO*d{2Mqw)7hGK8DVZ8w?Mltg_R;rknd5?a_obg^@4iQc6iW|!cX4M7{iI9 zWZ}>^MLnW&;kMCYgSDnp3#CJ92KA)dcxXoGhky92+p?sKwZV^rvH*ex3q;`-LFR3a zdUsBu&dDqAI2t#T|7>Sz@PV7RN5|dEJ$6z~&etv}@wW&6ayq@9&Z_6@U+-eB3a-!2 zu4a>!!~GmR*3Hc(r@4E{B3e6)AM<0RfVH^iYg4lLq=s%mVHmxv@wW2fp0@enMP%8{xExNrdR z0IOABf<8@We^@}<8!FaZJpTOs*mH5O+tO>$M;JOkXfXU5o&R|J=#GG=BA2mJnPdjD zm51Xf6)cx+{2P0X!|H8)^lEh9vcq?my*sbE=Zqb%ch9-Vh#TNhti{9Jgf9#%S} zxYSEBW6?k?cmgb8iIOSfB9l5nu*98;uCM~S7>Sf*wX&kiEl;$HV!9#!tXHRjqc9ch zLWMydtjGnO1>gW1R}AWZVaO7}Dy$S?g8Yy;%YhGa)(Pr4ZSS}xgdd(vHF`rw$-qj!tJ5O25U{H7D|Ui2K8{c@zC_q_g?s{+p?sKwZU)OWdS4_%q0pp4>F@U>pVG$ zmQHrSV}H;L|FfNi!B=iRot-a#?yZwDx?KB3iEm%M*-|W p@H|Ivb@RB%Y3@F-h-#m)d{!ZwudUhc_r~bw_SHgKy}R_z)nCc|I=cV> literal 0 HcmV?d00001 diff --git a/tests/vectors/unique-index/surrogate-store.cc b/tests/vectors/unique-index/surrogate-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..4d0a1daa2799ca34f874b1ca12866607529aa12c GIT binary patch literal 1070 zcmc&y%TC-d6fIJJgls!Rj+1!e>=29Yx*%0IRaH6m^$<-S5Id+*g*5$y0i@23R#2%M z7OhyaKqGzvJ_7gzE)y7mrn|nZySdlL=V5#Ar)+X7h1>ww*s2D1+Z%trcxg5S4Oz_n zG>te>5c!00!aSiFXDRhH547^BPJKz0P`>0I01HBhgi;|RQK-ErqCYGcYuu*Hwi!6{AQsS-_xd9{-Wjk-Is0Uz^M?T9Y&+%ZX;KOR32^4iYiOh6C zW;8p!2PfX#$(pb*sOt7F+qpOR#Lc^%@%-g}_owVmo*iQ1<0p4Hot{narjw=b4>1=3 zXM6kKhBpV3(I4}2u}tyTtq>3EH6ih=T+*!*B04Dr~fXgsf`gl zf)OS~vZX<5>XmB+XD%OQ|xvXU6qs}`1J z4)(5`@Ycx#h}a)bX8+jErNIq1FAmU(Z@E~UEt=h<&gZK(w-e5; z5zQoeB|v44cu?MO=~HyMmM#OL#0rN@Th(wQAQj#Sqjc&5XC}|3QKu~@79)J!h}HoW zLRmu7(Ol2~1fo@@&L|Mz&>(W8vOxkeVYLaV@tmjx%}gc@dC4w1_&Y!wVfvLTdVj%Z zbN(OCt!eoap1YE<1D=P6Cs15pZo4`;zayAX_ZX-3l(AyDx@`5_T}D4{&p$mrZe&F} WpEoFL*Sg*HTKJ1@?OL}tCi@dSN>`Tv literal 0 HcmV?d00001 -- 2.47.3 From 8973efdd0bb605445df0d04abd8783aca181778b Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 09:03:39 +0000 Subject: [PATCH 2/4] cultcache C# tests: pin that a refusal orders keys by code unit, not case Co-Authored-By: Claude Sonnet 5.5 --- .../UniqueIndexVectorTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs index 257c4175..cf47bfbe 100644 --- a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs @@ -562,5 +562,21 @@ namespace GameCult.Caching.Tests Assert.That(walked, Is.EqualTo(13)); } + + // Not a vector row (the cross-runtime rows are fixed by the spec): keys order by code unit, so "B" precedes "a". + [Test] + public void RefusalOrdersKeysByCodeUnitNotByCase() + { + using var cache = CultCacheMessagePack.Create( + Path.Combine(Path.GetTempPath(), $"cultlib-uidxc-{Guid.NewGuid():N}.cc"), + new CultCacheOpenOptions { Registry = CultDocumentRegistry.ForTypes(new[] { ItemIndex }) }); + var refused = Assert.Throws(() => cache.Commit(batch => + { + foreach (var (key, sku) in new[] { ("a", "S"), ("z", "S"), ("B", "T"), ("C", "T") }) + batch.Upsert(ItemIndex, EmittedDocumentTypes.New(ItemIndex, ("Sku", sku), ("Category", "")), new CultRecordKey(key)); + }))!; + Assert.That(refused.Message, Is.EqualTo( + "Records B and C (tests.unique_item) both hold unique index 'Sku'; an index value names one record.")); + } } } -- 2.47.3 From c2391c61b3749191fcba0e65f9ed9ad84899841a Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 09:23:05 +0000 Subject: [PATCH 3/4] unique-index refusals vector: rows 14-19 pin code-unit, case, culture and alias ordering; README walk counts per row and schema ids Co-Authored-By: Claude Sonnet 5.5 --- .../UniqueIndexVectorTests.cs | 40 ++++++++++-------- tests/vectors/unique-index/README.md | 24 ++++++++--- tests/vectors/unique-index/alias-store.cc | Bin 0 -> 1060 bytes tests/vectors/unique-index/refusals.msgpack | Bin 1592 -> 2438 bytes 4 files changed, 40 insertions(+), 24 deletions(-) create mode 100644 tests/vectors/unique-index/alias-store.cc diff --git a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs index cf47bfbe..3e499f0f 100644 --- a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs @@ -310,6 +310,12 @@ namespace GameCult.Caching.Tests EmittedDocumentTypes.Emit("UqVecPairIndex", "tests.uq_pair", "tests.uq_pair.v1", new EmittedDocumentTypes.Field[] { new("Sku", typeof(string), 0, Index: "Sku"), new("Alt", typeof(string), 1, Index: "Alt") }), "Alt"), + new("tests.uq_alias", + EmittedDocumentTypes.Emit("UqVecAliasRole", "tests.uq_alias", "tests.uq_alias.v1", + new EmittedDocumentTypes.Field[] { new("Sku", typeof(string), 0, Role: "Sku"), new("Alt", typeof(string), 1, Role: "alt") }), + EmittedDocumentTypes.Emit("UqVecAliasIndex", "tests.uq_alias", "tests.uq_alias.v1", + new EmittedDocumentTypes.Field[] { new("Sku", typeof(string), 0, Index: "Sku"), new("Alt", typeof(string), 1, Index: "alt") }), + "Alt"), Plain("Alpha", "tests.uq_alpha"), Plain("Beta", "tests.uq_beta"), }; @@ -326,6 +332,7 @@ namespace GameCult.Caching.Tests private const string Item = "tests.unique_item"; private const string Pair = "tests.uq_pair"; + private const string Alias = "tests.uq_alias"; private const string Alpha = "tests.uq_alpha"; private const string Beta = "tests.uq_beta"; @@ -340,18 +347,20 @@ namespace GameCult.Caching.Tests { new("b:1", Alpha, "P"), new("a:1", Beta, "P"), new("b:2", Alpha, "P"), new("a:2", Beta, "P") }; + private static readonly Rec[] AliasRecords = { new("item:a", Alias, "S", "X"), new("item:b", Alias, "S", "Y"), new("item:c", Alias, "T", "Y") }; private static readonly Rec[] RepullBefore = Items_(("item:a1", "A"), ("item:b1", "C")); private static readonly Rec[] RepullAfter = Items_(("item:a1", "A"), ("item:b1", "B"), ("item:b2", "B"), ("item:c1", "A")); private static readonly (string File, Rec[] Records)[] RefusalStores = { ("pair-store.cc", PairRecords), ("surrogate-store.cc", SurrogateRecords), ("types-store.cc", TypeRecords), - ("repull-before.cc", RepullBefore), ("repull-after.cc", RepullAfter), + ("repull-before.cc", RepullBefore), ("repull-after.cc", RepullAfter), ("alias-store.cc", AliasRecords), }; private static readonly Refusal SurrogateRefusal = new("k\U0001F600", "k\uE000", Item, "Sku"); private static readonly Refusal TypesRefusal = new("a:1", "a:2", Beta, "Sku"); private static readonly Refusal PairRefusal = new("item:b", "item:c", Pair, "Alt"); + private static readonly Refusal AliasRefusal = new("item:a", "item:b", Alias, "Sku"); private static readonly Refusal RepullRefusal = new("item:a1", "item:c1", Item, "Sku"); private static readonly Case[] Cases = @@ -373,6 +382,17 @@ namespace GameCult.Caching.Tests new("load-repull-after", new[] { Open("repull-after.cc") }, RepullRefusal), new("commit-onto-a-loaded-holder", new[] { Open("repull-before.cc"), Commit(Items_(("item:z", "A"))) }, new Refusal("item:a1", "item:z", Item, "Sku")), + // Rows 14-19 extend the spec's floor of 13: orderings a code-point, case-insensitive or culture compare would flip. + new("batch-key-case", new[] { Open(), Commit(Items_(("a", "S"), ("z", "S"), ("B", "T"), ("C", "T"))) }, + new Refusal("B", "C", Item, "Sku")), + new("batch-key-punctuation", new[] { Open(), Commit(Items_(("_", "S"), ("a", "S"), ("Z", "T"), ("b", "T"))) }, + new Refusal("Z", "b", Item, "Sku")), + new("batch-key-case-in-a-group", new[] { Open(), Commit(Items_(("a", "S"), ("B", "S"))) }, + new Refusal("B", "a", Item, "Sku")), + new("batch-surrogate-groups", new[] { Open(), Commit(Items_(("\uE000", "S"), ("\uE000b", "S"), ("\U0001F600", "T"), ("\U0001F600b", "T"))) }, + new Refusal("\U0001F600", "\U0001F600b", Item, "Sku")), + new("batch-alias-case", new[] { Open(), Commit(AliasRecords) }, AliasRefusal), + new("load-alias-case", new[] { Open("alias-store.cc") }, AliasRefusal), }; private static byte[] RefusalStoreBytes(Rec[] records) @@ -560,23 +580,7 @@ namespace GameCult.Caching.Tests } } - Assert.That(walked, Is.EqualTo(13)); - } - - // Not a vector row (the cross-runtime rows are fixed by the spec): keys order by code unit, so "B" precedes "a". - [Test] - public void RefusalOrdersKeysByCodeUnitNotByCase() - { - using var cache = CultCacheMessagePack.Create( - Path.Combine(Path.GetTempPath(), $"cultlib-uidxc-{Guid.NewGuid():N}.cc"), - new CultCacheOpenOptions { Registry = CultDocumentRegistry.ForTypes(new[] { ItemIndex }) }); - var refused = Assert.Throws(() => cache.Commit(batch => - { - foreach (var (key, sku) in new[] { ("a", "S"), ("z", "S"), ("B", "T"), ("C", "T") }) - batch.Upsert(ItemIndex, EmittedDocumentTypes.New(ItemIndex, ("Sku", sku), ("Category", "")), new CultRecordKey(key)); - }))!; - Assert.That(refused.Message, Is.EqualTo( - "Records B and C (tests.unique_item) both hold unique index 'Sku'; an index value names one record.")); + Assert.That(walked, Is.EqualTo(19)); } } } diff --git a/tests/vectors/unique-index/README.md b/tests/vectors/unique-index/README.md index 3717067c..8c706de2 100644 --- a/tests/vectors/unique-index/README.md +++ b/tests/vectors/unique-index/README.md @@ -18,10 +18,11 @@ reviewed commit. Other runtimes are held to the same canonical text and the same stored-at pinned to `2026-01-01T00:00:00Z`. Records `item:a` and `item:b` both hold Sku `S-1`; `item:c` holds Sku `" "` and `item:d` holds Sku `U+0085`. Written through a role Sku. - `blank-store.cc`: the same, only `item:c` and `item:d`. -- `pair-store.cc`, `surrogate-store.cc`, `types-store.cc`, `repull-before.cc`, `repull-after.cc`: single-file stores +- `pair-store.cc`, `surrogate-store.cc`, `types-store.cc`, `repull-before.cc`, `repull-after.cc`, `alias-store.cc`: single-file stores written like `duplicate-store.cc`, through roles, for the rows of `refusals.msgpack` that open or pull them. Schemas - `tests.unique_item`, `tests.uq_pair` (Sku and Alt both unique), `tests.uq_alpha` and `tests.uq_beta`, each slots - `[0: Sku string, 1: second string]`. + `tests.unique_item`, `tests.uq_pair` (Sku and Alt both unique), `tests.uq_alias` (Sku and `alt` both unique), + `tests.uq_alpha` and `tests.uq_beta`, each slots `[0: Sku string, 1: second string]`; schema ids are the schema name + plus `.v1` (`tests.unique_item.v1`, `tests.uq_pair.v1`, `tests.uq_alias.v1`, `tests.uq_alpha.v1`, `tests.uq_beta.v1`). - `outcomes.msgpack`: rows `[file, unique alias or nil, open|refused, k1 or nil, k2 or nil]`. Opening `file` with that alias a unique index (nil: a role, no unique alias) yields the verdict. A refusal reads, byte for byte, `Records and (tests.unique_item) both hold unique index ''; an index value names one record.` @@ -44,6 +45,17 @@ last succeeds and the last refuses with exactly the RULE text built from those f byte-identical. Schemas are those of the store files above; `second` is `Category` (plain) except in `tests.uq_pair`, where it is `Alt` (unique). -A runtime performs every row its API can perform and asserts the count it walked: C# and Rust 13; Python 12 (not -`batch-interleaved-types`: `put_envelopes` takes one type); TypeScript 7 (`held-then-landing` and the five load and -re-pull rows 8-13: no batch commit, `variants:idea:unique-index-runtimes.ts-batch-commit`). +The 13 rows of the spec are a floor; rows 14-19 extend it with orderings that a code-point, case-insensitive or +culture compare would flip. Rows, in file order: 1 batch-later-value-staged-first, 2 batch-surrogate-keys, 3 +batch-interleaved-types, 4 batch-two-aliases, 5 held-then-landing, 6 held-in-the-least-group, 7 swap-off-a-held-value, +8 load-two-aliases, 9 load-surrogate-keys, 10 load-interleaved-types, 11 repull-held-pair, 12 load-repull-after, 13 +commit-onto-a-loaded-holder, 14 batch-key-case (`B` before `a`), 15 batch-key-punctuation (`Z` before `_` before `a`), +16 batch-key-case-in-a-group, 17 batch-surrogate-groups (U+1F600 before U+E000, the reverse of code-point order), 18 +batch-alias-case (alias `Sku` before `alt`), 19 load-alias-case. + +A runtime performs every row its API can perform and asserts the count it walked: + +- C# and Rust: all 19. +- Python: 18, every row but 3 (`put_envelopes` takes one type). +- TypeScript: 8, rows 5, 8, 9, 10, 11, 12, 13 and 19 (no batch commit, so no row 1-4, 6, 7 or 14-18; + `variants:idea:unique-index-runtimes.ts-batch-commit`). diff --git a/tests/vectors/unique-index/alias-store.cc b/tests/vectors/unique-index/alias-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..3ea51ef5737d2275373395d084133ae2da86860e GIT binary patch literal 1060 zcmchV&u-K(5XRd>AB0@HM7HDDNp3xH<3Os4szTM+8MhHh7Gf{zs_H}Zk3_GlO58xA zPf)FRiSjheW|tK!xB-pkGM>rz`)2Ixdpl`@1$&CI591!=5B&1u;|qTZB2|UfK#}AU zO_dp^IdW-oqnxk^GS`_EX(ds!)Ky$0N;;(&R>mo-62sk>fIj$m^8Of_8vNJIlvryL z7h0wkm@u}=GHrOC=2o*@GtPz63M!!>1to<_@9Fvwhtemdc6 zCwoo!-%emaY&v;!ak;*)zkL~3w_A5K?dOd>&gT#F#p&(#9gX;h8o9c@`Bc;93;1VN r9-j#&)N{ru_8BXNh}rVn9kOpbSF>{VdWAMmk3L+X?Ng)oXNNxkwMSNA literal 0 HcmV?d00001 diff --git a/tests/vectors/unique-index/refusals.msgpack b/tests/vectors/unique-index/refusals.msgpack index 674664bc53e153396024efd4b50e8182dee1f2db..6820a694ea540cfd7731fdfd48ddfb761e71a13a 100644 GIT binary patch delta 518 zcmZvYu};G<5QcFeMIeMgZUcg;GfEK?QpfTL?EqbZW2_RjiA&-POmt#T+<^@&O2+~N z2qwyV@C=AYz)sRe3T$`x-+lMry{%ot#jgeU3=T^hd}1Sp&&@8mG%*TDRE7Lg+ve30 zEQ#jbP&D`^fO<7Pdm{ zz2^wE-gG<@nH`^o{Wu=cKJljU7*p4ESj<1P9iZ$OnshbQ%DeZ?ZpdF#LJ;~s?I1?d zX?_aId>>R+)Dffa!9cAxmPS9fH~av=RUz+qkV#U9>oQZ)AS+2{S5GMy^&Au=uPGYh z!?DANTSpT{0v6Q53oB_BO>vw__Fw5FlWEztU9z|zn?kY;(G)XG~ Pqduu&JhPTM{15aCR3Oj# delta 10 RcmZn@-oe8-cO$bo8vqhr0_^|* -- 2.47.3 From 0c9c0442cc6c01d89309f3916bb2469579e71e7c Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 09:56:06 +0000 Subject: [PATCH 4/4] unique-index refusals vector: rows 20-22 load rows 14, 15, 17; README gives the real schema ids Rows 20-22 open stores holding the records of rows 14, 15 and 17, so a runtime with no batch commit (TS) still walks cross-group key order. Over the TS-walkable rows (5, 8-13, 19-22), hand mutants of CompareRefusals' first-key compare fail: code point row 22; OrdinalIgnoreCase 20, 22; InvariantCulture 20, 21; InvariantCultureIgnoreCase 20, 21. Rows 1-19 are byte-identical. The README now names the sha256 schema ids the store catalogs carry, not the versions, and counts tests.uq_alias's unique Alt. Co-Authored-By: Claude Opus 5.5 --- .../UniqueIndexVectorTests.cs | 23 ++++++++++---- tests/vectors/unique-index/README.md | 30 +++++++++++------- tests/vectors/unique-index/key-case-store.cc | Bin 0 -> 1177 bytes .../unique-index/key-punctuation-store.cc | Bin 0 -> 1177 bytes tests/vectors/unique-index/refusals.msgpack | Bin 2438 -> 2676 bytes .../unique-index/surrogate-groups-store.cc | Bin 0 -> 1189 bytes 6 files changed, 35 insertions(+), 18 deletions(-) create mode 100644 tests/vectors/unique-index/key-case-store.cc create mode 100644 tests/vectors/unique-index/key-punctuation-store.cc create mode 100644 tests/vectors/unique-index/surrogate-groups-store.cc diff --git a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs index 3e499f0f..44aef30d 100644 --- a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs @@ -350,11 +350,16 @@ namespace GameCult.Caching.Tests private static readonly Rec[] AliasRecords = { new("item:a", Alias, "S", "X"), new("item:b", Alias, "S", "Y"), new("item:c", Alias, "T", "Y") }; private static readonly Rec[] RepullBefore = Items_(("item:a1", "A"), ("item:b1", "C")); private static readonly Rec[] RepullAfter = Items_(("item:a1", "A"), ("item:b1", "B"), ("item:b2", "B"), ("item:c1", "A")); + private static readonly Rec[] KeyCaseRecords = Items_(("a", "S"), ("z", "S"), ("B", "T"), ("C", "T")); + private static readonly Rec[] KeyPunctuationRecords = Items_(("_", "S"), ("a", "S"), ("Z", "T"), ("b", "T")); + private static readonly Rec[] SurrogateGroupRecords = Items_(("\uE000", "S"), ("\uE000b", "S"), ("\U0001F600", "T"), ("\U0001F600b", "T")); private static readonly (string File, Rec[] Records)[] RefusalStores = { ("pair-store.cc", PairRecords), ("surrogate-store.cc", SurrogateRecords), ("types-store.cc", TypeRecords), ("repull-before.cc", RepullBefore), ("repull-after.cc", RepullAfter), ("alias-store.cc", AliasRecords), + ("key-case-store.cc", KeyCaseRecords), ("key-punctuation-store.cc", KeyPunctuationRecords), + ("surrogate-groups-store.cc", SurrogateGroupRecords), }; private static readonly Refusal SurrogateRefusal = new("k\U0001F600", "k\uE000", Item, "Sku"); @@ -362,6 +367,9 @@ namespace GameCult.Caching.Tests private static readonly Refusal PairRefusal = new("item:b", "item:c", Pair, "Alt"); private static readonly Refusal AliasRefusal = new("item:a", "item:b", Alias, "Sku"); private static readonly Refusal RepullRefusal = new("item:a1", "item:c1", Item, "Sku"); + private static readonly Refusal KeyCaseRefusal = new("B", "C", Item, "Sku"); + private static readonly Refusal KeyPunctuationRefusal = new("Z", "b", Item, "Sku"); + private static readonly Refusal SurrogateGroupRefusal = new("\U0001F600", "\U0001F600b", Item, "Sku"); private static readonly Case[] Cases = { @@ -383,16 +391,17 @@ namespace GameCult.Caching.Tests new("commit-onto-a-loaded-holder", new[] { Open("repull-before.cc"), Commit(Items_(("item:z", "A"))) }, new Refusal("item:a1", "item:z", Item, "Sku")), // Rows 14-19 extend the spec's floor of 13: orderings a code-point, case-insensitive or culture compare would flip. - new("batch-key-case", new[] { Open(), Commit(Items_(("a", "S"), ("z", "S"), ("B", "T"), ("C", "T"))) }, - new Refusal("B", "C", Item, "Sku")), - new("batch-key-punctuation", new[] { Open(), Commit(Items_(("_", "S"), ("a", "S"), ("Z", "T"), ("b", "T"))) }, - new Refusal("Z", "b", Item, "Sku")), + new("batch-key-case", new[] { Open(), Commit(KeyCaseRecords) }, KeyCaseRefusal), + new("batch-key-punctuation", new[] { Open(), Commit(KeyPunctuationRecords) }, KeyPunctuationRefusal), new("batch-key-case-in-a-group", new[] { Open(), Commit(Items_(("a", "S"), ("B", "S"))) }, new Refusal("B", "a", Item, "Sku")), - new("batch-surrogate-groups", new[] { Open(), Commit(Items_(("\uE000", "S"), ("\uE000b", "S"), ("\U0001F600", "T"), ("\U0001F600b", "T"))) }, - new Refusal("\U0001F600", "\U0001F600b", Item, "Sku")), + new("batch-surrogate-groups", new[] { Open(), Commit(SurrogateGroupRecords) }, SurrogateGroupRefusal), new("batch-alias-case", new[] { Open(), Commit(AliasRecords) }, AliasRefusal), new("load-alias-case", new[] { Open("alias-store.cc") }, AliasRefusal), + // Rows 20-22 load rows 14, 15 and 17's records, so a runtime with no batch commit still walks cross-group key order. + new("load-key-case", new[] { Open("key-case-store.cc") }, KeyCaseRefusal), + new("load-key-punctuation", new[] { Open("key-punctuation-store.cc") }, KeyPunctuationRefusal), + new("load-surrogate-groups", new[] { Open("surrogate-groups-store.cc") }, SurrogateGroupRefusal), }; private static byte[] RefusalStoreBytes(Rec[] records) @@ -580,7 +589,7 @@ namespace GameCult.Caching.Tests } } - Assert.That(walked, Is.EqualTo(19)); + Assert.That(walked, Is.EqualTo(22)); } } } diff --git a/tests/vectors/unique-index/README.md b/tests/vectors/unique-index/README.md index 8c706de2..ac4a40cd 100644 --- a/tests/vectors/unique-index/README.md +++ b/tests/vectors/unique-index/README.md @@ -18,11 +18,17 @@ reviewed commit. Other runtimes are held to the same canonical text and the same stored-at pinned to `2026-01-01T00:00:00Z`. Records `item:a` and `item:b` both hold Sku `S-1`; `item:c` holds Sku `" "` and `item:d` holds Sku `U+0085`. Written through a role Sku. - `blank-store.cc`: the same, only `item:c` and `item:d`. -- `pair-store.cc`, `surrogate-store.cc`, `types-store.cc`, `repull-before.cc`, `repull-after.cc`, `alias-store.cc`: single-file stores - written like `duplicate-store.cc`, through roles, for the rows of `refusals.msgpack` that open or pull them. Schemas +- `pair-store.cc`, `surrogate-store.cc`, `types-store.cc`, `repull-before.cc`, `repull-after.cc`, `alias-store.cc`, + `key-case-store.cc`, `key-punctuation-store.cc`, `surrogate-groups-store.cc`: single-file stores written like + `duplicate-store.cc`, through roles, for the rows of `refusals.msgpack` that open or pull them. Schemas `tests.unique_item`, `tests.uq_pair` (Sku and Alt both unique), `tests.uq_alias` (Sku and `alt` both unique), - `tests.uq_alpha` and `tests.uq_beta`, each slots `[0: Sku string, 1: second string]`; schema ids are the schema name - plus `.v1` (`tests.unique_item.v1`, `tests.uq_pair.v1`, `tests.uq_alias.v1`, `tests.uq_alpha.v1`, `tests.uq_beta.v1`). + `tests.uq_alpha` and `tests.uq_beta`, each slots `[0: Sku string, 1: second string]`, schema version the name plus + `.v1`. The schema ids C# writes in the store catalogs and on every record: + - `tests.unique_item`: `sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882` + - `tests.uq_pair`: `sha256:2538cceafb6dcbce22ef11b932ecb804af5f96bc3aaedff692a52d3640433e60` + - `tests.uq_alias`: `sha256:77a77a394ebd8bf71e14b9b6f2cea4978c25d4e705fd12364f660edbf6c63b1f` + - `tests.uq_alpha`: `sha256:53aeaf2ea9b25e85ade6b15fc04c7653e7b206c87e258f1aa74dd32a840a1389` + - `tests.uq_beta`: `sha256:45ed62a64c91f6d815d3ec975c4a4a1b2ce07b6e7262a32d828673cf10899586` - `outcomes.msgpack`: rows `[file, unique alias or nil, open|refused, k1 or nil, k2 or nil]`. Opening `file` with that alias a unique index (nil: a role, no unique alias) yields the verdict. A refusal reads, byte for byte, `Records and (tests.unique_item) both hold unique index ''; an index value names one record.` @@ -42,20 +48,22 @@ fresh path with no file, and open a cache on it), `["pull", file]` (overwrite th file, then pull every backing store again) or `["commit", [[key, schema, sku, second], ...]]` (one batch, staged in row order; schema is the schema name). `refusal` is nil (every op succeeds) or `[k1, k2, schema, alias]`: every op but the last succeeds and the last refuses with exactly the RULE text built from those fields, leaving the file -byte-identical. Schemas are those of the store files above; `second` is `Category` (plain) except in `tests.uq_pair`, -where it is `Alt` (unique). +byte-identical. Schemas are those of the store files above; `second` is `Category` (plain) except in `tests.uq_pair` +and `tests.uq_alias`, where it is `Alt` (unique, alias `Alt` and `alt` respectively). -The 13 rows of the spec are a floor; rows 14-19 extend it with orderings that a code-point, case-insensitive or +The 13 rows of the spec are a floor; rows 14-22 extend it with orderings that a code-point, case-insensitive or culture compare would flip. Rows, in file order: 1 batch-later-value-staged-first, 2 batch-surrogate-keys, 3 batch-interleaved-types, 4 batch-two-aliases, 5 held-then-landing, 6 held-in-the-least-group, 7 swap-off-a-held-value, 8 load-two-aliases, 9 load-surrogate-keys, 10 load-interleaved-types, 11 repull-held-pair, 12 load-repull-after, 13 commit-onto-a-loaded-holder, 14 batch-key-case (`B` before `a`), 15 batch-key-punctuation (`Z` before `_` before `a`), 16 batch-key-case-in-a-group, 17 batch-surrogate-groups (U+1F600 before U+E000, the reverse of code-point order), 18 -batch-alias-case (alias `Sku` before `alt`), 19 load-alias-case. +batch-alias-case (alias `Sku` before `alt`), 19 load-alias-case, 20 load-key-case, 21 load-key-punctuation, 22 +load-surrogate-groups. Rows 19-22 open a store holding the records of rows 18, 14, 15 and 17, so a runtime with no batch +commit still walks alias and cross-group key order. A runtime performs every row its API can perform and asserts the count it walked: -- C# and Rust: all 19. -- Python: 18, every row but 3 (`put_envelopes` takes one type). -- TypeScript: 8, rows 5, 8, 9, 10, 11, 12, 13 and 19 (no batch commit, so no row 1-4, 6, 7 or 14-18; +- C# and Rust: all 22. +- Python: 21, every row but 3 (`put_envelopes` takes one type). +- TypeScript: 11, rows 5, 8, 9, 10, 11, 12, 13, 19, 20, 21 and 22 (no batch commit, so no row 1-4, 6, 7 or 14-18; `variants:idea:unique-index-runtimes.ts-batch-commit`). diff --git a/tests/vectors/unique-index/key-case-store.cc b/tests/vectors/unique-index/key-case-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..b5032c8d4041f05dfb9dd52d09842bda4b0710b1 GIT binary patch literal 1177 zcmc(d%}(1u6oo z^#M@Sm!MC>B?%EI-IX6}HgkREeCOEn^G3C$Q=t0jTQ_LXKMYQO-EG@G@K~fW%=1Jt zjY-IaWFi0~q{w+_WMp*6O&%()L5E5Ph$4zH0j*=flGp@E!mk}#XZ^M=zO}etIIMmz zO^CSQMTs`3Arw3U7O^OoG2f3S|l^5hl`DariU(jlCy5hk`C4yk3Nsxnm*f!(ZJoBrUDl}UEXuA#IM)7tGot1!qE9~i{a{*M`;d@Pu73#Z*R)!`PL;h{{HA&&gZxD#q@mp+e6Jw!R>T9 w+kdhgcP}{IxLp>}8ZeeGSIAb+%jNFJ&gpD$F`XSgH?$xq{12#lZs_3R7daGo4gdfE literal 0 HcmV?d00001 diff --git a/tests/vectors/unique-index/refusals.msgpack b/tests/vectors/unique-index/refusals.msgpack index 6820a694ea540cfd7731fdfd48ddfb761e71a13a..d3cf4d3edf2a853644fc9707d421f4a95b01ca6e 100644 GIT binary patch delta 180 zcmZn@{vyJBhe2#3b2F#z_D*BXv|U` zN||t?AqffNgayJd&Lj;DkBknfk)fhm=uq(hfJHGRLhG23BsM{k&~pdYS-+|B9}RrV z9h8@I6C}=fQKAiK5i%MH5|K#qm{68*6@f759kVtwOtc|jF=CZ8tW6D#lng>V!EYDK z-=4)XrT8RD@KSFM=d~UMU!b;mxB8m~BcZH>QliF7zoKW+bzVwZUNyDNzBj0Fy{$1L z>sf5(XRgkx4vusC84T1=DST^0VFArV)thacs5{`YXF02;DDYsV;n&9^7YOyHh}=$# zOl-DWPf3DVl2zfL)2(}N9Op6Mi!>YCyR$#{+MBXBSUAGQXD`0xXmm9iPX~*qPc`QS zSN;C+{lQ@8pGCP@rg-UANPugdkaV(qGJQTwcGpXLhwH=s$(Squ1Ni7}=iU2e!~QX1 J-@R@&d;n6NfQ$eD literal 0 HcmV?d00001 -- 2.47.3