diff --git a/CMakeLists.txt b/CMakeLists.txt index 2e87d82b..227bd3e8 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 5ae74f8c..3601da6e 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 00000000..c3448d10 --- /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 8cb71e54..8c99b972 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 faeeb242..5337467d 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 34db1c61..e62d49a2 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 53e2715f..18efe26d 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 ff844797..5520999f 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 a49f6e55..53113f92 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 00000000..c889378c --- /dev/null +++ b/src/framework/runtime/task_vocabulary.cpp @@ -0,0 +1,88 @@ +#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}, +}; + +/// 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 { + 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 c190f8a3..378bd3c7 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;