From 3798fa27f3153c3427c0f8d9e35fad06fc969e6a Mon Sep 17 00:00:00 2001 From: Aqua Date: Wed, 11 Nov 2026 03:56:11 +0000 Subject: [PATCH 1/3] Docs: many-row members are [CultRole], not [CultIndex] (CultLib #90) CultLib #90 makes [CultIndex] unique at commit and load. The daemon's PatchId and SessionId members, and the speech store's RequestId and PayloadId, are shared by many rows, so they become [CultRole]; the CommandId of each receipt stays [CultIndex] (a receipt's key derives from its CommandId alone). Needs CultLib >= 7760219b: does not build at the 42dce361 pin. Co-Authored-By: Claude Sonnet 5.5 --- .../SpeechDistributedTraining.cs | 4 +- src/AquaSynth.Faust/AquaSynthDaemonService.cs | 26 ++--- .../CultIndexUniquenessTests.cs | 110 ++++++++++++++++++ .../SpeechDistributedTrainingTests.cs | 26 +++++ 4 files changed, 151 insertions(+), 15 deletions(-) create mode 100644 tests/AquaSynth.Dsl.Tests/CultIndexUniquenessTests.cs diff --git a/src/AquaSynth.Core/SpeechDistributedTraining.cs b/src/AquaSynth.Core/SpeechDistributedTraining.cs index ec994aa..4b8743a 100644 --- a/src/AquaSynth.Core/SpeechDistributedTraining.cs +++ b/src/AquaSynth.Core/SpeechDistributedTraining.cs @@ -32,7 +32,7 @@ public sealed record SpeechRenderResult( [property: CultName] string ResultId, [property: Key(1)] - [property: CultIndex("request")] + [property: CultRole("request")] string RequestId, [property: Key(2)] string WorkerId, [property: Key(3)] string RendererToolchain, @@ -150,7 +150,7 @@ public sealed record SpeechWorkerAdmissionReceipt( [property: CultName] string ReceiptId, [property: Key(1)] - [property: CultIndex("payload")] + [property: CultRole("payload")] string PayloadId, [property: Key(2)] string PeerId, [property: Key(3)] string LeaseId, diff --git a/src/AquaSynth.Faust/AquaSynthDaemonService.cs b/src/AquaSynth.Faust/AquaSynthDaemonService.cs index 3e34ca0..a01135b 100644 --- a/src/AquaSynth.Faust/AquaSynthDaemonService.cs +++ b/src/AquaSynth.Faust/AquaSynthDaemonService.cs @@ -95,14 +95,14 @@ public sealed record AquaSynthInstrumentOpenCommand( [MessagePackObject] public sealed record AquaSynthInstrumentControlCommand( [property: Key(0), CultName] string CommandId, - [property: Key(1), CultIndex("session")] string SessionId, + [property: Key(1), CultRole("session")] string SessionId, [property: Key(2)] Dictionary Controls); [CultDocument("aquasynth.command.instrument_block", AquaSynthDaemonSchemas.InstrumentBlockCommand)] [MessagePackObject] public sealed record AquaSynthInstrumentBlockCommand( [property: Key(0), CultName] string CommandId, - [property: Key(1), CultIndex("session")] string SessionId, + [property: Key(1), CultRole("session")] string SessionId, [property: Key(2)] int FrameCount = 128, [property: Key(3)] float Gain = 1.0f, [property: Key(4)] Dictionary? Controls = null); @@ -111,13 +111,13 @@ public sealed record AquaSynthInstrumentBlockCommand( [MessagePackObject] public sealed record AquaSynthInstrumentCloseCommand( [property: Key(0), CultName] string CommandId, - [property: Key(1), CultIndex("session")] string SessionId); + [property: Key(1), CultRole("session")] string SessionId); [CultDocument("aquasynth.compiled_instrument_session", AquaSynthDaemonSchemas.CompiledInstrumentSession)] [MessagePackObject] public sealed record AquaSynthCompiledInstrumentSession( [property: Key(0), CultName] string SessionId, - [property: Key(1), CultIndex("patch")] string PatchId, + [property: Key(1), CultRole("patch")] string PatchId, [property: Key(2)] string CompileKey, [property: Key(3)] string FaustName, [property: Key(4)] int Revision, @@ -134,7 +134,7 @@ public sealed record AquaSynthCompiledInstrumentSession( [MessagePackObject] public sealed record AquaSynthLiveInstrumentSessionState( [property: Key(0), CultName] string SessionId, - [property: Key(1), CultIndex("patch")] string PatchId, + [property: Key(1), CultRole("patch")] string PatchId, [property: Key(2)] string Status, [property: Key(3)] string OpenedAtUtc, [property: Key(4)] string UpdatedAtUtc, @@ -154,7 +154,7 @@ public sealed record AquaSynthLiveInstrumentSessionState( public sealed record AquaSynthPatchCompileReceipt( [property: Key(0), CultName] string ReceiptId, [property: Key(1), CultIndex("command")] string CommandId, - [property: Key(2), CultIndex("patch")] string PatchId, + [property: Key(2), CultRole("patch")] string PatchId, [property: Key(3)] string SessionId, [property: Key(4)] string Status, [property: Key(5)] string DecidedAtUtc, @@ -170,7 +170,7 @@ public sealed record AquaSynthPatchCompileReceipt( public sealed record AquaSynthRenderSampleReceipt( [property: Key(0), CultName] string RenderId, [property: Key(1), CultIndex("command")] string CommandId, - [property: Key(2), CultIndex("patch")] string PatchId, + [property: Key(2), CultRole("patch")] string PatchId, [property: Key(3)] string SessionId, [property: Key(4)] string Status, [property: Key(5)] string CompletedAtUtc, @@ -190,7 +190,7 @@ public sealed record AquaSynthRenderSampleReceipt( public sealed record AquaSynthAutomationStreamReceipt( [property: Key(0), CultName] string StreamRunId, [property: Key(1), CultIndex("command")] string CommandId, - [property: Key(2), CultIndex("patch")] string PatchId, + [property: Key(2), CultRole("patch")] string PatchId, [property: Key(3)] string SessionId, [property: Key(4)] string Status, [property: Key(5)] string CompletedAtUtc, @@ -210,8 +210,8 @@ public sealed record AquaSynthAutomationStreamReceipt( public sealed record AquaSynthInstrumentOpenReceipt( [property: Key(0), CultName] string ReceiptId, [property: Key(1), CultIndex("command")] string CommandId, - [property: Key(2), CultIndex("patch")] string PatchId, - [property: Key(3), CultIndex("session")] string SessionId, + [property: Key(2), CultRole("patch")] string PatchId, + [property: Key(3), CultRole("session")] string SessionId, [property: Key(4)] string Status, [property: Key(5)] string OpenedAtUtc, [property: Key(6)] int SampleRate, @@ -228,7 +228,7 @@ public sealed record AquaSynthInstrumentOpenReceipt( public sealed record AquaSynthInstrumentControlReceipt( [property: Key(0), CultName] string ReceiptId, [property: Key(1), CultIndex("command")] string CommandId, - [property: Key(2), CultIndex("session")] string SessionId, + [property: Key(2), CultRole("session")] string SessionId, [property: Key(3)] string Status, [property: Key(4)] string AppliedAtUtc, [property: Key(5)] int ControlCount, @@ -240,7 +240,7 @@ public sealed record AquaSynthInstrumentControlReceipt( public sealed record AquaSynthInstrumentBlockReceipt( [property: Key(0), CultName] string BlockId, [property: Key(1), CultIndex("command")] string CommandId, - [property: Key(2), CultIndex("session")] string SessionId, + [property: Key(2), CultRole("session")] string SessionId, [property: Key(3)] string Status, [property: Key(4)] string CompletedAtUtc, [property: Key(5)] ulong Sequence, @@ -258,7 +258,7 @@ public sealed record AquaSynthInstrumentBlockReceipt( public sealed record AquaSynthInstrumentCloseReceipt( [property: Key(0), CultName] string ReceiptId, [property: Key(1), CultIndex("command")] string CommandId, - [property: Key(2), CultIndex("session")] string SessionId, + [property: Key(2), CultRole("session")] string SessionId, [property: Key(3)] string Status, [property: Key(4)] string ClosedAtUtc, [property: Key(5)] bool Released, diff --git a/tests/AquaSynth.Dsl.Tests/CultIndexUniquenessTests.cs b/tests/AquaSynth.Dsl.Tests/CultIndexUniquenessTests.cs new file mode 100644 index 0000000..24afc9d --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/CultIndexUniquenessTests.cs @@ -0,0 +1,110 @@ +using System.Reflection; +using AquaSynth.Dsl; +using GameCult.Caching; +using GameCult.Caching.MessagePack; + +namespace AquaSynth.Dsl.Tests; + +// CultLib #90 (7760219b): [CultIndex] means unique, enforced at commit and load; [CultRole] names a many-row selection role. +// A member that two rows can share must be a role. The only [CultIndex] left is the CommandId of a receipt: every receipt is stored +// under a key derived from its CommandId alone (AquaSynthCultNetDaemon.*ReceiptKey), so a second row with that CommandId replaces the first. +public sealed class CultIndexUniquenessTests +{ + private static readonly string[] ReceiptTypes = + [ + nameof(AquaSynthPatchCompileReceipt), nameof(AquaSynthRenderSampleReceipt), nameof(AquaSynthAutomationStreamReceipt), nameof(AquaSynthInstrumentOpenReceipt), + nameof(AquaSynthInstrumentControlReceipt), nameof(AquaSynthInstrumentBlockReceipt), nameof(AquaSynthInstrumentCloseReceipt), + ]; + + [Fact] + public void TheOnlyCultIndexMembersAreReceiptCommandIds() + { + var indexed = new[] { typeof(AquaSynthDaemonService).Assembly, typeof(SpeechRenderResult).Assembly }.Distinct() + .SelectMany(assembly => assembly.GetTypes()) + .SelectMany(type => type.GetMembers(BindingFlags.Public | BindingFlags.Instance) + .Where(member => member is PropertyInfo or FieldInfo && member.GetCustomAttribute() is not null) + .Select(member => $"{type.Name}.{member.Name}")) + .OrderBy(name => name, StringComparer.Ordinal).ToArray(); + Assert.Equal(ReceiptTypes.Select(name => $"{name}.CommandId").OrderBy(name => name, StringComparer.Ordinal), indexed); + } + + [Fact] + public async Task ManyRowsMaySharePatchAndSessionAcrossDaemonDocuments() + { + var path = TempFile(); + try + { + using (var cache = await CultCacheMessagePack.OpenAsync(path)) + { + string[] paths = []; + for (var i = 0; i < 3; i++) + { + // Three commands, one patch, one session: the shape a client that opens, drives and closes one instrument produces. + await Put(cache, $"cmd-{i}-control", new AquaSynthInstrumentControlCommand($"cmd-{i}-control", "session-1", new Dictionary())); + await Put(cache, $"cmd-{i}-block", new AquaSynthInstrumentBlockCommand($"cmd-{i}-block", "session-1")); + await Put(cache, $"cmd-{i}-close", new AquaSynthInstrumentCloseCommand($"cmd-{i}-close", "session-1")); + await Put(cache, $"sess-{i}", new AquaSynthCompiledInstrumentSession($"sess-{i}", "patch-1", $"key-{i}", "f", 1, 48000, 0, 1, 128, 0.1f, "t", paths, paths)); + await Put(cache, $"live-{i}", new AquaSynthLiveInstrumentSessionState($"live-{i}", "patch-1", "open", "t", "t", "", 48000, 0, 1, 128, 1f, 0, new Dictionary(), "", "")); + await Put(cache, $"cmd-{i}-compile", new AquaSynthPatchCompileReceipt($"cmd-{i}-compile", $"cmd-{i}", "patch-1", "session-1", "succeeded", "t", "k", 1, paths, paths)); + await Put(cache, $"cmd-{i}-render", new AquaSynthRenderSampleReceipt($"cmd-{i}-render", $"cmd-{i}", "patch-1", "session-1", "succeeded", "t", 48000, 1, 0.1f, 0, 0, "", "", "")); + await Put(cache, $"cmd-{i}-stream", new AquaSynthAutomationStreamReceipt($"cmd-{i}-stream", $"cmd-{i}", "patch-1", "session-1", "succeeded", "t", "v", "a", "c", 48000, 128, 1, [], [])); + await Put(cache, $"cmd-{i}-open", new AquaSynthInstrumentOpenReceipt($"cmd-{i}-open", $"cmd-{i}", "patch-1", "session-1", "succeeded", "t", 48000, 0, 1, 128, paths, paths)); + await Put(cache, $"cmd-{i}-ctl", new AquaSynthInstrumentControlReceipt($"cmd-{i}-ctl", $"cmd-{i}", "session-1", "applied", "t", 0)); + await Put(cache, $"cmd-{i}-blk", new AquaSynthInstrumentBlockReceipt($"cmd-{i}-blk", $"cmd-{i}", "session-1", "succeeded", "t", 0, 48000, 128, 0, 0, "", "")); + await Put(cache, $"cmd-{i}-cls", new AquaSynthInstrumentCloseReceipt($"cmd-{i}-cls", $"cmd-{i}", "session-1", "closed", "t", true)); + } + + await cache.FlushAsync(); + } + + // Reopening hydrates and revalidates: a unique index would refuse the file here. + using var reloaded = await CultCacheMessagePack.OpenAsync(path); + Assert.Equal(3, reloaded.GetAll().Count()); + Assert.Equal(3, reloaded.GetAll().Count(session => session.PatchId == "patch-1")); + Assert.Equal(3, reloaded.GetAll().Count(receipt => receipt.SessionId == "session-1" && receipt.PatchId == "patch-1")); + Assert.Equal(3, reloaded.GetAll().Count(receipt => receipt.SessionId == "session-1")); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task OneRowPerCommandIdPerReceiptType() + { + var path = TempFile(); + try + { + using var cache = await CultCacheMessagePack.OpenAsync(path); + string[] paths = []; + AquaSynthPatchCompileReceipt Receipt(string command, string status) => + new(AquaSynthCultNetDaemon.CompileReceiptKey(command).Value, command, "patch-1", "session-1", status, "t", "k", 1, paths, paths); + foreach (var (command, status) in new[] { ("a", "failed"), ("a", "succeeded"), ("b", "succeeded") }) + { + var receipt = Receipt(command, status); + await cache.UpsertAsync(receipt, new CultRecordHandle(AquaSynthCultNetDaemon.CompileReceiptKey(command))); + } + + // A retried command replaces its own receipt; it cannot add a second row that carries the same CommandId. + var all = cache.GetAll().OrderBy(receipt => receipt.CommandId, StringComparer.Ordinal).ToArray(); + Assert.Equal(["a", "b"], all.Select(receipt => receipt.CommandId)); + Assert.Equal("succeeded", all[0].Status); + foreach (var kind in new Func[] { AquaSynthCultNetDaemon.CompileReceiptKey, AquaSynthCultNetDaemon.RenderReceiptKey, AquaSynthCultNetDaemon.StreamReceiptKey, + AquaSynthCultNetDaemon.OpenReceiptKey, AquaSynthCultNetDaemon.ControlReceiptKey, AquaSynthCultNetDaemon.BlockReceiptKey, AquaSynthCultNetDaemon.CloseReceiptKey }) + { + Assert.NotEqual(kind("a"), kind("b")); + Assert.Equal(kind("a"), kind("a")); + } + } + finally + { + File.Delete(path); + } + } + + private static Task Put(CultCache cache, string key, T document) where T : class => + cache.UpsertAsync(document, new CultRecordHandle(new CultRecordKey(key))); + + private static string TempFile() => Path.Combine(Path.GetTempPath(), $"aquasynth-cultindex-{Guid.NewGuid():N}.cc"); +} diff --git a/tests/AquaSynth.Dsl.Tests/SpeechDistributedTrainingTests.cs b/tests/AquaSynth.Dsl.Tests/SpeechDistributedTrainingTests.cs index 4c06e7a..15f564c 100644 --- a/tests/AquaSynth.Dsl.Tests/SpeechDistributedTrainingTests.cs +++ b/tests/AquaSynth.Dsl.Tests/SpeechDistributedTrainingTests.cs @@ -180,6 +180,8 @@ public sealed class SpeechDistributedTrainingTests Assert.True(after < before, $"expected CultMesh worker gradients to reduce loss; before={before}, after={after}"); Assert.Single(payloads); Assert.Equal(SpeechCultMeshRoles.RenderWorker, payloads[0].WorkerRole); + // One payload, four receipts: PayloadId is a role (many rows share it), not a unique index (CultLib #90). + Assert.Equal(4, admissions.Count(receipt => receipt.PayloadId == "payload-cultmesh-0001")); Assert.Equal(2, admissions.Count(receipt => receipt.Status == SpeechWorkerAdmissionStatus.Admitted)); Assert.Equal(2, admissions.Count(receipt => receipt.Status == SpeechWorkerAdmissionStatus.Rejected)); Assert.All(admissions.Where(receipt => receipt.Status == SpeechWorkerAdmissionStatus.Admitted), receipt => Assert.NotEmpty(receipt.AssignedRequestIds)); @@ -284,6 +286,30 @@ public sealed class SpeechDistributedTrainingTests return loss / actual.Count; } + [Fact] + public async Task OneRequestMayHaveAFailedAndAScoredResultInTheSameStore() + { + var directory = TempDirectory(); + try + { + var resultStore = Path.Combine(directory, "speech-results.cc"); + var request = SpeechDistributedTrainingCoordinator.CreateRenderRequests("batch-roles", Pipeline(), TrainingSet())[0]; + var scored = SpeechDistributedTrainingCoordinator.ScoreControlVectorRequests([request], "worker-a"); + var failed = SpeechDistributedTrainingCoordinator.ScoreControlVectorRequests([request with { CandidateControlVector = [] }], "worker-b"); + await SpeechDistributedTrainingCultCacheStore.UpsertResultsAsync(resultStore, [.. scored, .. failed]); + + // RequestId is a role: a retried request keeps its failed result beside the later scored one (CultLib #90 makes CultIndex unique). + var loaded = await SpeechDistributedTrainingCultCacheStore.ReadResultsAsync(resultStore); + Assert.Equal(2, loaded.Count(result => result.RequestId == request.RequestId)); + Assert.Contains(loaded, result => result.Status == SpeechRenderStatus.Failed); + Assert.Contains(loaded, result => result.Status == SpeechRenderStatus.Succeeded); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + private static string TempDirectory() { var path = Path.Combine(Path.GetTempPath(), $"aquasynth-speech-distributed-{Guid.NewGuid():N}"); -- 2.47.3 From 5d03490f2274a0d2cabe6a284d0da4ac0f6af819 Mon Sep 17 00:00:00 2001 From: Aqua Date: Wed, 11 Nov 2026 08:24:27 +0000 Subject: [PATCH 2/3] Pin CultLib abd1a92e; ElementId on every list element type CultLib 7760219b+ registers a document only if each list element class has a [CultElementId] string member. Found by registering every document type: AutomationControlFrame, AutomationPacketReceipt, CultMeshStreamDescriptor, CultCachePatchDatabaseEntry, PatchClaimCard, SpeechClaimCard, PrimitiveTimelineFact, SpeechScoreMetric, SpeechRenderArtifact, SpeechTimingReceipt, SpeechWorkerPayloadArtifact. Each gets a trailing keyed ElementId (default empty, minted on write; derived from Path/Id/StreamId/Sequence where the type already has a unique natural key). Wire is additive. Tests: a store written by the 42dce361 pin (fixture) reopens with stable unique ids and survives a write; derived packet ids. Co-Authored-By: Claude Sonnet 5.5 --- Directory.Build.props | 4 +- README.md | 6 +- src/AquaSynth.Core/CultCachePatchDocument.cs | 9 +- src/AquaSynth.Core/IpaTrialResults.cs | 3 +- .../SpeechDistributedTraining.cs | 12 +- src/AquaSynth.Faust/AquaSynthDaemonService.cs | 9 +- .../CultCachePatchDocumentTests.cs | 3 +- tests/AquaSynth.Dsl.Tests/ElementIdTests.cs | 103 ++++++++++++++++++ .../Fixtures/CultCache/pre-element-ids.cc | Bin 0 -> 6045 bytes 9 files changed, 132 insertions(+), 17 deletions(-) create mode 100644 tests/AquaSynth.Dsl.Tests/ElementIdTests.cs create mode 100644 tests/AquaSynth.Dsl.Tests/Fixtures/CultCache/pre-element-ids.cc diff --git a/Directory.Build.props b/Directory.Build.props index c8a3c67..ac7209e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,8 +2,8 @@ $(CULTLIB_ROOT) $(MSBuildThisFileDirectory)..\CultLib - - 42dce3611a81adc5c5fac54cadbaa5637d6022d3 + + abd1a92eb24e1af3fc7cdd1bf98a88dbdebc2938 C:\Program Files\Git\cmd\git.exe git diff --git a/README.md b/README.md index ee44b52..da440b3 100644 --- a/README.md +++ b/README.md @@ -156,11 +156,11 @@ handoff state lives in [`state/spine.yaml`](state/spine.yaml). ## Building against CultLib -Builds are pinned to CultLib 2.0.0 (tag `cultlib-unity-v2.0.0`, revision `42dce3611a81adc5c5fac54cadbaa5637d6022d3`); the build fails if the CultLib checkout differs or is dirty. Create a pinned worktree and point the build at it: +Builds are pinned to CultLib revision `abd1a92eb24e1af3fc7cdd1bf98a88dbdebc2938` (#91); the build fails if the CultLib checkout differs or is dirty. Create a pinned worktree and point the build at it: ```powershell -git -C worktree add --detach cultlib-unity-v2.0.0 +git -C worktree add --detach abd1a92eb24e1af3fc7cdd1bf98a88dbdebc2938 dotnet build -p:CultLibRoot= # or set CULTLIB_ROOT= ``` -Without an override the sibling `..\CultLib` checkout is used and must itself be at that tag. +Without an override the sibling `..\CultLib` checkout is used and must itself be at that revision. diff --git a/src/AquaSynth.Core/CultCachePatchDocument.cs b/src/AquaSynth.Core/CultCachePatchDocument.cs index 9e73596..12e9abe 100644 --- a/src/AquaSynth.Core/CultCachePatchDocument.cs +++ b/src/AquaSynth.Core/CultCachePatchDocument.cs @@ -27,7 +27,8 @@ public sealed record CultCachePatchDatabaseEntry( [property: Key(1)] string Family, [property: Key(2)] string Name, [property: Key(3)] string Source, - [property: Key(4)] string Notes); + [property: Key(4)] string Notes, + [property: Key(5), CultElementId("Path")] string ElementId = ""); [MessagePackObject] public sealed record CultCachePatchClaimCard( @@ -36,7 +37,8 @@ public sealed record CultCachePatchClaimCard( [property: Key(2)] CultCacheReferenceTruth Reference, [property: Key(3)] CultCacheIntentTruth Intent, [property: Key(4)] string Tier, - [property: Key(5)] CultCacheProofTruth Proof); + [property: Key(5)] CultCacheProofTruth Proof, + [property: Key(6), CultElementId("Id")] string ElementId = ""); [MessagePackObject] public sealed record CultCacheSpeechClaimCard( @@ -45,7 +47,8 @@ public sealed record CultCacheSpeechClaimCard( [property: Key(2)] CultCacheSpeechTargetTruth Target, [property: Key(3)] CultCacheIntentTruth Intent, [property: Key(4)] string Tier, - [property: Key(5)] CultCacheProofTruth Proof); + [property: Key(5)] CultCacheProofTruth Proof, + [property: Key(6), CultElementId("Id")] string ElementId = ""); [MessagePackObject] public sealed record CultCacheReferenceTruth( diff --git a/src/AquaSynth.Core/IpaTrialResults.cs b/src/AquaSynth.Core/IpaTrialResults.cs index 7957cc9..589f2c4 100644 --- a/src/AquaSynth.Core/IpaTrialResults.cs +++ b/src/AquaSynth.Core/IpaTrialResults.cs @@ -40,7 +40,8 @@ public sealed record PrimitiveTimelineFact( [property: Key(4)] string Unit, [property: Key(5)] int BlockStart, [property: Key(6)] int BlockEnd, - [property: Key(7)] string Summary); + [property: Key(7)] string Summary, + [property: Key(8), CultElementId] string ElementId = ""); [CultDocument("aquasynth.song_challenge_evidence", "aquasynth.song_challenge_evidence.v1")] [MessagePackObject] diff --git a/src/AquaSynth.Core/SpeechDistributedTraining.cs b/src/AquaSynth.Core/SpeechDistributedTraining.cs index 4b8743a..2c2f62d 100644 --- a/src/AquaSynth.Core/SpeechDistributedTraining.cs +++ b/src/AquaSynth.Core/SpeechDistributedTraining.cs @@ -67,7 +67,8 @@ public sealed record SpeechTimingReceipt( [property: Key(2)] double LatencyMilliseconds, [property: Key(3)] double BudgetMilliseconds, [property: Key(4)] float Confidence, - [property: Key(5)] string Notes); + [property: Key(5)] string Notes, + [property: Key(6), CultElementId] string ElementId = ""); [MessagePackObject] public sealed record SpeechUtteranceEmbeddingInputSnapshot( @@ -107,13 +108,15 @@ public sealed record SpeechPhoneticEventSnapshot( public sealed record SpeechScoreMetric( [property: Key(0)] string Name, [property: Key(1)] float Value, - [property: Key(2)] float Weight); + [property: Key(2)] float Weight, + [property: Key(3), CultElementId] string ElementId = ""); [MessagePackObject] public sealed record SpeechRenderArtifact( [property: Key(0)] string Kind, [property: Key(1)] string Uri, - [property: Key(2)] string ContentHash); + [property: Key(2)] string ContentHash, + [property: Key(3), CultElementId] string ElementId = ""); public enum SpeechRenderStatus { @@ -141,7 +144,8 @@ public sealed record SpeechWorkerPayloadArtifact( [property: Key(0)] string Kind, [property: Key(1)] string Uri, [property: Key(2)] string ContentHash, - [property: Key(3)] string MediaType); + [property: Key(3)] string MediaType, + [property: Key(4), CultElementId] string ElementId = ""); [CultDocument("aquasynth.speech_worker_admission_receipt", "aquasynth.speech_worker_admission_receipt.v1")] [MessagePackObject] diff --git a/src/AquaSynth.Faust/AquaSynthDaemonService.cs b/src/AquaSynth.Faust/AquaSynthDaemonService.cs index a01135b..907b518 100644 --- a/src/AquaSynth.Faust/AquaSynthDaemonService.cs +++ b/src/AquaSynth.Faust/AquaSynthDaemonService.cs @@ -77,7 +77,8 @@ public sealed record AquaSynthAutomationStreamCommand( [MessagePackObject] public sealed record AquaSynthAutomationControlFrame( [property: Key(0)] int Block, - [property: Key(1)] Dictionary Controls); + [property: Key(1)] Dictionary Controls, + [property: Key(2), CultElementId] string ElementId = ""); [CultDocument("aquasynth.command.instrument_open", AquaSynthDaemonSchemas.InstrumentOpenCommand)] [MessagePackObject] @@ -276,7 +277,8 @@ public sealed record AquaSynthCultMeshStreamDescriptor( [property: Key(6)] int Channels, [property: Key(7)] int FramesPerPacket, [property: Key(8)] string[] PreferredTransports, - [property: Key(9)] string? MetadataSchemaId); + [property: Key(9)] string? MetadataSchemaId, + [property: Key(10), CultElementId("StreamId")] string ElementId = ""); [MessagePackObject] public sealed record AquaSynthAutomationPacketReceipt( @@ -289,7 +291,8 @@ public sealed record AquaSynthAutomationPacketReceipt( [property: Key(6)] string Float32Uri, [property: Key(7)] string ContentHash, [property: Key(8)] string PageRef, - [property: Key(9)] int ControlCount); + [property: Key(9)] int ControlCount, + [property: Key(10), CultElementId("Sequence")] string ElementId = ""); [CultDocument("aquasynth.operator_state", AquaSynthDaemonSchemas.OperatorState)] [CultGlobal] diff --git a/tests/AquaSynth.Dsl.Tests/CultCachePatchDocumentTests.cs b/tests/AquaSynth.Dsl.Tests/CultCachePatchDocumentTests.cs index 2f55645..1c27b12 100644 --- a/tests/AquaSynth.Dsl.Tests/CultCachePatchDocumentTests.cs +++ b/tests/AquaSynth.Dsl.Tests/CultCachePatchDocumentTests.cs @@ -83,7 +83,8 @@ public sealed class CultCachePatchDocumentTests Path.Combine(RepositoryRoot(), "patches", "aquasynth-patch-cultcache.cc")); var fromCatalog = CultCachePatchDocumentCatalog.CreateDefault(); - Assert.Equal(fromCatalog.PatchDatabase.Entries, fromFile.PatchDatabase.Entries); + // The checked-in file predates element ids; a load mints them (derived from Path), so they are not part of the catalog's content. + Assert.Equal(fromCatalog.PatchDatabase.Entries, fromFile.PatchDatabase.Entries.Select(entry => entry with { ElementId = "" })); Assert.Equal(fromCatalog.PatchClaims.Select(claim => claim.Id), fromFile.PatchClaims.Select(claim => claim.Id)); Assert.Equal(fromCatalog.SpeechClaims.Select(claim => claim.Id), fromFile.SpeechClaims.Select(claim => claim.Id)); } diff --git a/tests/AquaSynth.Dsl.Tests/ElementIdTests.cs b/tests/AquaSynth.Dsl.Tests/ElementIdTests.cs new file mode 100644 index 0000000..dd90718 --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/ElementIdTests.cs @@ -0,0 +1,103 @@ +using AquaSynth.Dsl; +using GameCult.Caching; +using GameCult.Caching.MessagePack; + +namespace AquaSynth.Dsl.Tests; + +// CultLib abd1a92e refuses to register a document whose list element type has no [CultElementId] member (CultCache.cs:1008-1010); +// opening any cache here registers every AquaSynth document type, so these tests also fail if one element type loses its id. +public sealed class ElementIdTests +{ + // Fixtures/CultCache/pre-element-ids.cc was written by the previous pin (CultLib 42dce361), before list elements had ids: + // an automation-stream command with 2 control frames and a stream receipt with 2 stream descriptors and 2 packets. + [Fact] + public async Task AStoreWrittenBeforeElementIdsReopensWithStableUniqueIds() + { + var path = CopyFixture(); + try + { + string[] first; + using (var cache = await CultCacheMessagePack.OpenAsync(path)) + { + var command = cache.GetAll().Single(); + var receipt = cache.GetAll().Single(); + Assert.Equal([0, 4], command.ControlFrames!.Select(frame => frame.Block)); + Assert.Equal(220f, command.ControlFrames![0].Controls["/freq"]); + Assert.Equal(["audio-1", "control-1"], receipt.Streams.Select(stream => stream.StreamId)); + Assert.Equal([0UL, 1UL], receipt.Packets.Select(packet => packet.Sequence)); + first = Ids(command, receipt); + Assert.All(first, id => Assert.False(string.IsNullOrEmpty(id))); + Assert.Equal(2, command.ControlFrames!.Select(frame => frame.ElementId).Distinct().Count()); + Assert.Equal(2, receipt.Packets.Select(packet => packet.ElementId).Distinct().Count()); + } + + // A second reader of the same bytes mints the same ids (load mints from the record key and element path). + using var again = await CultCacheMessagePack.OpenAsync(path); + Assert.Equal(first, Ids(again.GetAll().Single(), again.GetAll().Single())); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task IdsSurviveAWriteAndReload() + { + var path = CopyFixture(); + try + { + string[] first; + using (var cache = await CultCacheMessagePack.OpenAsync(path)) + { + first = Ids(cache.GetAll().Single(), cache.GetAll().Single()); + // An unrelated write rewrites the store in the current shape. + await cache.UpsertAsync(new AquaSynthInstrumentCloseCommand("c-1", "s-1"), new CultRecordHandle(new CultRecordKey("c-1"))); + await cache.FlushAsync(); + } + + using var reloaded = await CultCacheMessagePack.OpenAsync(path); + Assert.Equal(first, Ids(reloaded.GetAll().Single(), reloaded.GetAll().Single())); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task NewWritesKeepNaturalIdsAndDeriveThePacketIdFromItsSequence() + { + var path = Path.Combine(Path.GetTempPath(), $"aquasynth-elementid-{Guid.NewGuid():N}.cc"); + try + { + using (var cache = await CultCacheMessagePack.OpenAsync(path)) + { + var packets = new[] { new AquaSynthAutomationPacketReceipt(7, 0, 1, 128, 0.5f, 0.1f, "u", "h", "p", 0), new AquaSynthAutomationPacketReceipt(9, 1, 1, 128, 0.5f, 0.1f, "u", "h", "p", 0) }; + var streams = new[] { new AquaSynthCultMeshStreamDescriptor("audio-9", "v", "p", "audio", "f32", 48000, 1, 128, [], null) }; + await cache.UpsertAsync(new AquaSynthAutomationStreamReceipt("run", "cmd", "patch", "session", "succeeded", "t", "v", "a", "c", 48000, 128, 1, streams, packets), + new CultRecordHandle(new CultRecordKey("run"))); + await cache.FlushAsync(); + } + + using var reloaded = await CultCacheMessagePack.OpenAsync(path); + var receipt = reloaded.GetAll().Single(); + Assert.Equal(["7", "9"], receipt.Packets.Select(packet => packet.ElementId)); + Assert.Equal("audio-9", receipt.Streams.Single().StreamId); + } + finally + { + File.Delete(path); + } + } + + private static string[] Ids(AquaSynthAutomationStreamCommand command, AquaSynthAutomationStreamReceipt receipt) => + [.. command.ControlFrames!.Select(frame => frame.ElementId), .. receipt.Packets.Select(packet => packet.ElementId)]; + + private static string CopyFixture() + { + var path = Path.Combine(Path.GetTempPath(), $"aquasynth-preids-{Guid.NewGuid():N}.cc"); + File.Copy(Path.Combine(AppContext.BaseDirectory, "Fixtures", "CultCache", "pre-element-ids.cc"), path); + return path; + } +} diff --git a/tests/AquaSynth.Dsl.Tests/Fixtures/CultCache/pre-element-ids.cc b/tests/AquaSynth.Dsl.Tests/Fixtures/CultCache/pre-element-ids.cc new file mode 100644 index 0000000000000000000000000000000000000000..6bc8bc8a094ad3a378bedb7bb38eccde96b87de8 GIT binary patch literal 6045 zcmd5=TW=dh6y7xHmFrIcBk@9FW_D-SyOv57j8gRhRK;8#N>$CyoQ z2}W53@`3Pt$gkp!@7kAx3RQKZh!M4QtZ|(i+_o`AMu8Bn5@I?w#{l4PrvR3Phz8uU zECRYtm=2U84ReFF-=eod8mzgZlBc2Y>Qq!c_eLOmp!FL*FsL>}`|UB5B(4;dN>pZm zL{UHl9E2&;Bsgw^fjQF=)ogA!wv9}}5CV;20}P_eM^z(^ZqR4v55LX@a?5r45v@b6 zm>UB?ZoXskDfofh#CTQ`wlu6m-Ii9l;x)N$%@esIn$Nk((&g9}%b|OPOHHxXi13ux z0s-~BlvUjo`FAx~1_yj_nUp%T7Jz&yb=T4dLH!jF<*q%sVXY<&T@GI^)~Zx4-uWj; z_ALAmZqFa#!6}}SSC?<*Cypk^l7-^kC6MBcWth|y(^V>1<>V1HqGd+o(Na8R2%-=; zaYVf**5@9Q!4Xu1!9=5{4~w2+k>S&*fdIZJJ`rr1n2Z!tl(8_~!z!d;7DHb3lBAz5 zBr}Cn@?7D2wf_SsD+N`iQ6Mc-0RusF#TA+w=jjWtihXg7vpW5L&0{yq)vu-sCo9u2 zoRSy1Vw&#BN^}xDT}5b7HvV$l_?KuHh@d`>hVGX`X6zSj(W!PE0hKv%4ve z7}VKo&OIo{eSMhh^poB`J51+HtJC*}3C}+6=|jU*&i&li7ltXFf0)(jpE|yZ%d>RV z{$BIKZdx{nNMCxGRk0!Bms_2p($>=AksrI5BmiEKrw}K3TOIk1k;$FB;?+l8NjJ;~ zuRZC_fgw8CL@oIkuy#=CjkABPXhn0A#sOhOl81)kv#J z;$dr&qh=qRtO1wf);~aCCfLZb&YBjg0XUPRn^{-Q3gZ;vWD``U6D;}i&f>rErEbd! z(WPwGx=gF?fd9&pB-J9KasrnseUYAxSj(_ljc<+m^X|lhtD7D@Z}Yj=RF2_35j7f3 zdiI*9cGL1Rq(wGoA0*{qm~8G?9&!439&zSzcMt|`JO&f!wzzgZAp&Q&qj*Z;#bK04 z&i#0FzJoMo<8I5V@dZD0Ul>bmzo{bCSio3*KSqV3W)_i^$C54R*m?({B0_)sEvf9$ z!cQUqL441+!0Nme)6WIG8zk3Q(XWs(01o_nv9lW8mb7SeO(7*Lxf@u(hrg{Kte@N1 zJdz}O+YJDJCszK!(Nw84bFhAA+YfnTZ! Date: Wed, 11 Nov 2026 08:31:21 +0000 Subject: [PATCH 3/3] ElementIdTests: pin the StreamId derivation Co-Authored-By: Claude Sonnet 5.5 --- tests/AquaSynth.Dsl.Tests/ElementIdTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/AquaSynth.Dsl.Tests/ElementIdTests.cs b/tests/AquaSynth.Dsl.Tests/ElementIdTests.cs index dd90718..3ba1d12 100644 --- a/tests/AquaSynth.Dsl.Tests/ElementIdTests.cs +++ b/tests/AquaSynth.Dsl.Tests/ElementIdTests.cs @@ -84,6 +84,7 @@ public sealed class ElementIdTests var receipt = reloaded.GetAll().Single(); Assert.Equal(["7", "9"], receipt.Packets.Select(packet => packet.ElementId)); Assert.Equal("audio-9", receipt.Streams.Single().StreamId); + Assert.Equal("audio-9", receipt.Streams.Single().ElementId); } finally { -- 2.47.3