Dings: DingCatalog.Identity owns patch identity derivation (step 1) + migration plan #42

Merged
aqua merged 2 commits from aqua/dingcatalog-owner-step1 into main 2026-11-12 10:41:58 +00:00
6 changed files with 102 additions and 46 deletions

View file

@ -31,14 +31,14 @@ code. The grouping above follows who writes the key.
## A. `DingCatalog` instrument id and script
- Where: `src/AquaSynth.Dings/DingCatalog.cs:22-28` (four instruments), built by the
private `Instrument(...)` at `:48-50`, which writes the `Script` string (an AquaSynth patch script, `param path=...; voice wave=...`, that `FaustEmitter.EmitScript` lowers to Faust; not Faust source) and
- Where: `src/AquaSynth.Dings/DingCatalog.cs:29-35` (four instruments), built by the
private `Instrument(...)` at `:56-57`, which writes the `Script` string (an AquaSynth patch script, `param path=...; voice wave=...`, that `FaustEmitter.EmitScript` lowers to Faust; not Faust source) and
fixes `RootFrequency = 261.6256f` for all four.
- Keys on: the instrument `Id` string (`warm-bell`, `glass-chime`, `soft-triangle`,
`muted-pluck`), case-insensitive dictionary (`:28`). Events are a separate keyed
dictionary (`:9-20`, nine ids) and are not voice identity.
`muted-pluck`), case-insensitive dictionary (`:35`). Events are a separate keyed
dictionary (`:16-27`, nine ids) and are not voice identity.
- Lifetime: compiled in. Changing an entry means a new build of every consumer.
- Writer: whoever edits the file. `ValidateCuration` (`:31-46`) is the only gate.
- Writer: whoever edits the file. `ValidateCuration` (`:37-54`) is the only gate.
- Readers: `Host/DingsPlaybackHost.cs:66-67`, `Mcp/DingService.cs:9-10`,
`Mcp/AgentInstrumentRegistry.cs:11`, `Mcp/DingTools.cs:28,31,49`,
`tools/AquaLower/Program.cs:17`, and the tests `DingCatalogTests`.
@ -47,12 +47,15 @@ code. The grouping above follows who writes the key.
## B. Host and daemon derivation of the patch
The Host does not pass the catalog id through. It builds the daemon's identity fields
itself, and the daemon builds more keys from them.
The Host does not pass the catalog id through. It sends the daemon identity fields
derived from it by `DingCatalog.Identity` (`DingCatalog.cs:12-14`, added in step 1; before
it, at `965dfe8` (origin/main before this change), `AquaDaemonClient.cs:20` composed the strings inline), and the daemon
builds more keys from them.
- `Host/AquaDaemonClient.cs:20` per note: `patchId = "dings." + instrument.Id`,
`faustName = "dings_" + instrument.Id.Replace('-', '_')`, `revision = 1` (a literal),
`durationSeconds = 1.5f`, `controls["/ding/frequency"]`.
- `Host/AquaDaemonClient.cs:20-21` per note: `var identity = DingCatalog.Identity(instrument)`
gives `patchId = "dings." + id`, `faustName = "dings_" + id with - as _`, `revision = 1`
(a literal inside `Identity`); the same envelope sets `durationSeconds = 1.5f` and
`controls["/ding/frequency"]`.
- `src/AquaSynth.Faust/NativeFaustRuntime.cs:596-599`: the compile key is SHA-256 of
`"{revision}\n{faustName}\n{script}"`. `patchId` is not in it. So two ids with the same
name and script share a compile key; one id with an edited script gets a new key even
@ -84,13 +87,14 @@ itself, and the daemon builds more keys from them.
emitted source carry the rewritten name, while the compile key (`:596-599`) hashes the
raw `identity.FaustName`. Two raw names that sanitise alike compile alike but key
differently.
- The `dings_<id>` rule is written three times: `AquaDaemonClient.cs:20` and
`tools/AquaLower/Program.cs:19`, and
`DingCatalogTests.cs:45` (`LoweredDingsFedToTheFeelMeterMatchTheCatalog` builds the
name with the same expression). Nothing ties the three together except the test.
- The `dings_<id>` rule now lives once, in `DingCatalog.Identity`. `AquaDaemonClient.cs:20`
and `tools/AquaLower/Program.cs:19` call it; `LoweredDingsFedToTheFeelMeterMatchTheCatalog`
compares against a literal per instrument, and `CallersDeriveIdentityOnlyThroughTheCatalog`
fails if either caller stops calling it. (History: at `965dfe8` the rule was written three
times, in those two files and in the test at `DingCatalogTests.cs:45`.)
- Writer: the Host (fields) and the daemon (session id, file names). Readers: the
daemon's compile/render path and receipts; no Dings code reads the patch id back.
- `Host/AquaDaemonClient.cs:23-28` returns the WAV path from the receipt. The Host
- `Host/AquaDaemonClient.cs:24-29` returns the WAV path from the receipt. The Host
never asks the daemon which patch rendered it.
## C. MCP `AgentInstrumentRegistry`
@ -143,7 +147,7 @@ itself, and the daemon builds more keys from them.
ids; its comment (`:21`) says values "start from the Dings catalog instruments and
were not tuned by ear". The values have drifted from the catalog:
| id | catalog root / decay (`DingCatalog.cs:24-27`) | workshop `rootHz` / `decay` |
| id | catalog root / decay (`DingCatalog.cs:31-34`) | workshop `rootHz` / `decay` |
|---|---|---|
| warm-bell | 261.6256 / 1.15 | 261.6256 / 0.55 |
| glass-chime | 261.6256 / 0.85 | 329.6276 / 0.45 |
@ -177,10 +181,11 @@ itself, and the daemon builds more keys from them.
4. **Same id, different sound.** `warm-bell` is a catalog instrument (A), a workshop
identity (E) and the neutral fallback voice (D). Three definitions, parameter sets
that have drifted (table above).
5. **Derivation written three times.** `dings_<id>` in `AquaDaemonClient.cs:20`,
`AquaLower/Program.cs:19`, and the test; and the `dings.<id>` patch id appears in
`StoreFileNameTests` as a literal. A rename rule changed in one place breaks
store-file naming in another without a compile error.
5. **Derivation (resolved by step 1 for the code callers).** At `965dfe8` the `dings_<id>`
rule was written three times (`AquaDaemonClient.cs:20`, `AquaLower/Program.cs:19`, the
test). Now it is in `DingCatalog.Identity`. What remains: the `dings.<id>` patch id appears
in `StoreFileNameTests` as a literal, and the test pins literals, so a rename of the rule
fails those tests on purpose rather than silently moving store file names.
6. **Double validation.** `DingService.cs:9-11` and `DingsPlaybackHost.cs:66-68` each
decide whether an id is valid. Safe while both read one dictionary.
@ -259,18 +264,22 @@ user. Any process of that user can send `Play`. There is no per-caller identity
pipe: `DingsCommand` carries an instrument, not a caller, so the Host cannot know who
asked.
## PROPOSED: one owner (a recommendation for the stewarding decision, not decided)
**Status (2026-11-12): the owner is RULED.** The operator tier, relayed through Persona, ruled that `DingCatalog` is the single owner of Dings patch identity. The sections below were written as a proposal; they are kept as the analysis behind the ruling. Their per-step claims are still unverified plans, except step 1, which landed on branch `aqua/dingcatalog-owner-step1`.
## One owner: DingCatalog (ruled; analysis below was written as a PROPOSAL)
**Proposal.** `DingCatalog` (`src/AquaSynth.Dings`) becomes the only place that decides
what a voice is, and also the only place that derives the daemon keys from it. Its
instrument gains a content revision (a hash of `Script` plus the parameters that matter),
and one function in that assembly produces `PatchId`, `FaustName` and the revision for
the daemon (`AquaLower` and the test need only the faust-name part of it, not
the daemon (`AquaLower` needs only the faust-name part of it, not
`patchId` or revision). Everything else keys on the catalog id and takes the rest from the catalog.
End state versus this change: the content revision named above is deferred (step 3 of the Migration plan below). Until it lands, `revision` is the literal 1 and the catalog function returns it; the revision hash is not part of the first consolidation.
**What the others become.**
- B (Host/daemon): the Host stops composing `patchId`/`faustName`/`revision`; it calls
the catalog function. `AquaLower/Program.cs:19` and `DingCatalogTests.cs:45` call only the faust-name slice of it. The daemon
the catalog function. `AquaLower/Program.cs:19` calls only the faust-name slice of it (done in step 1); the test pins literals instead of calling it. The daemon
keeps its session and file naming; those are daemon-internal and derive from what it
is given.
- C (registry): stays in the bridge as a cache of choices, but its assignment function
@ -290,32 +299,15 @@ the daemon (`AquaLower` and the test need only the faust-name part of it, not
definition of the script, and is the only one the host cannot run without. C, D and E
are all consumers or approximations of it by name.
**Migration order** (each step independent and reversible):
1. Add the catalog function; switch `AquaDaemonClient.cs:20` and `AquaLower` onto it;
delete the test's private copy (see the oracle risk below). Pure refactor while
`revision` stays the literal 1: the compile key, and so the session id, stay equal
for unchanged scripts, which is checkable by comparing receipts before and after.
2. Decide the agent-identity question (below); change the registry key. Wire-visible:
existing sessions lose their hashed assignment.
3. Content revision: DEFERRED, not scheduled here. Replacing the literal `revision = 1`
with a catalog hash changes every compile key and session id (and the
`sessions/<...>.cc` file names), so it breaks step 1's equality check by design. It
needs its own decision on what feeds the hash and whether old session files are
left or removed.
4. Remove the duplicate gates in `DingService.cs:9-11` or have the Host's gate be the
single one (the bridge then only forwards). Keep whichever side the tests
(`DingsBridgeTests`) need for fail-fast, but one of them must be the owner.
5. Point the neutral fallback and Earcon Workshop at the catalog.
**Migration order.** One list only: the "Migration plan" section at the end of this note (the original five-item order was merged into it).
**Risks.**
- Step 2 changes what agents hear on first use after deploy; nobody has listened to a
before/after.
- The deferred content revision changes session ids and session file names, visible in
receipts but not read by anything I found.
- Oracle loss in step 1: deleting the test's private `"dings_" + id` copy makes
`LoweredDingsFedToTheFeelMeterMatchTheCatalog` compare the shared function against
itself. The test should keep an independent expected value (a literal per instrument
or the checked-in `.dsp` header) so a wrong rule still fails.
- Oracle loss in step 1 (handled): the guard test uses a literal per instrument, so it does
not compare the shared function against itself.
- Collapsing 8 senders onto 4 instruments would lose distinctness; the proposal does not
do that, and I am recommending against it without having listened to either set.
- Moving derivation to the catalog assembly makes `AquaSynth.Dings` the home of
@ -338,3 +330,21 @@ what the MCP bridge can learn about its caller.
and whether the Host's `Ping`/`GetVolume` have different callers in practice. The
pipe protocol above is from reading, not from a captured exchange. Nothing was built,
run, or listened to for this note.
## Migration plan (ruled 2026-11-12: DingCatalog is the owner)
Rules for every step: deprecate before removing (mark the old path with a pointer, keep it working, delete in a later pass after the window); `patchId`, `faustName`, `revision = 1`, session ids, compile keys and `sessions/<...>.cc` names stay byte-identical until step 3; no change to the Host or pipe protocol. Deprecation window: one merged release of the owner path with the old path still working and marked; removal only in a separate PR after the operator hears or reviews nothing regressed.
**Step 1 (done in this change).** `DingCatalog.Identity(instrument)` returns `PatchId`, `FaustName`, `Revision` (literal 1). `AquaDaemonClient` and `AquaLower --dings` call it. The guard test no longer carries a private copy of the rule: it compares against literals per instrument, and `IdentityPinsTheOldDerivedValuesForAllFourInstruments` pins the old derived values for all four. `CallersDeriveIdentityOnlyThroughTheCatalog` reads `AquaDaemonClient.cs` and `AquaLower/Program.cs` and fails if either lacks the `DingCatalog.Identity(` call or reintroduces a `"dings_"` / `$"dings` literal (a source check, not a behaviour check).
- Verify: the pin test; `DingCatalogTests` and `StoreFileNameTests`; `AquaLower --dings tools/aqua-feel/lowered-dings` leaves `git status` clean (the checked-in mirror is byte-identical).
- Not verified: the Host sending the same JSON to the daemon at runtime (compile-checked and read, not run against a daemon).
- Rollback: revert the commit; no stored state or wire format moved.
- Echo sites in this step: the `lowered-dings` mirror is tied to the catalog by `AquaLower` calling `Identity` and stays test-guarded. Earcon Workshop `IDENTITIES` is NOT tied: it is browser JS with its own voice model (`fmRatio`, `harmonics`, seconds-based decay) and shares only ids, so tying needs a generated file and a build step, which is step 5. It is marked deprecated with a pointer, and still works.
**Step 2. Agent identity for the registry** (`AgentInstrumentRegistry` key). Blocked on the open question (what the bridge can learn about its caller). Verify: a test that the same agent id maps to the same instrument across sessions, and listening before and after, which nobody has done. Deprecate: keep the session-id keyed path behind the new key for one release. Rollback: revert; assignments are an in-memory cache.
**Step 3. Content revision** (replace the literal 1 with a catalog hash). Deliberately breaks byte-identity: compile keys, session ids and `sessions/<...>.cc` names all change. Needs its own ruling on what feeds the hash and whether old session files are left or removed. Verify: receipts before and after, with the old keys listed as expected to change. Rollback: revert to literal 1; new session files stay as orphans, harmless.
**Step 4. One validation gate** (`DingService.cs:9-11` versus `DingsPlaybackHost.cs:66-68`). Make the Host the owner and let the bridge forward (the bridge's check, which `DingsBridgeTests` may need for fail-fast, stays as the deprecated path); keep the bridge check as deprecated fail-fast for one window. Verify: `DingsBridgeTests`. Rollback: revert; both gates read one dictionary, so behaviour is the same either way.
**Step 5. Echo sites by reference.** (a) Neutral fallback in `tools/dings-voices/build.mjs` names a catalog instrument id. (b) Earcon Workshop `IDENTITIES`: export the shared ids and root frequencies from the catalog into a checked-in file (as `lowered-dings` is), guarded by a test, and have `earcon.js` read it; the voice-model parameters stay workshop-owned and the drifted values (table in section E) need an owner's decision first. Deprecation: `IDENTITIES` keeps its inline values, marked, until the generated file is read in production. Verify: `tools/earcon-workshop/test.mjs`, the guard test, and an ear check, since the workshop values were never tuned to the catalog. Rollback: revert; the inline values still exist.

View file

@ -4,8 +4,15 @@ public sealed record DingNote(double Semitones, int DelayMilliseconds, float Gai
public sealed record DingEvent(string Id, string Meaning, IReadOnlyList<DingNote> Notes);
public sealed record DingInstrument(string Id, string Description, string TimbreFamily, string Script, float RootFrequency, float Brightness, float DecaySeconds, bool UsesNoise);
/// <summary>The daemon-facing identity of a catalog instrument. Revision is the literal 1 until the deferred content-revision step.</summary>
public sealed record DingPatchIdentity(string PatchId, string FaustName, int Revision);
public static class DingCatalog
{
/// <summary>The only derivation of PatchId, FaustName and revision from an instrument id. Host, AquaLower and the tests call this.</summary>
public static DingPatchIdentity Identity(DingInstrument instrument) =>
new($"dings.{instrument.Id}", $"dings_{instrument.Id.Replace('-', '_')}", 1);
public static readonly IReadOnlyDictionary<string, DingEvent> Events = new DingEvent[]
{
new("session.start", "Opening welcome", [new(0, 0), new(7, 220)]),

View file

@ -34,6 +34,30 @@ public sealed class DingCatalogTests
Assert.InRange(pair.Second.DelayMilliseconds - pair.First.DelayMilliseconds, 180, 320);
}
// Independent oracle: literals captured from the pre-DingCatalog.Identity derivation in AquaDaemonClient/AquaLower.
// Session ids, compile keys and store file names hang off these; they must not move without the deferred revision decision.
private static readonly Dictionary<string, (string PatchId, string FaustName)> Pinned = new()
{
["warm-bell"] = ("dings.warm-bell", "dings_warm_bell"),
["glass-chime"] = ("dings.glass-chime", "dings_glass_chime"),
["soft-triangle"] = ("dings.soft-triangle", "dings_soft_triangle"),
["muted-pluck"] = ("dings.muted-pluck", "dings_muted_pluck"),
};
private static Dictionary<string, string> PinnedFaustNames => Pinned.ToDictionary(x => x.Key, x => x.Value.FaustName);
[Fact]
public void IdentityPinsTheOldDerivedValuesForAllFourInstruments()
{
Assert.Equal(Pinned.Keys.Order(), DingCatalog.Instruments.Keys.Order());
foreach (var instrument in DingCatalog.Instruments.Values)
{
var identity = DingCatalog.Identity(instrument);
Assert.Equal(Pinned[instrument.Id].PatchId, identity.PatchId);
Assert.Equal(Pinned[instrument.Id].FaustName, identity.FaustName);
Assert.Equal(1, identity.Revision);
}
}
// tools/aqua-feel measures the lowered files in tools/aqua-feel/lowered-dings; regenerate them with
// `dotnet run --project tools/AquaLower -- --dings tools/aqua-feel/lowered-dings` when the catalog changes.
[Fact]
@ -42,7 +66,7 @@ public sealed class DingCatalogTests
var dir = Path.Combine(RepositoryRoot(), "tools", "aqua-feel", "lowered-dings");
foreach (var instrument in DingCatalog.Instruments.Values)
{
var expected = FaustEmitter.EmitScript(instrument.Script, new FaustExportOptions("dings_" + instrument.Id.Replace('-', '_'))).Source;
var expected = FaustEmitter.EmitScript(instrument.Script, new FaustExportOptions(PinnedFaustNames[instrument.Id])).Source;
Assert.Equal(expected, File.ReadAllText(Path.Combine(dir, instrument.Id + ".dsp")));
}
@ -60,6 +84,18 @@ public sealed class DingCatalogTests
}
}
// Negative check on callers: reverting either call site to an inline rule leaves the catalog tests green, so read the sources.
[Theory]
[InlineData("tools/AquaSynthDingsHost/AquaDaemonClient.cs")]
[InlineData("tools/AquaLower/Program.cs")]
public void CallersDeriveIdentityOnlyThroughTheCatalog(string relativePath)
{
var source = File.ReadAllText(Path.Combine(RepositoryRoot(), relativePath));
Assert.Contains("DingCatalog.Identity(", source, StringComparison.Ordinal);
Assert.DoesNotContain("\"dings_\"", source, StringComparison.Ordinal);
Assert.DoesNotContain("$\"dings", source, StringComparison.Ordinal);
}
private static string RepositoryRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);

View file

@ -16,7 +16,7 @@ if (args.Length == 2 && args[0] == "--dings")
var instruments = new List<object>();
foreach (var ins in DingCatalog.Instruments.Values.OrderBy(i => i.Id, StringComparer.Ordinal))
{
var export = FaustEmitter.EmitScript(ins.Script, new FaustExportOptions("dings_" + ins.Id.Replace('-', '_')));
var export = FaustEmitter.EmitScript(ins.Script, new FaustExportOptions(DingCatalog.Identity(ins).FaustName));
File.WriteAllText(Path.Combine(dingDir, ins.Id + ".dsp"), export.Source);
instruments.Add(new { id = ins.Id, rootFrequency = ins.RootFrequency, dsp = ins.Id + ".dsp", controlPath = "/ding/frequency", warnings = export.Warnings });
Console.WriteLine($"{ins.Id}: {export.Source.Length} chars, {export.Warnings.Count} warnings");

View file

@ -17,7 +17,8 @@ public sealed class AquaDaemonClient : IAsyncDisposable
{
EnsureStarted();
var id = $"ding-{Guid.NewGuid():N}";
var envelope = new { command = "instrument.sample", payload = new { commandId = id, patchId = $"dings.{instrument.Id}", faustName = $"dings_{instrument.Id.Replace('-', '_')}", script = instrument.Script, durationSeconds = 1.5f, gain, controls = new Dictionary<string, float> { ["/ding/frequency"] = frequency }, revision = 1 } };
var identity = DingCatalog.Identity(instrument);
var envelope = new { command = "instrument.sample", payload = new { commandId = id, patchId = identity.PatchId, faustName = identity.FaustName, script = instrument.Script, durationSeconds = 1.5f, gain, controls = new Dictionary<string, float> { ["/ding/frequency"] = frequency }, revision = identity.Revision } };
await process!.StandardInput.WriteLineAsync(JsonSerializer.Serialize(envelope, json));
await process.StandardInput.FlushAsync(cancellationToken);
var line = await process.StandardOutput.ReadLineAsync(cancellationToken) ?? throw new InvalidOperationException("Aqua daemon closed before returning a render receipt.");

View file

@ -17,6 +17,8 @@ export const EVENTS = {
waiting: { label: "Waiting", meaning: "Needs you: quiet, sparse, slow low sway, three soft notes", notes: [[0, 0, 0.55], [5, 450, 0.55], [0, 900, 0.55]] },
};
// DEPRECATED as an identity owner (ruled 2026-11-12: DingCatalog owns Dings patch identity; see
// docs/dings-patch-identity-authority-map.md, 'Migration plan'). Kept working; do not add ids or copy catalog values here.
// Timbre identities. rootHz in the calm mid register; fmRatio/fmIndex/fmDecay shape the bell; harmonics > 0 selects the
// additive triangle; attack is seconds. Values start from the Dings catalog instruments and were not tuned by ear.
export const IDENTITIES = {