From 033e126da38f32588b6ddb1bddc2012cae9f9644 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Sun, 13 Sep 2026 18:31:51 -0600 Subject: [PATCH 1/2] Define the task vocabulary once, and let the ABI ask about it The fourteen task kinds were written out in four places that nobody compared: the model-spec schema's allowed set, the model-spec parser, the runtime's parser and to_string, and include/audiocpp.h's prose. They had drifted in every direction. - schema.cpp allowed "codec", which no parser maps to a task kind, so model_specs/miocodec.json threw from parse_task_kind at load and the family was unreachable. - metadata.cpp accepted "audio_generation", which the schema rejects, so a spec using it failed validation instead. - model_specs/moss_voicegen.json declares "vdes", the ABI token, where the spec vocabulary wants "design" -- also unreachable. - audiocpp.h documented "diarization" and "alignment" as task spellings. Neither has ever parsed, and audiocpp_model_supports returns 0 for an unrecognised task, so a caller following the header was told "no" for every model that does diarize with nothing to say the question was malformed. - parse_voice_task_kind's error message listed the tokens a fifth time. runtime::task_vocabulary is now the single definition. The schema's allowed set, both parsers, to_string and the error messages all read it, so they cannot disagree. Adding a task kind is one row. The two specs that declared unmappable names are fixed: moss_voicegen uses "design", and miocodec drops "codec", which named no task kind the runtime can serve. If codec should be a real task, that is now one row rather than four edits. Three additive C entry points, because none of this was askable. Every task-aware call took an audiocpp_model, so a caller deciding what to install -- reading model_specs/*.json to build a picker before anything is downloaded -- had to hardcode a copy of the mapping table. Nine of the fourteen spellings are identical between the two vocabularies, so comparing them directly appears to work and silently drops the other five. size_t audiocpp_task_count(void); const char * audiocpp_task_name(size_t index); const char * audiocpp_task_from_spec_name(const char * spec_task); And one more, for a different conflation of the same shape: audiocpp_status audiocpp_request_set_text_language(audiocpp_request *, const char *); audiocpp_request_set_text writes options["language"] as well as the transcript language, mirroring the CLI's --language. The two could not be separated through the ABI, and they have to be: parakeet_tdt validates request options against its contract and refuses a "language" it does not declare, while qwen3_forced_aligner declares no language option and requires the transcript language anyway. So "not declared" means "must not send" for one and "must send" for the other, and no static check decides it. The reference server needs exactly this separation and gets it by reaching around the ABI -- drop_unsupported_language_option in app/server/runtime.cpp erases the option and keeps the transcript language. A C ABI client could not express that function. Tests: the vocabulary is asserted self-consistent in both directions, and every spec this repository ships is checked to declare task names the engine knows. Reverting the two spec fixes fails that test by name, which is how it was confirmed to test anything. --- CMakeLists.txt | 5 + include/audiocpp.h | 67 ++++++++++++- .../framework/runtime/task_vocabulary.h | 56 +++++++++++ model_specs/miocodec.json | 1 - model_specs/moss_voicegen.json | 2 +- src/capi/audiocpp.cpp | 42 ++++++++ src/framework/model_spec/metadata.cpp | 61 ++++-------- src/framework/model_spec/schema.cpp | 19 +++- src/framework/runtime/session.cpp | 96 +++++-------------- src/framework/runtime/task_vocabulary.cpp | 53 ++++++++++ tests/unittests/test_model_spec_system.cpp | 92 ++++++++++++++++++ 11 files changed, 373 insertions(+), 121 deletions(-) create mode 100644 include/engine/framework/runtime/task_vocabulary.h create mode 100644 src/framework/runtime/task_vocabulary.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 2e87d82b9..227bd3e81 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -411,6 +411,7 @@ add_library(engine_core OBJECT src/framework/runtime/model.cpp src/framework/model_spec/metadata.cpp src/framework/runtime/session.cpp + src/framework/runtime/task_vocabulary.cpp src/framework/runtime/artifacts.cpp src/framework/runtime/cache.cpp src/framework/runtime/graph_executor.cpp @@ -2538,6 +2539,10 @@ if (ENGINE_BUILD_TESTS OR ENGINE_BUILD_EXTENDED_TESTS OR ENGINE_BUILD_MODEL_TEST add_test(NAME transformer_kv_ring_test COMMAND transformer_kv_ring_test) add_engine_unittest(model_spec_system_test tests/unittests/test_model_spec_system.cpp) + # So the test can read the specs this repository ships, rather than a + # copy of them staged somewhere. + target_compile_definitions(model_spec_system_test + PRIVATE AUDIOCPP_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") target_include_directories(model_spec_system_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests/unittests) add_test(NAME model_spec_system_test COMMAND model_spec_system_test) endif() diff --git a/include/audiocpp.h b/include/audiocpp.h index 5ae74f8c3..3601da6eb 100644 --- a/include/audiocpp.h +++ b/include/audiocpp.h @@ -120,6 +120,33 @@ AUDIOCPP_API audiocpp_status audiocpp_registry_family(const audiocpp_registry * size_t index, const char ** out_family); +/* ---- Task vocabulary ------------------------------------------------------- + * + * The tasks this build knows, askable without a model. Every other task-aware + * entry point takes an audiocpp_model, so a caller that is deciding what to + * install -- reading model_specs/*.json to build a picker, say -- has had + * nothing to ask and has had to hardcode a copy of the table in + * src/framework/model_spec/metadata.cpp. + * + * Model specs and this ABI use different spellings for the same kinds: a spec + * says "music", "sfx", "edit" or "audio_generation" where this says "gen", + * "clone" for "clon", "design" for "vdes", "speaker" for "spk". Nine of the + * fourteen are identical, which is what makes comparing them directly appear + * to work. + */ + +/* How many task tokens this build accepts. */ +AUDIOCPP_API size_t audiocpp_task_count(void); + +/* The canonical token at `index`, or NULL when out of range. The returned + * pointer is static and outlives any call. */ +AUDIOCPP_API const char * audiocpp_task_name(size_t index); + +/* The canonical token for a model-spec task name ("music" -> "gen"), or NULL + * when the name names no task kind -- which is also how a caller detects a + * spec declaring a task this build cannot serve. */ +AUDIOCPP_API const char * audiocpp_task_from_spec_name(const char * spec_task); + /* ------------------------------------------------------------------ */ /* Model */ /* ------------------------------------------------------------------ */ @@ -129,6 +156,7 @@ AUDIOCPP_API audiocpp_status audiocpp_registry_family(const audiocpp_registry * * config_id --config, for packages that ship several configs * weight_id --weight, for packages that ship several weight sets * model_spec_override --model-spec-override */ + typedef struct audiocpp_model_config { const char * family_hint; const char * config_id; @@ -147,9 +175,20 @@ AUDIOCPP_API void audiocpp_model_free(audiocpp_model * model); AUDIOCPP_API const char * audiocpp_model_family(const audiocpp_model * model); AUDIOCPP_API const char * audiocpp_model_description(const audiocpp_model * model); -/* Capability queries. task/mode are the same spellings the CLI accepts, - * e.g. "tts", "asr", "vad", "diarization", "alignment" / "offline", - * "streaming". Returns 1 when supported, 0 when not or when unrecognised. */ +/* Capability queries. `task` is one of the tokens audiocpp_task_name() + * enumerates -- "vad", "asr", "diar", "sep", "gen", "tts", "clon", "vc", + * "s2s", "align", "vdes", "spk", "svc", "midi" -- and `mode` is "offline" or + * "streaming". + * + * Returns 1 when supported and 0 otherwise, which includes a task or mode this + * build does not recognise: a caller cannot tell a misspelled question from a + * negative answer. Validate against audiocpp_task_name() first if that + * distinction matters. + * + * (This comment previously gave "diarization" and "alignment" as examples. + * Neither has ever parsed, so a caller following it was told "no" for every + * model that does diarize, with nothing to indicate the question was + * malformed.) */ AUDIOCPP_API int audiocpp_model_supports(const audiocpp_model * model, const char * task, const char * mode); @@ -236,10 +275,32 @@ AUDIOCPP_API void audiocpp_request_free(audiocpp_request * request * "language" option, because that is what audiocpp_cli's --language does and * some families read only the option. A later audiocpp_request_set_option with * the same key overrides it. */ +/* Sets the request text, and -- when `language` is non-NULL and non-empty -- + * both the transcript language and options["language"], mirroring what + * audiocpp_cli's --language does. Some families read only the option, so the + * two travel together by default. + * + * That coupling cannot be undone through this call: pass NULL and use + * audiocpp_request_set_text_language() below to set the transcript language + * alone. Needed because "does this model declare a language option" and "does + * this model need a transcript language" are different questions with + * different answers -- parakeet_tdt validates its request options strictly and + * refuses a language it does not declare, while qwen3_forced_aligner declares + * no language option and requires the transcript language anyway. */ AUDIOCPP_API audiocpp_status audiocpp_request_set_text(audiocpp_request * request, const char * text, const char * language); +/* Sets the transcript language without touching options["language"]. + * + * The reference server needs exactly this and reaches around the ABI for it + * (drop_unsupported_language_option in app/server/runtime.cpp erases the option + * and keeps the transcript language). A C ABI client could not express that: + * set_text is the only way to reach the transcript language and it writes the + * option as a side effect, so the two arrived together or not at all. */ +AUDIOCPP_API audiocpp_status audiocpp_request_set_text_language(audiocpp_request * request, + const char * language); + /* Interleaved float PCM. Copied into the request, so `samples` need not * outlive the call. `frames` is per-channel. */ AUDIOCPP_API audiocpp_status audiocpp_request_set_audio(audiocpp_request * request, diff --git a/include/engine/framework/runtime/task_vocabulary.h b/include/engine/framework/runtime/task_vocabulary.h new file mode 100644 index 000000000..c3448d10f --- /dev/null +++ b/include/engine/framework/runtime/task_vocabulary.h @@ -0,0 +1,56 @@ +#pragma once + +#include "engine/framework/runtime/session.h" + +#include +#include + +namespace engine::runtime { + +/// One task kind, with every name any layer accepts for it. +/// +/// The canonical token is what the C ABI and the CLI take and what `to_string` +/// returns. The aliases are the spellings a model spec may use: the two +/// vocabularies differ for historical reasons (`gen` vs `music`/`sfx`/`edit`), +/// and this is the only place that difference is written down. +/// Every view here must refer to a string literal. +/// +/// The C ABI hands `token.data()` straight to callers as a `const char *`, so a +/// view over anything that is not NUL-terminated and statically allocated would +/// return a pointer into a temporary or an unterminated buffer. That holds for +/// the table below; it is a constraint on anything added to it. +struct TaskVocabularyEntry { + VoiceTaskKind kind; + std::string_view token; + /// Spec-side spellings, `aliases[alias_count]` onwards unused. + std::string_view aliases[4]; + std::size_t alias_count; +}; + +/// Every task kind this build knows, in enum order. +/// +/// This is the single definition of the task vocabulary. `to_string`, +/// `parse_voice_task_kind`, the model-spec schema's allowed task set, the +/// model-spec parser and the C ABI's enumeration all read it, so they cannot +/// disagree with one another. +/// +/// They used to. The schema accepted `codec`, which no parser mapped to a kind, +/// so `model_specs/miocodec.json` threw at load; the spec parser accepted +/// `audio_generation`, which the schema rejected, so a spec using it failed +/// validation instead; and `include/audiocpp.h` documented `"diarization"` and +/// `"alignment"`, which nothing accepts. Four hand-maintained lists of the same +/// fourteen things drift in four directions, and nothing was comparing them. +const TaskVocabularyEntry * task_vocabulary(std::size_t & count) noexcept; + +/// The canonical token for a spec-side task name, or an empty view when the +/// name names no task kind. +/// +/// `"music"` -> `"gen"`. Exposed because a caller that reads `model_specs/*.json` +/// -- a package browser, an installer, a binding building a picker before +/// anything is loaded -- needs the mapping and has no model to ask. +std::string_view task_token_for_spec_name(std::string_view spec_task) noexcept; + +/// Whether a spec may declare this task name. +bool is_spec_task_name(std::string_view value) noexcept; + +} // namespace engine::runtime diff --git a/model_specs/miocodec.json b/model_specs/miocodec.json index 8cb71e543..8c99b9725 100644 --- a/model_specs/miocodec.json +++ b/model_specs/miocodec.json @@ -5,7 +5,6 @@ "category": "audio_tools", "status": "supported", "tasks": [ - "codec", "vc", "s2s" ], diff --git a/model_specs/moss_voicegen.json b/model_specs/moss_voicegen.json index faeeb2422..5337467d4 100644 --- a/model_specs/moss_voicegen.json +++ b/model_specs/moss_voicegen.json @@ -5,7 +5,7 @@ "category": "community", "status": "community", "tasks": [ - "vdes" + "design" ], "modes": [ "offline" diff --git a/src/capi/audiocpp.cpp b/src/capi/audiocpp.cpp index 34db1c610..e62d49a2a 100644 --- a/src/capi/audiocpp.cpp +++ b/src/capi/audiocpp.cpp @@ -13,6 +13,8 @@ #include "audiocpp.h" +#include "engine/framework/runtime/task_vocabulary.h" + #include "engine/framework/core/backend.h" #include "engine/framework/core/module.h" #include "engine/framework/runtime/model.h" @@ -586,6 +588,46 @@ void audiocpp_request_free(audiocpp_request * request) { delete request; } +size_t audiocpp_task_count(void) { + size_t count = 0; + (void) rt::task_vocabulary(count); + return count; +} + +const char * audiocpp_task_name(size_t index) { + size_t count = 0; + const auto * entries = rt::task_vocabulary(count); + if (index >= count) { + return nullptr; + } + /* Every token is a string literal in the table, so this outlives any call + * and the caller never owns it. */ + return entries[index].token.data(); +} + +const char * audiocpp_task_from_spec_name(const char * spec_task) { + if (spec_task == nullptr) { + return nullptr; + } + const auto token = rt::task_token_for_spec_name(spec_task); + return token.empty() ? nullptr : token.data(); +} + +audiocpp_status audiocpp_request_set_text_language(audiocpp_request * request, const char * language) { + if (request == nullptr) { + return fail(AUDIOCPP_ERR_INVALID_ARGUMENT, "request must be non-null"); + } + return guard([&] { + /* Deliberately not touching request->request.options: that is the whole + * difference between this and set_text's language argument. */ + if (!request->request.text_input.has_value()) { + request->request.text_input = rt::Transcript{}; + } + request->request.text_input->language = language != nullptr ? language : ""; + return AUDIOCPP_OK; + }); +} + audiocpp_status audiocpp_request_set_text(audiocpp_request * request, const char * text, const char * language) { if (request == nullptr || text == nullptr) { return fail(AUDIOCPP_ERR_INVALID_ARGUMENT, "request and text must be non-null"); diff --git a/src/framework/model_spec/metadata.cpp b/src/framework/model_spec/metadata.cpp index 53e2715fb..18efe26d7 100644 --- a/src/framework/model_spec/metadata.cpp +++ b/src/framework/model_spec/metadata.cpp @@ -1,4 +1,5 @@ #include "engine/framework/model_spec/metadata.h" +#include "engine/framework/runtime/task_vocabulary.h" #include "engine/framework/model_spec/options.h" #include "engine/framework/model_spec/package.h" @@ -19,49 +20,25 @@ namespace { namespace json = engine::io::json; runtime::VoiceTaskKind parse_task_kind(const std::string & value) { - if (value == "vad") { - return runtime::VoiceTaskKind::Vad; - } - if (value == "asr") { - return runtime::VoiceTaskKind::Asr; - } - if (value == "diar") { - return runtime::VoiceTaskKind::Diarization; - } - if (value == "sep") { - return runtime::VoiceTaskKind::SourceSeparation; - } - if (value == "audio_generation" || value == "music" || value == "sfx" || value == "edit") { - return runtime::VoiceTaskKind::AudioGeneration; - } - if (value == "tts") { - return runtime::VoiceTaskKind::Tts; - } - if (value == "clone") { - return runtime::VoiceTaskKind::VoiceCloning; - } - if (value == "vc") { - return runtime::VoiceTaskKind::VoiceConversion; - } - if (value == "s2s") { - return runtime::VoiceTaskKind::SpeechToSpeech; - } - if (value == "align") { - return runtime::VoiceTaskKind::Alignment; - } - if (value == "design") { - return runtime::VoiceTaskKind::VoiceDesign; - } - if (value == "speaker") { - return runtime::VoiceTaskKind::SpeakerRecognition; - } - if (value == "svc") { - return runtime::VoiceTaskKind::Svc; - } - if (value == "midi") { - return runtime::VoiceTaskKind::Midi; + // Spec names, not ABI tokens: "music" here is "gen" there. The mapping is + // in runtime::task_vocabulary, which the schema's allowed set reads too, so + // a name the schema accepts cannot be one this rejects. + const auto token = runtime::task_token_for_spec_name(value); + if (!token.empty()) { + return runtime::parse_voice_task_kind(std::string(token)); + } + std::string expected; + std::size_t count = 0; + const auto * entries = runtime::task_vocabulary(count); + for (std::size_t i = 0; i < count; ++i) { + for (std::size_t alias = 0; alias < entries[i].alias_count; ++alias) { + if (!expected.empty()) { + expected += ", "; + } + expected.append(entries[i].aliases[alias]); + } } - throw std::runtime_error("unknown model spec task: " + value); + throw std::runtime_error("unknown model spec task: " + value + " (expected one of " + expected + ")"); } runtime::RunMode parse_run_mode(const std::string & value) { diff --git a/src/framework/model_spec/schema.cpp b/src/framework/model_spec/schema.cpp index ff8447979..5520999f1 100644 --- a/src/framework/model_spec/schema.cpp +++ b/src/framework/model_spec/schema.cpp @@ -1,4 +1,5 @@ #include "engine/framework/model_spec/schema.h" +#include "engine/framework/runtime/task_vocabulary.h" #include "engine/framework/model_spec/options.h" #include @@ -65,10 +66,20 @@ void require_spec_number(const json::Value & value, std::string_view path) { } const std::unordered_set & tasks() { - static const std::unordered_set values = { - "vad", "asr", "diar", "sep", "music", "sfx", "edit", "tts", "clone", "vc", - "s2s", "align", "design", "speaker", "svc", "codec", "midi", - }; + // Built from the one vocabulary rather than typed out again. The hand-kept + // copy had drifted both ways: it allowed "codec", which no parser maps to a + // task kind, and omitted "audio_generation", which the parser accepts. + static const std::unordered_set values = [] { + std::unordered_set names; + std::size_t count = 0; + const auto * entries = engine::runtime::task_vocabulary(count); + for (std::size_t i = 0; i < count; ++i) { + for (std::size_t alias = 0; alias < entries[i].alias_count; ++alias) { + names.emplace(entries[i].aliases[alias]); + } + } + return names; + }(); return values; } diff --git a/src/framework/runtime/session.cpp b/src/framework/runtime/session.cpp index a49f6e557..53113f921 100644 --- a/src/framework/runtime/session.cpp +++ b/src/framework/runtime/session.cpp @@ -1,4 +1,5 @@ #include "engine/framework/runtime/session.h" +#include "engine/framework/runtime/task_vocabulary.h" #include "engine/framework/text/chunking.h" #include @@ -89,35 +90,12 @@ void DiscreteGraphCapacityAdapter::prepare_capacity(int64_t capacity) { } const char * to_string(VoiceTaskKind task) noexcept { - switch (task) { - case VoiceTaskKind::Vad: - return "vad"; - case VoiceTaskKind::Asr: - return "asr"; - case VoiceTaskKind::Diarization: - return "diar"; - case VoiceTaskKind::SourceSeparation: - return "sep"; - case VoiceTaskKind::AudioGeneration: - return "gen"; - case VoiceTaskKind::Tts: - return "tts"; - case VoiceTaskKind::VoiceCloning: - return "clon"; - case VoiceTaskKind::VoiceConversion: - return "vc"; - case VoiceTaskKind::SpeechToSpeech: - return "s2s"; - case VoiceTaskKind::Alignment: - return "align"; - case VoiceTaskKind::VoiceDesign: - return "vdes"; - case VoiceTaskKind::SpeakerRecognition: - return "spk"; - case VoiceTaskKind::Svc: - return "svc"; - case VoiceTaskKind::Midi: - return "midi"; + std::size_t count = 0; + const auto * entries = task_vocabulary(count); + for (std::size_t i = 0; i < count; ++i) { + if (entries[i].kind == task) { + return entries[i].token.data(); + } } return "unknown"; } @@ -156,49 +134,27 @@ const char * to_string(GraphCapacityMode mode) noexcept { } VoiceTaskKind parse_voice_task_kind(const std::string & value) { - if (value == "vad") { - return VoiceTaskKind::Vad; - } - if (value == "asr") { - return VoiceTaskKind::Asr; - } - if (value == "diar") { - return VoiceTaskKind::Diarization; - } - if (value == "sep") { - return VoiceTaskKind::SourceSeparation; - } - if (value == "gen") { - return VoiceTaskKind::AudioGeneration; - } - if (value == "tts") { - return VoiceTaskKind::Tts; - } - if (value == "clon") { - return VoiceTaskKind::VoiceCloning; - } - if (value == "vc") { - return VoiceTaskKind::VoiceConversion; - } - if (value == "s2s") { - return VoiceTaskKind::SpeechToSpeech; - } - if (value == "align") { - return VoiceTaskKind::Alignment; - } - if (value == "vdes") { - return VoiceTaskKind::VoiceDesign; - } - if (value == "spk") { - return VoiceTaskKind::SpeakerRecognition; - } - if (value == "svc") { - return VoiceTaskKind::Svc; + std::size_t count = 0; + const auto * entries = task_vocabulary(count); + for (std::size_t i = 0; i < count; ++i) { + if (entries[i].token == value) { + return entries[i].kind; + } } - if (value == "midi") { - return VoiceTaskKind::Midi; + // The list in this message was a fifth copy of the vocabulary; it is built + // from the table now, so a task kind added later cannot leave behind an + // error message that says it does not exist. + std::string expected; + for (std::size_t i = 0; i < count; ++i) { + if (i != 0) { + expected += ", "; + } + if (i + 1 == count) { + expected += "or "; + } + expected.append(entries[i].token); } - throw std::runtime_error("unsupported task: " + value + " (expected vad, asr, diar, sep, gen, tts, clon, vc, s2s, align, vdes, spk, svc, or midi)"); + throw std::runtime_error("unsupported task: " + value + " (expected " + expected + ")"); } RunMode parse_run_mode(const std::string & value) { diff --git a/src/framework/runtime/task_vocabulary.cpp b/src/framework/runtime/task_vocabulary.cpp new file mode 100644 index 000000000..196b94d09 --- /dev/null +++ b/src/framework/runtime/task_vocabulary.cpp @@ -0,0 +1,53 @@ +#include "engine/framework/runtime/task_vocabulary.h" + +namespace engine::runtime { + +namespace { + +/// The vocabulary, in enum order. +/// +/// Adding a task kind means adding one row here. Nothing else enumerates them, +/// which is the point: the four lists this replaced were edited independently +/// and drifted. +constexpr TaskVocabularyEntry kVocabulary[] = { + {VoiceTaskKind::Vad, "vad", {"vad"}, 1}, + {VoiceTaskKind::Asr, "asr", {"asr"}, 1}, + {VoiceTaskKind::Diarization, "diar", {"diar"}, 1}, + {VoiceTaskKind::SourceSeparation, "sep", {"sep"}, 1}, + // The one genuinely many-to-one row: a spec says what the audio is for, + // the runtime has a single generation kind. + {VoiceTaskKind::AudioGeneration, "gen", {"music", "sfx", "edit", "audio_generation"}, 4}, + {VoiceTaskKind::Tts, "tts", {"tts"}, 1}, + {VoiceTaskKind::VoiceCloning, "clon", {"clone"}, 1}, + {VoiceTaskKind::VoiceConversion, "vc", {"vc"}, 1}, + {VoiceTaskKind::SpeechToSpeech, "s2s", {"s2s"}, 1}, + {VoiceTaskKind::Alignment, "align", {"align"}, 1}, + {VoiceTaskKind::VoiceDesign, "vdes", {"design"}, 1}, + {VoiceTaskKind::SpeakerRecognition, "spk", {"speaker"}, 1}, + {VoiceTaskKind::Svc, "svc", {"svc"}, 1}, + {VoiceTaskKind::Midi, "midi", {"midi"}, 1}, +}; + +} // namespace + +const TaskVocabularyEntry * task_vocabulary(std::size_t & count) noexcept { + count = sizeof(kVocabulary) / sizeof(kVocabulary[0]); + return kVocabulary; +} + +std::string_view task_token_for_spec_name(std::string_view spec_task) noexcept { + for (const auto & entry : kVocabulary) { + for (std::size_t i = 0; i < entry.alias_count; ++i) { + if (entry.aliases[i] == spec_task) { + return entry.token; + } + } + } + return {}; +} + +bool is_spec_task_name(std::string_view value) noexcept { + return !task_token_for_spec_name(value).empty(); +} + +} // namespace engine::runtime diff --git a/tests/unittests/test_model_spec_system.cpp b/tests/unittests/test_model_spec_system.cpp index c190f8a3c..378bd3c78 100644 --- a/tests/unittests/test_model_spec_system.cpp +++ b/tests/unittests/test_model_spec_system.cpp @@ -1,4 +1,5 @@ #include "engine/framework/model_spec/metadata.h" +#include "engine/framework/runtime/task_vocabulary.h" #include "engine/framework/model_spec/package.h" #include "engine/framework/model_spec/schema.h" #include "engine/framework/io/json.h" @@ -9,6 +10,7 @@ #include #include #include +#include namespace { @@ -1342,6 +1344,94 @@ void test_loading_and_resource_bundle() { std::filesystem::remove_all(root); } +// Every task name is written down once, and the surfaces that read it agree. +// +// They did not. The schema allowed "codec", which no parser mapped to a task +// kind, so model_specs/miocodec.json threw on load; the spec parser accepted +// "audio_generation", which the schema rejected; include/audiocpp.h documented +// "diarization" and "alignment", which nothing accepts; and no test read the +// shipped specs, so none of it surfaced here. +void test_task_vocabulary_is_consistent() { + std::size_t count = 0; + const auto * entries = engine::runtime::task_vocabulary(count); + engine::test::require(count > 0, "task vocabulary should not be empty"); + + for (std::size_t i = 0; i < count; ++i) { + const auto & entry = entries[i]; + const std::string token(entry.token); + + // The canonical token round-trips through the ABI's own parser. + engine::test::require( + engine::runtime::parse_voice_task_kind(token) == entry.kind, + "task token should parse back to its kind: " + token); + engine::test::require_eq( + std::string(engine::runtime::to_string(entry.kind)), + token, + "to_string should return the canonical token"); + + // Every spec alias maps to that token. + engine::test::require(entry.alias_count > 0, + "task kind should have at least one spec name: " + token); + for (std::size_t alias = 0; alias < entry.alias_count; ++alias) { + const std::string name(entry.aliases[alias]); + engine::test::require_eq( + std::string(engine::runtime::task_token_for_spec_name(name)), + token, + "spec name should map to its token: " + name); + } + } + + // A name that is neither is rejected rather than guessed at. + engine::test::require( + engine::runtime::task_token_for_spec_name("codec").empty(), + "a name that maps to no task kind should report as such"); + engine::test::require( + engine::runtime::task_token_for_spec_name("diarization").empty(), + "the header's old example spelling should not silently resolve"); +} + +// Every task name the shipped specs declare is one the engine can serve. +// +// The two that were not -- moss_voicegen declaring the ABI token "vdes" where +// the spec vocabulary wants "design", and miocodec declaring "codec", which +// names no task kind -- were found by a binding reading the spec files, not by +// anything here. Both threw from parse_task_kind at load, so the families were +// unreachable. +void test_shipped_model_specs_declare_known_tasks() { + const std::filesystem::path specs = + std::filesystem::path(AUDIOCPP_SOURCE_DIR) / "model_specs"; + engine::test::require(std::filesystem::is_directory(specs), + "model_specs should exist at " + specs.string()); + + std::vector failures; + std::size_t checked = 0; + for (const auto & entry : std::filesystem::directory_iterator(specs)) { + if (entry.path().extension() != ".json") { + continue; + } + const auto spec = engine::model_spec::load_spec(entry.path()); + const auto * tasks = spec.find("tasks"); + if (tasks == nullptr || !tasks->is_array()) { + continue; + } + ++checked; + for (const auto & task : tasks->as_array()) { + const auto name = task.as_string(); + if (engine::runtime::task_token_for_spec_name(name).empty()) { + failures.push_back(entry.path().filename().string() + " declares '" + name + "'"); + } + } + } + + for (const auto & failure : failures) { + std::cerr << " unmappable task: " << failure << "\n"; + } + engine::test::require(failures.empty(), + "every shipped spec should declare task names the engine knows (" + + std::to_string(failures.size()) + " did not)"); + engine::test::require(checked > 0, "should have checked at least one shipped spec"); +} + } // namespace int main() { @@ -1358,6 +1448,8 @@ int main() { test_experimental_spec_without_installable_package(); test_contract_spec_prefers_workspace_over_package_local_spec(); test_loading_and_resource_bundle(); + test_task_vocabulary_is_consistent(); + test_shipped_model_specs_declare_known_tasks(); } catch (const std::exception & error) { std::cerr << "model_spec_system_test failed: " << error.what() << "\n"; return 1; From d476f48f20d84c2b2f06b896605dbce1f2de0988 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Sun, 13 Sep 2026 19:16:25 -0600 Subject: [PATCH 2/2] Keep the compile-time exhaustiveness the switches gave for free Replacing the hand-written switches with a table lookup gave up something they provided without anyone having to ask: -Wswitch reported a VoiceTaskKind that was added and not handled. A table cannot, so a new kind would have silently become to_string() -> "unknown", an ABI enumeration that omits it, and a spec name that maps to nothing -- with every test still passing. Confirmed by adding a fifteenth kind: before this commit the build was silent and model_spec_system_test passed; upstream's switch warned. vocabulary_is_exhaustive is a switch with no default whose cases all fall through to one return. It carries no data, so it cannot drift from the table the way the five copies drifted from each other -- it only fails the build until whoever adds a kind opens this file. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EkxqpYvUbjCpRDnFiNiVfx --- src/framework/runtime/task_vocabulary.cpp | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/framework/runtime/task_vocabulary.cpp b/src/framework/runtime/task_vocabulary.cpp index 196b94d09..c889378c6 100644 --- a/src/framework/runtime/task_vocabulary.cpp +++ b/src/framework/runtime/task_vocabulary.cpp @@ -28,6 +28,41 @@ constexpr TaskVocabularyEntry kVocabulary[] = { {VoiceTaskKind::Midi, "midi", {"midi"}, 1}, }; +/// Compile-time exhaustiveness, kept deliberately. +/// +/// Replacing the hand-written switches with a table lookup gave up something +/// they provided for free: `-Wswitch` told you when a VoiceTaskKind was added +/// and not handled. A table cannot, so a new kind would have silently become +/// `to_string` -> "unknown" and an ABI enumeration that omits it. +/// +/// This switch has no default and every case falls through to one return, so +/// adding a kind without adding a row below fails the build the same way it +/// used to. It carries no data, so it cannot drift from the table -- it only +/// forces whoever adds a kind to open this file. +constexpr bool vocabulary_is_exhaustive(VoiceTaskKind kind) { + switch (kind) { + case VoiceTaskKind::Vad: + case VoiceTaskKind::Asr: + case VoiceTaskKind::Diarization: + case VoiceTaskKind::SourceSeparation: + case VoiceTaskKind::AudioGeneration: + case VoiceTaskKind::Tts: + case VoiceTaskKind::VoiceCloning: + case VoiceTaskKind::VoiceConversion: + case VoiceTaskKind::SpeechToSpeech: + case VoiceTaskKind::Alignment: + case VoiceTaskKind::VoiceDesign: + case VoiceTaskKind::SpeakerRecognition: + case VoiceTaskKind::Svc: + case VoiceTaskKind::Midi: + return true; + } + return false; +} + +static_assert(vocabulary_is_exhaustive(VoiceTaskKind::Vad), + "every VoiceTaskKind must be listed above and have a row in kVocabulary"); + } // namespace const TaskVocabularyEntry * task_vocabulary(std::size_t & count) noexcept {