Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
1795 lines
67 KiB
C++
1795 lines
67 KiB
C++
#include "engine/framework/model_spec/metadata.h"
|
|
#include "engine/framework/runtime/task_vocabulary.h"
|
|
#include "engine/framework/runtime/spec_backed_model.h"
|
|
#include "engine/framework/runtime/registry.h"
|
|
#include "engine/framework/model_spec/package.h"
|
|
#include "engine/framework/model_spec/schema.h"
|
|
#include "engine/framework/io/json.h"
|
|
#include "test_assert.h"
|
|
#include "gguf.h"
|
|
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <unordered_map>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
namespace json = engine::io::json;
|
|
|
|
class ScopedCurrentPath {
|
|
public:
|
|
explicit ScopedCurrentPath(const std::filesystem::path & path)
|
|
: previous_(std::filesystem::current_path()) {
|
|
std::filesystem::current_path(path);
|
|
}
|
|
|
|
~ScopedCurrentPath() {
|
|
std::filesystem::current_path(previous_);
|
|
}
|
|
|
|
ScopedCurrentPath(const ScopedCurrentPath &) = delete;
|
|
ScopedCurrentPath & operator=(const ScopedCurrentPath &) = delete;
|
|
|
|
private:
|
|
std::filesystem::path previous_;
|
|
};
|
|
|
|
std::filesystem::path make_temp_root() {
|
|
const auto root = std::filesystem::temp_directory_path() / "audiocpp_model_spec_system_test";
|
|
std::error_code ec;
|
|
std::filesystem::remove_all(root, ec);
|
|
std::filesystem::create_directories(root);
|
|
return root;
|
|
}
|
|
|
|
std::filesystem::path write_text(
|
|
const std::filesystem::path & root,
|
|
const std::filesystem::path & relative_path,
|
|
const std::string & text) {
|
|
const auto path = root / relative_path;
|
|
std::filesystem::create_directories(path.parent_path());
|
|
std::ofstream out(path, std::ios::binary);
|
|
if (!out) {
|
|
throw std::runtime_error("failed to create test file: " + path.string());
|
|
}
|
|
out << text;
|
|
return path;
|
|
}
|
|
|
|
std::string legacy_spec_text(const std::string & dependencies) {
|
|
return R"JSON({
|
|
"family": "toy_model",
|
|
"sources": [
|
|
{
|
|
"format": "safetensors",
|
|
"roots": {
|
|
"model": "."
|
|
},
|
|
"files": {
|
|
"config": "model:config.json"
|
|
},
|
|
"optional_files": {
|
|
"tokenizer": "model:tokenizer.json"
|
|
},
|
|
"tensors": {
|
|
"weights": "model:model.safetensors"
|
|
}
|
|
}
|
|
],
|
|
"dependencies": )JSON" + dependencies + R"JSON(
|
|
})JSON";
|
|
}
|
|
|
|
std::string schema_v1_spec_text(const std::string & dependencies) {
|
|
return R"JSON({
|
|
"schema_version": 1,
|
|
"family": "toy_model",
|
|
"display_name": "Toy Model",
|
|
"description": "Toy model schema v1 fixture.",
|
|
"category": "tts",
|
|
"status": "experimental",
|
|
"tasks": ["tts"],
|
|
"modes": ["offline"],
|
|
"languages": ["en"],
|
|
"runtime": {"tags": ["gguf"]},
|
|
"capabilities": {"tts": ["long_form"]},
|
|
"options": {
|
|
"request": [
|
|
{
|
|
"name": "return_timestamps",
|
|
"type": "bool",
|
|
"required": false,
|
|
"default": false,
|
|
"description": "Return timestamps when supported."
|
|
},
|
|
{
|
|
"name": "audio_chunk_mode",
|
|
"type": "enum",
|
|
"values": ["auto", "fixed", "vad", "none"],
|
|
"required": false,
|
|
"default": "auto",
|
|
"description": "Audio chunking mode."
|
|
},
|
|
{
|
|
"name": "vad_path",
|
|
"type": "path",
|
|
"required": false,
|
|
"description": "Optional request-scoped VAD path."
|
|
}
|
|
],
|
|
"session": [
|
|
{
|
|
"name": "peer_model_path",
|
|
"type": "path",
|
|
"required": false,
|
|
"description": "Peer model path."
|
|
},
|
|
{
|
|
"name": "vad_path",
|
|
"type": "path",
|
|
"required": false,
|
|
"description": "Session-scoped VAD path."
|
|
}
|
|
],
|
|
"load": []
|
|
},
|
|
"packages": [
|
|
{
|
|
"id": "toy_model_q8",
|
|
"display_name": "Toy Model Q8",
|
|
"format": "gguf",
|
|
"precision": "q8_0",
|
|
"target_directory": "Toy-Model-GGUF",
|
|
"download": {"kind": "unsupported", "reason": "test fixture"},
|
|
"files": ["toy-model-q8_0.gguf"],
|
|
"default": true
|
|
}
|
|
],
|
|
"dependencies": )JSON" + dependencies + R"JSON(,
|
|
"ui": {
|
|
"recommended_package": "toy_model_q8",
|
|
"tags": ["TTS", "GGUF"],
|
|
"docs": ["docs/tts.md"]
|
|
},
|
|
"sources": [
|
|
{
|
|
"format": "gguf",
|
|
"roots": {
|
|
"model": ".",
|
|
"weights": "$gguf"
|
|
},
|
|
"files": {
|
|
"config": "model:config.json"
|
|
},
|
|
"tensors": {
|
|
"weights": "weights:"
|
|
}
|
|
}
|
|
]
|
|
})JSON";
|
|
}
|
|
|
|
std::string schema_v1_spec_with_options(const std::string & options) {
|
|
return R"JSON({
|
|
"schema_version": 1,
|
|
"family": "toy_model",
|
|
"display_name": "Toy Model",
|
|
"description": "Toy model schema v1 fixture.",
|
|
"category": "tts",
|
|
"status": "experimental",
|
|
"tasks": ["tts"],
|
|
"modes": ["offline"],
|
|
"languages": ["en"],
|
|
"runtime": {"tags": ["gguf"]},
|
|
"capabilities": {"tts": ["long_form"]},
|
|
"options": )JSON" + options + R"JSON(,
|
|
"packages": [
|
|
{
|
|
"id": "toy_model_q8",
|
|
"display_name": "Toy Model Q8",
|
|
"format": "gguf",
|
|
"precision": "q8_0",
|
|
"target_directory": "Toy-Model-GGUF",
|
|
"download": {"kind": "unsupported", "reason": "test fixture"},
|
|
"files": ["toy-model-q8_0.gguf"],
|
|
"default": true
|
|
}
|
|
],
|
|
"dependencies": [],
|
|
"ui": {
|
|
"recommended_package": "toy_model_q8",
|
|
"tags": ["TTS", "GGUF"],
|
|
"docs": ["docs/tts.md"]
|
|
},
|
|
"sources": [
|
|
{
|
|
"format": "gguf",
|
|
"roots": {
|
|
"model": ".",
|
|
"weights": "$gguf"
|
|
},
|
|
"files": {
|
|
"config": "model:config.json"
|
|
},
|
|
"tensors": {
|
|
"weights": "weights:"
|
|
}
|
|
}
|
|
]
|
|
})JSON";
|
|
}
|
|
|
|
void expect_rejects(const std::string & label, const std::string & spec_text, const std::string & needle) {
|
|
bool rejected = false;
|
|
try {
|
|
engine::model_spec::validate_spec(json::parse(spec_text), label);
|
|
} catch (const std::runtime_error & error) {
|
|
rejected = std::string(error.what()).find(needle) != std::string::npos;
|
|
}
|
|
engine::test::require(rejected, label + " should reject with: " + needle);
|
|
}
|
|
|
|
std::string spec_with_download(const std::string & download) {
|
|
auto text = schema_v1_spec_text("[]");
|
|
const std::string anchor = "\"download\": {\"kind\": \"unsupported\", \"reason\": \"test fixture\"}";
|
|
const auto at = text.find(anchor);
|
|
engine::test::require(at != std::string::npos, "download fixture anchor exists");
|
|
text.replace(at, anchor.size(), "\"download\": " + download);
|
|
return text;
|
|
}
|
|
|
|
void test_download_kinds_schema() {
|
|
// ModelScope snapshot downloads validate like Hugging Face snapshots:
|
|
// repo is required, revision is optional.
|
|
engine::model_spec::validate_spec(
|
|
json::parse(spec_with_download(
|
|
R"JSON({"kind": "modelscope_snapshot", "repo": "audio-cpp/toy-model"})JSON")),
|
|
"modelscope_snapshot_repo_only");
|
|
engine::model_spec::validate_spec(
|
|
json::parse(spec_with_download(
|
|
R"JSON({"kind": "modelscope_snapshot", "repo": "audio-cpp/toy-model", "revision": "master"})JSON")),
|
|
"modelscope_snapshot_with_revision");
|
|
expect_rejects(
|
|
"modelscope_snapshot_missing_repo",
|
|
spec_with_download(R"JSON({"kind": "modelscope_snapshot"})JSON"),
|
|
"missing required field 'repo'");
|
|
}
|
|
|
|
void test_legacy_dependencies_schema() {
|
|
// Valid legacy specs accept required model dependencies and conditional bundled dependencies.
|
|
const auto spec = json::parse(schema_v1_spec_text(R"JSON([
|
|
{
|
|
"kind": "model",
|
|
"family": "peer_model",
|
|
"scope": "session",
|
|
"option": "peer_model_path",
|
|
"required": true
|
|
},
|
|
{
|
|
"kind": "bundled_model",
|
|
"family": "silero_vad",
|
|
"path": "assets/framework/models/silero_vad",
|
|
"scope": "request",
|
|
"option": "vad_path",
|
|
"required": false,
|
|
"required_when": [
|
|
{
|
|
"scope": "request",
|
|
"option_key": "audio_chunk_mode",
|
|
"equals": "vad"
|
|
}
|
|
]
|
|
}
|
|
])JSON"));
|
|
engine::model_spec::validate_spec(spec, "valid_legacy_dependencies");
|
|
|
|
// Dependency rows must use the current local "option" field, not the old option_key field.
|
|
expect_rejects(
|
|
"old_option_key",
|
|
schema_v1_spec_text(R"JSON([
|
|
{
|
|
"kind": "model",
|
|
"family": "peer_model",
|
|
"scope": "session",
|
|
"option_key": "toy_model.peer_model_path",
|
|
"required": true
|
|
}
|
|
])JSON"),
|
|
"missing required field 'option'");
|
|
// Dependency scopes are typed and reject unknown values.
|
|
expect_rejects(
|
|
"invalid_scope",
|
|
schema_v1_spec_text(R"JSON([
|
|
{
|
|
"kind": "model",
|
|
"family": "peer_model",
|
|
"scope": "prepare",
|
|
"option": "peer_model_path",
|
|
"required": true
|
|
}
|
|
])JSON"),
|
|
"unknown dependency scope 'prepare'");
|
|
// Dependency options are local names; the family namespace is derived by the framework.
|
|
expect_rejects(
|
|
"namespaced_local_option",
|
|
schema_v1_spec_text(R"JSON([
|
|
{
|
|
"kind": "model",
|
|
"family": "peer_model",
|
|
"scope": "session",
|
|
"option": "toy_model.peer_model_path",
|
|
"required": true
|
|
}
|
|
])JSON"),
|
|
"dependency option must be local");
|
|
// Free-form required_for was replaced by typed required_when conditions.
|
|
expect_rejects(
|
|
"old_required_for",
|
|
schema_v1_spec_text(R"JSON([
|
|
{
|
|
"kind": "model",
|
|
"family": "peer_model",
|
|
"scope": "session",
|
|
"option": "peer_model_path",
|
|
"required": false,
|
|
"required_for": ["decode"]
|
|
}
|
|
])JSON"),
|
|
"use typed required_when conditions");
|
|
// Optional dependencies must say exactly when they become required.
|
|
expect_rejects(
|
|
"optional_missing_condition",
|
|
schema_v1_spec_text(R"JSON([
|
|
{
|
|
"kind": "model",
|
|
"family": "peer_model",
|
|
"scope": "session",
|
|
"option": "peer_model_path",
|
|
"required": false
|
|
}
|
|
])JSON"),
|
|
"optional dependencies require typed conditions");
|
|
// Required dependencies are unconditional and must not also have required_when rows.
|
|
expect_rejects(
|
|
"required_with_condition",
|
|
schema_v1_spec_text(R"JSON([
|
|
{
|
|
"kind": "model",
|
|
"family": "peer_model",
|
|
"scope": "session",
|
|
"option": "peer_model_path",
|
|
"required": true,
|
|
"required_when": [
|
|
{
|
|
"scope": "request",
|
|
"option_key": "return_timestamps",
|
|
"equals": true
|
|
}
|
|
]
|
|
}
|
|
])JSON"),
|
|
"required dependencies must not have conditions");
|
|
// Dependency package hints are intentionally outside the core runtime contract.
|
|
expect_rejects(
|
|
"dependency_package_hint",
|
|
schema_v1_spec_text(R"JSON([
|
|
{
|
|
"kind": "model",
|
|
"family": "peer_model",
|
|
"scope": "session",
|
|
"option": "peer_model_path",
|
|
"required": false,
|
|
"required_when": [
|
|
{
|
|
"scope": "request",
|
|
"option_key": "return_timestamps",
|
|
"equals": true
|
|
}
|
|
],
|
|
"package": "peer_model_q8_0"
|
|
}
|
|
])JSON"),
|
|
"dependency package hints are not part of the core contract");
|
|
// Bundled dependencies must provide the bundled asset path.
|
|
expect_rejects(
|
|
"bundled_missing_path",
|
|
schema_v1_spec_text(R"JSON([
|
|
{
|
|
"kind": "bundled_model",
|
|
"family": "silero_vad",
|
|
"scope": "session",
|
|
"option": "vad_path",
|
|
"required": false,
|
|
"required_when": [
|
|
{
|
|
"scope": "request",
|
|
"option_key": "audio_chunk_mode",
|
|
"equals": "vad"
|
|
}
|
|
]
|
|
}
|
|
])JSON"),
|
|
"missing required field 'path'");
|
|
// Dependency options must be declared under options.<scope>.
|
|
expect_rejects(
|
|
"undeclared_dependency_option",
|
|
schema_v1_spec_text(R"JSON([
|
|
{
|
|
"kind": "model",
|
|
"family": "peer_model",
|
|
"scope": "session",
|
|
"option": "missing_peer_path",
|
|
"required": true
|
|
}
|
|
])JSON"),
|
|
"is not declared in options.session");
|
|
// required_when option keys must refer to declared options in that scope.
|
|
expect_rejects(
|
|
"undeclared_required_when_option",
|
|
schema_v1_spec_text(R"JSON([
|
|
{
|
|
"kind": "model",
|
|
"family": "peer_model",
|
|
"scope": "session",
|
|
"option": "peer_model_path",
|
|
"required": false,
|
|
"required_when": [
|
|
{
|
|
"scope": "request",
|
|
"option_key": "unknown_trigger",
|
|
"equals": true
|
|
}
|
|
]
|
|
}
|
|
])JSON"),
|
|
"is not declared in options.request");
|
|
// Session-scoped required_when keys use the public <family>.<name> form.
|
|
expect_rejects(
|
|
"required_when_session_local_name",
|
|
schema_v1_spec_text(R"JSON([
|
|
{
|
|
"kind": "model",
|
|
"family": "peer_model",
|
|
"scope": "session",
|
|
"option": "peer_model_path",
|
|
"required": false,
|
|
"required_when": [
|
|
{
|
|
"scope": "session",
|
|
"option_key": "peer_model_path",
|
|
"equals": "/tmp/peer"
|
|
}
|
|
]
|
|
}
|
|
])JSON"),
|
|
"is not declared in options.session");
|
|
}
|
|
|
|
void test_typed_schema_renamed_dependencies() {
|
|
// Valid typed specs accept top-level languages, task-keyed capabilities, and typed dependency conditions.
|
|
const auto typed = json::parse(R"JSON({
|
|
"schema_version": 1,
|
|
"family": "typed_model",
|
|
"display_name": "Typed Model",
|
|
"category": "asr",
|
|
"status": "experimental",
|
|
"tasks": ["asr"],
|
|
"modes": ["offline"],
|
|
"languages": ["en"],
|
|
"runtime": {
|
|
"tags": ["gguf"]
|
|
},
|
|
"capabilities": {
|
|
"asr": ["word_timestamps"]
|
|
},
|
|
"options": {
|
|
"request": [
|
|
{
|
|
"name": "return_timestamps",
|
|
"type": "bool",
|
|
"required": false,
|
|
"default": false,
|
|
"description": "Return word timestamps."
|
|
},
|
|
{
|
|
"name": "audio_chunk_seconds",
|
|
"type": "float",
|
|
"required": false,
|
|
"default": 15,
|
|
"description": "Audio chunk duration in seconds."
|
|
}
|
|
],
|
|
"session": [
|
|
{
|
|
"name": "aligner_path",
|
|
"type": "path",
|
|
"required": false,
|
|
"description": "Forced aligner model path."
|
|
}
|
|
],
|
|
"load": []
|
|
},
|
|
"packages": [
|
|
{
|
|
"id": "typed_model_q8",
|
|
"display_name": "Typed Model Q8",
|
|
"format": "gguf",
|
|
"precision": "q8_0",
|
|
"target_directory": "Typed-Model",
|
|
"download": {
|
|
"kind": "huggingface_snapshot",
|
|
"repo": "audio-cpp/typed-model"
|
|
},
|
|
"files": ["typed-model-q8_0.gguf"],
|
|
"default": true
|
|
}
|
|
],
|
|
"dependencies": [
|
|
{
|
|
"kind": "model",
|
|
"family": "typed_aligner",
|
|
"scope": "session",
|
|
"option": "aligner_path",
|
|
"required": false,
|
|
"required_when": [
|
|
{
|
|
"scope": "request",
|
|
"option_key": "return_timestamps",
|
|
"equals": true
|
|
},
|
|
{
|
|
"scope": "request",
|
|
"option_key": "audio_chunk_seconds",
|
|
"equals": 15
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"ui": {
|
|
"recommended_package": "typed_model_q8",
|
|
"tags": ["ASR"],
|
|
"docs": ["docs/asr.md"]
|
|
},
|
|
"sources": [
|
|
{
|
|
"format": "gguf",
|
|
"roots": {
|
|
"model": ".",
|
|
"weights": "$gguf"
|
|
},
|
|
"files": {
|
|
"config": "model:config.json"
|
|
},
|
|
"tensors": {
|
|
"weights": "weights:"
|
|
}
|
|
}
|
|
]
|
|
})JSON");
|
|
engine::model_spec::validate_spec(typed, "typed_dependencies");
|
|
|
|
// The old "companions" block is rejected in favor of typed dependencies.
|
|
expect_rejects(
|
|
"typed_rejects_companions",
|
|
R"JSON({
|
|
"schema_version": 1,
|
|
"family": "typed_model",
|
|
"display_name": "Typed Model",
|
|
"category": "asr",
|
|
"status": "experimental",
|
|
"tasks": ["asr"],
|
|
"modes": ["offline"],
|
|
"languages": ["en"],
|
|
"runtime": {"tags": ["gguf"]},
|
|
"capabilities": {
|
|
"asr": ["word_timestamps"]
|
|
},
|
|
"options": {"request": [], "session": [], "load": []},
|
|
"packages": [
|
|
{
|
|
"id": "typed_model_q8",
|
|
"display_name": "Typed Model Q8",
|
|
"format": "gguf",
|
|
"precision": "q8_0",
|
|
"target_directory": "Typed-Model",
|
|
"download": {"kind": "huggingface_snapshot", "repo": "audio-cpp/typed-model"},
|
|
"files": ["typed-model-q8_0.gguf"],
|
|
"default": true
|
|
}
|
|
],
|
|
"companions": [],
|
|
"ui": {
|
|
"recommended_package": "typed_model_q8",
|
|
"tags": ["ASR"],
|
|
"docs": ["docs/asr.md"]
|
|
},
|
|
"sources": [
|
|
{
|
|
"format": "gguf",
|
|
"roots": {"model": ".", "weights": "$gguf"},
|
|
"files": {"config": "model:config.json"},
|
|
"tensors": {"weights": "weights:"}
|
|
}
|
|
]
|
|
})JSON",
|
|
"missing required field 'dependencies'");
|
|
|
|
// Task names are typed and reject unknown operation ids.
|
|
expect_rejects(
|
|
"typed_rejects_unknown_task",
|
|
R"JSON({
|
|
"schema_version": 1,
|
|
"family": "typed_model",
|
|
"display_name": "Typed Model",
|
|
"category": "tts",
|
|
"status": "experimental",
|
|
"tasks": ["bad_task"],
|
|
"modes": ["offline"],
|
|
"languages": ["en"],
|
|
"runtime": {"tags": ["gguf"]},
|
|
"capabilities": {},
|
|
"options": {"request": [], "session": [], "load": []},
|
|
"packages": [
|
|
{
|
|
"id": "typed_model_q8",
|
|
"display_name": "Typed Model Q8",
|
|
"format": "gguf",
|
|
"precision": "q8_0",
|
|
"target_directory": "Typed-Model",
|
|
"download": {"kind": "huggingface_snapshot", "repo": "audio-cpp/typed-model"},
|
|
"files": ["typed-model-q8_0.gguf"],
|
|
"default": true
|
|
}
|
|
],
|
|
"dependencies": [],
|
|
"ui": {
|
|
"recommended_package": "typed_model_q8",
|
|
"tags": ["TTS"],
|
|
"docs": ["docs/tts.md"]
|
|
},
|
|
"sources": [
|
|
{
|
|
"format": "gguf",
|
|
"roots": {"model": ".", "weights": "$gguf"},
|
|
"files": {"config": "model:config.json"},
|
|
"tensors": {"weights": "weights:"}
|
|
}
|
|
]
|
|
})JSON",
|
|
"unknown task 'bad_task'");
|
|
|
|
// Capability keys must be one of the model's declared tasks.
|
|
expect_rejects(
|
|
"typed_rejects_capability_for_undeclared_task",
|
|
R"JSON({
|
|
"schema_version": 1,
|
|
"family": "typed_model",
|
|
"display_name": "Typed Model",
|
|
"category": "tts",
|
|
"status": "experimental",
|
|
"tasks": ["tts"],
|
|
"modes": ["offline"],
|
|
"languages": ["en"],
|
|
"runtime": {"tags": ["gguf"]},
|
|
"capabilities": {"clone": ["speaker_reference"]},
|
|
"options": {"request": [], "session": [], "load": []},
|
|
"packages": [
|
|
{
|
|
"id": "typed_model_q8",
|
|
"display_name": "Typed Model Q8",
|
|
"format": "gguf",
|
|
"precision": "q8_0",
|
|
"target_directory": "Typed-Model",
|
|
"download": {"kind": "huggingface_snapshot", "repo": "audio-cpp/typed-model"},
|
|
"files": ["typed-model-q8_0.gguf"],
|
|
"default": true
|
|
}
|
|
],
|
|
"dependencies": [],
|
|
"ui": {
|
|
"recommended_package": "typed_model_q8",
|
|
"tags": ["TTS"],
|
|
"docs": ["docs/tts.md"]
|
|
},
|
|
"sources": [
|
|
{
|
|
"format": "gguf",
|
|
"roots": {"model": ".", "weights": "$gguf"},
|
|
"files": {"config": "model:config.json"},
|
|
"tensors": {"weights": "weights:"}
|
|
}
|
|
]
|
|
})JSON",
|
|
"capability key must be one of this model's tasks");
|
|
|
|
// Capability values are typed per task, so ASR timestamp capabilities cannot be attached to TTS.
|
|
expect_rejects(
|
|
"typed_rejects_unknown_capability",
|
|
R"JSON({
|
|
"schema_version": 1,
|
|
"family": "typed_model",
|
|
"display_name": "Typed Model",
|
|
"category": "tts",
|
|
"status": "experimental",
|
|
"tasks": ["tts"],
|
|
"modes": ["offline"],
|
|
"languages": ["en"],
|
|
"runtime": {"tags": ["gguf"]},
|
|
"capabilities": {"tts": ["word_timestamps"]},
|
|
"options": {"request": [], "session": [], "load": []},
|
|
"packages": [
|
|
{
|
|
"id": "typed_model_q8",
|
|
"display_name": "Typed Model Q8",
|
|
"format": "gguf",
|
|
"precision": "q8_0",
|
|
"target_directory": "Typed-Model",
|
|
"download": {"kind": "huggingface_snapshot", "repo": "audio-cpp/typed-model"},
|
|
"files": ["typed-model-q8_0.gguf"],
|
|
"default": true
|
|
}
|
|
],
|
|
"dependencies": [],
|
|
"ui": {
|
|
"recommended_package": "typed_model_q8",
|
|
"tags": ["TTS"],
|
|
"docs": ["docs/tts.md"]
|
|
},
|
|
"sources": [
|
|
{
|
|
"format": "gguf",
|
|
"roots": {"model": ".", "weights": "$gguf"},
|
|
"files": {"config": "model:config.json"},
|
|
"tensors": {"weights": "weights:"}
|
|
}
|
|
]
|
|
})JSON",
|
|
"unknown capability 'word_timestamps'");
|
|
}
|
|
|
|
void test_dependency_option_mapping_from_production_spec() {
|
|
// The production catalog currently points at the legacy source-only specs.
|
|
const auto miotts_dependencies = engine::model_spec::dependencies("miotts");
|
|
engine::test::require(miotts_dependencies.empty(), "legacy miotts spec should not expose dependencies");
|
|
|
|
const auto qwen_dependencies = engine::model_spec::dependencies("qwen3_asr");
|
|
engine::test::require(qwen_dependencies.empty(), "legacy qwen3 ASR spec should not expose dependencies");
|
|
}
|
|
|
|
void test_options_schema() {
|
|
// Valid option metadata accepts typed scopes, enum presets, structured list types, and load paths.
|
|
const auto spec = json::parse(schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "text_chunk_mode",
|
|
"type": "enum",
|
|
"preset": "text_chunk_mode_full",
|
|
"required": false,
|
|
"default": "word_budget",
|
|
"description": "Text chunking mode."
|
|
},
|
|
{
|
|
"name": "duration_seconds",
|
|
"type": "float_list",
|
|
"required": false,
|
|
"min": 0,
|
|
"description": "Per-prompt duration list."
|
|
}
|
|
],
|
|
"session": [
|
|
{
|
|
"name": "weight_type",
|
|
"type": "enum",
|
|
"preset": "weight_type_full",
|
|
"required": false,
|
|
"default": "native",
|
|
"description": "Weight storage type."
|
|
}
|
|
],
|
|
"load": [
|
|
{
|
|
"name": "sidecar_path",
|
|
"type": "path",
|
|
"required": false,
|
|
"description": "Optional sidecar path."
|
|
}
|
|
]
|
|
})JSON"));
|
|
engine::model_spec::validate_spec(spec, "valid_options");
|
|
|
|
// Option scopes are required so consumers do not guess missing categories.
|
|
expect_rejects(
|
|
"options_missing_scope",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [],
|
|
"session": []
|
|
})JSON"),
|
|
"missing required field 'load'");
|
|
|
|
// Option type names are typed and reject unknown values.
|
|
expect_rejects(
|
|
"options_reject_unknown_type",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "seed",
|
|
"type": "number",
|
|
"required": false,
|
|
"description": "Seed."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"),
|
|
"unknown option type 'number'");
|
|
|
|
// Requiredness is explicit for every option; absence would make consumers guess.
|
|
expect_rejects(
|
|
"options_reject_missing_required",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "seed",
|
|
"type": "int",
|
|
"description": "Seed."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"),
|
|
"missing required field 'required'");
|
|
|
|
// Enum options must list their accepted values directly or through a preset.
|
|
expect_rejects(
|
|
"options_reject_enum_without_values",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "text_chunk_mode",
|
|
"type": "enum",
|
|
"required": false,
|
|
"description": "Text chunking mode."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"),
|
|
"enum option requires values or preset");
|
|
|
|
// Defaults are typed; an int option cannot use a string default.
|
|
expect_rejects(
|
|
"options_reject_wrong_default_type",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "seed",
|
|
"type": "int",
|
|
"required": false,
|
|
"default": "0",
|
|
"description": "Seed."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"),
|
|
"expected numeric default");
|
|
|
|
// Enum defaults must be part of the declared enum domain.
|
|
expect_rejects(
|
|
"options_reject_enum_default_outside_values",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "route",
|
|
"type": "enum",
|
|
"values": ["a", "b"],
|
|
"required": false,
|
|
"default": "c",
|
|
"description": "Route."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"),
|
|
"default is not in enum values");
|
|
|
|
// Numeric bounds are typed and ordered.
|
|
expect_rejects(
|
|
"options_reject_bad_numeric_bounds",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "top_k",
|
|
"type": "int",
|
|
"required": false,
|
|
"min": 10,
|
|
"max": 1,
|
|
"description": "Top-k."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"),
|
|
"min must not exceed max");
|
|
|
|
// Numeric defaults must stay inside declared bounds.
|
|
expect_rejects(
|
|
"options_reject_default_outside_bounds",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "top_p",
|
|
"type": "float",
|
|
"required": false,
|
|
"default": 2.0,
|
|
"min": 0.0,
|
|
"max": 1.0,
|
|
"description": "Top-p."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"),
|
|
"default exceeds max");
|
|
|
|
// min/max are only meaningful for numeric options.
|
|
expect_rejects(
|
|
"options_reject_bounds_on_string",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "reference_text",
|
|
"type": "string",
|
|
"required": false,
|
|
"min": 1,
|
|
"description": "Reference text."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"),
|
|
"min/max are allowed only for int or float options");
|
|
|
|
// Non-enum options cannot carry enum values.
|
|
expect_rejects(
|
|
"options_reject_values_on_non_enum",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "seed",
|
|
"type": "int",
|
|
"values": ["1", "2"],
|
|
"required": false,
|
|
"description": "Seed."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"),
|
|
"values are allowed only for enum options");
|
|
|
|
// Enum presets replace repeated value arrays for shared option domains.
|
|
expect_rejects(
|
|
"options_reject_unknown_preset",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "text_chunk_mode",
|
|
"type": "enum",
|
|
"preset": "unknown_chunk_modes",
|
|
"required": false,
|
|
"description": "Text chunking mode."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"),
|
|
"unknown option preset 'unknown_chunk_modes'");
|
|
|
|
// Enum options must use either inline values or a preset, not both.
|
|
expect_rejects(
|
|
"options_reject_preset_with_values",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "text_chunk_mode",
|
|
"type": "enum",
|
|
"preset": "text_chunk_mode_full",
|
|
"values": ["word_budget"],
|
|
"required": false,
|
|
"description": "Text chunking mode."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"),
|
|
"enum options must use either preset or values");
|
|
|
|
// Shared option names carry shared type contracts; top_k is not free-form text.
|
|
expect_rejects(
|
|
"options_reject_wrong_common_type",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "top_k",
|
|
"type": "string",
|
|
"required": false,
|
|
"description": "Top-k sampling limit."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"),
|
|
"shared option 'top_k' must use its registered type");
|
|
|
|
// Route choices are finite and must be machine-readable, not buried in free-form descriptions.
|
|
expect_rejects(
|
|
"options_reject_route_as_string",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "route",
|
|
"type": "string",
|
|
"required": false,
|
|
"description": "Route name."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"),
|
|
"shared option 'route' must use its registered type");
|
|
|
|
// Model-specific request options are owned by the model spec and do not need central registration.
|
|
const auto local_request_option = json::parse(schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "private_knob",
|
|
"type": "bool",
|
|
"required": true,
|
|
"description": "Model-local switch."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"));
|
|
engine::model_spec::validate_spec(local_request_option, "local_request_option");
|
|
|
|
// Dotted request options are public names and must stay in the model family namespace.
|
|
expect_rejects(
|
|
"options_reject_wrong_request_namespace",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "other_model.private_knob",
|
|
"type": "bool",
|
|
"required": false,
|
|
"description": "Model-local switch."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON"),
|
|
"dotted request option 'other_model.private_knob' must use namespace toy_model.<name>");
|
|
|
|
// Session options are local names in the spec; the family prefix is derived by metadata readers.
|
|
expect_rejects(
|
|
"options_reject_prefixed_session_option",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [],
|
|
"session": [
|
|
{
|
|
"name": "toy_model.weight_type",
|
|
"type": "enum",
|
|
"values": ["native", "q8_0"],
|
|
"required": false,
|
|
"description": "Weight storage type."
|
|
}
|
|
],
|
|
"load": []
|
|
})JSON"),
|
|
"session option must be local");
|
|
|
|
// Load options follow the same local-name rule as session options.
|
|
expect_rejects(
|
|
"options_reject_prefixed_load_option",
|
|
schema_v1_spec_with_options(R"JSON({
|
|
"request": [],
|
|
"session": [],
|
|
"load": [
|
|
{
|
|
"name": "toy_model.sidecar_path",
|
|
"type": "path",
|
|
"required": false,
|
|
"description": "Optional sidecar path."
|
|
}
|
|
]
|
|
})JSON"),
|
|
"load option must be local");
|
|
}
|
|
|
|
void test_component_exec_plan_schema() {
|
|
const auto options = R"({"request":[],"session":[{
|
|
"name":"exec_plan","type":"string","description":"Model execution plan.",
|
|
"required":false,"default":"{}"
|
|
}],"load":[]})";
|
|
const auto root = make_temp_root();
|
|
write_text(root, "toy_model.json", schema_v1_spec_with_options(options));
|
|
const engine::model_spec::ScopedSpecOverride override(root);
|
|
const auto cli = engine::model_spec::cli_interface("toy_model");
|
|
engine::test::require(cli.has_value(), "exec plan spec projects CLI options");
|
|
engine::test::require_eq(cli->session_options.front().name, std::string("toy_model.exec_plan"),
|
|
"exec plan receives exactly one family prefix");
|
|
const auto contract = engine::model_spec::model_contract("toy_model");
|
|
engine::test::require(contract && contract->session_option_keys.count("toy_model.exec_plan") == 1,
|
|
"exec plan appears in the session contract");
|
|
expect_rejects("exec_plan_wrong_type", schema_v1_spec_with_options(R"({
|
|
"request":[],"session":[{"name":"exec_plan","type":"bool","description":"Bad type."}],"load":[]
|
|
})"), "exec_plan must be a JSON object encoded as a string");
|
|
expect_rejects("exec_plan_extra_namespace", schema_v1_spec_with_options(R"({
|
|
"request":[],"session":[{"name":"decoder.exec_plan","type":"string","description":"Bad name."}],"load":[]
|
|
})"), "session option must be local");
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
void test_option_name_mapping_from_production_spec() {
|
|
// Reference option tables do not opt legacy loaders into v1 contracts.
|
|
const auto specs = std::filesystem::path(AUDIOCPP_SOURCE_DIR) / "model_specs";
|
|
std::vector<std::string> failures;
|
|
for (const auto & entry : std::filesystem::directory_iterator(specs)) {
|
|
if (entry.path().extension() != ".json") {
|
|
continue;
|
|
}
|
|
json::Value spec;
|
|
try {
|
|
spec = engine::model_spec::load_spec(entry.path());
|
|
engine::model_spec::validate_spec(spec, entry.path().filename().string());
|
|
} catch (const std::exception & error) {
|
|
failures.push_back(error.what());
|
|
continue;
|
|
}
|
|
if (spec.find("schema_version") != nullptr) {
|
|
continue;
|
|
}
|
|
const auto family = entry.path().stem().string();
|
|
const auto cli = engine::model_spec::cli_interface(family);
|
|
engine::test::require(!cli.has_value(), "legacy spec should not expose v1 CLI metadata");
|
|
const auto * options = spec.find("options");
|
|
if (options == nullptr) {
|
|
continue;
|
|
}
|
|
try {
|
|
engine::model_spec::validate_spec(
|
|
json::parse(schema_v1_spec_with_options(json::stringify(*options))), family);
|
|
} catch (const std::exception & error) {
|
|
failures.push_back(error.what());
|
|
}
|
|
}
|
|
|
|
for (const auto & failure : failures) {
|
|
std::cerr << " invalid model spec: " << failure << "\n";
|
|
}
|
|
engine::test::require(failures.empty(), "all model specs and legacy reference option tables must satisfy the schema");
|
|
|
|
const auto vibevoice_cli = engine::model_spec::cli_interface("vibevoice");
|
|
engine::test::require(!vibevoice_cli.has_value(), "legacy vibevoice spec should not expose CLI metadata");
|
|
}
|
|
|
|
void test_echo_text_chunk_size_contract() {
|
|
const auto specs = std::filesystem::path(AUDIOCPP_SOURCE_DIR) / "model_specs";
|
|
const engine::model_spec::ScopedSpecOverride spec_override(specs);
|
|
const auto contract = engine::model_spec::model_contract("echo_tts");
|
|
engine::test::require(contract.has_value(), "Echo-TTS contract exists");
|
|
engine::test::require(
|
|
contract->request_option_keys.count("text_chunk_size") == 1,
|
|
"Echo-TTS validation must allow its text chunk size consumer");
|
|
}
|
|
|
|
void test_schema_v1_metadata_projection() {
|
|
const auto root = make_temp_root();
|
|
write_text(root, "toy_model.json", schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "language",
|
|
"type": "string",
|
|
"required": false,
|
|
"default": "en",
|
|
"description": "Target language."
|
|
}
|
|
],
|
|
"session": [
|
|
{
|
|
"name": "weight_type",
|
|
"type": "enum",
|
|
"preset": "weight_type_full",
|
|
"required": false,
|
|
"default": "native",
|
|
"description": "Updated weight storage type."
|
|
},
|
|
{
|
|
"name": "future_session_option",
|
|
"type": "bool",
|
|
"required": false,
|
|
"default": false,
|
|
"description": "New code-supported session option."
|
|
}
|
|
],
|
|
"load": []
|
|
})JSON"));
|
|
|
|
const engine::model_spec::ScopedSpecOverride spec_override(root);
|
|
const auto metadata = engine::model_spec::model_metadata("toy_model");
|
|
engine::test::require(metadata.has_value(), "schema v1 metadata should be projected");
|
|
engine::test::require_eq(metadata->family, std::string("toy_model"), "metadata family");
|
|
engine::test::require_eq(metadata->variant, std::string("Toy Model"), "metadata display name");
|
|
engine::test::require_eq(
|
|
metadata->description,
|
|
std::string("Toy model schema v1 fixture."),
|
|
"metadata description");
|
|
engine::test::require(!metadata->config_candidates.empty(), "metadata config candidates");
|
|
engine::test::require(!metadata->weight_candidates.empty(), "metadata weight candidates");
|
|
|
|
const auto capabilities = engine::model_spec::advertised_capabilities("toy_model");
|
|
engine::test::require(capabilities.has_value(), "schema v1 capabilities should be projected");
|
|
engine::test::require_eq(capabilities->languages.size(), size_t{1}, "capability language count");
|
|
engine::test::require_eq(capabilities->languages.front(), std::string("en"), "capability language");
|
|
engine::test::require_eq(capabilities->supported_tasks.size(), size_t{1}, "capability task count");
|
|
engine::test::require(
|
|
capabilities->supported_tasks.front().task == engine::runtime::VoiceTaskKind::Tts,
|
|
"capability task should come from v1 tasks");
|
|
engine::test::require(
|
|
capabilities->supported_tasks.front().modes.front() == engine::runtime::RunMode::Offline,
|
|
"capability mode should come from v1 modes");
|
|
|
|
const auto cli = engine::model_spec::cli_interface("toy_model");
|
|
engine::test::require(cli.has_value(), "schema v1 CLI should be projected");
|
|
engine::test::require_eq(cli->request_options.front().name, std::string("language"), "request option name");
|
|
engine::test::require_eq(
|
|
cli->session_options.front().name,
|
|
std::string("toy_model.weight_type"),
|
|
"session option public name");
|
|
engine::test::require_eq(
|
|
cli->session_options.front().description,
|
|
std::string("Updated weight storage type."),
|
|
"modified session option description");
|
|
engine::test::require_eq(
|
|
cli->session_options[1].name,
|
|
std::string("toy_model.future_session_option"),
|
|
"new session option public name");
|
|
|
|
const auto contract = engine::model_spec::model_contract("toy_model");
|
|
engine::test::require(contract.has_value(), "schema v1 contract should be projected");
|
|
engine::test::require(
|
|
contract->session_option_keys.find("toy_model.future_session_option") !=
|
|
contract->session_option_keys.end(),
|
|
"contract should include new session option");
|
|
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
void test_contract_projection_ignores_package_metadata_validation() {
|
|
const auto root = make_temp_root();
|
|
std::string spec_text = schema_v1_spec_with_options(R"JSON({
|
|
"request": [
|
|
{
|
|
"name": "reference_text",
|
|
"type": "string",
|
|
"required": false,
|
|
"description": "Reference transcript."
|
|
}
|
|
],
|
|
"session": [],
|
|
"load": []
|
|
})JSON");
|
|
const std::string marker = "\"default\": true";
|
|
const auto pos = spec_text.find(marker);
|
|
engine::test::require(pos != std::string::npos, "test fixture package marker");
|
|
spec_text.replace(pos, marker.size(), "\"default\": true,\n \"strip_prefix\": \"\"");
|
|
const auto spec_path = write_text(root, "toy_model.json", spec_text);
|
|
|
|
bool full_validation_rejected = false;
|
|
try {
|
|
(void) engine::model_spec::load_spec(spec_path);
|
|
} catch (const std::runtime_error & error) {
|
|
full_validation_rejected = std::string(error.what()).find("strip_prefix: expected non-empty string") !=
|
|
std::string::npos;
|
|
}
|
|
engine::test::require(
|
|
full_validation_rejected,
|
|
"full package/catalog validation should reject empty strip_prefix");
|
|
|
|
const engine::model_spec::ScopedSpecOverride spec_override(root);
|
|
const auto contract = engine::model_spec::model_contract("toy_model");
|
|
engine::test::require(contract.has_value(), "contract projection should ignore package metadata validation");
|
|
engine::test::require(contract->metadata.weight_candidates.empty(), "contract metadata should not read packages");
|
|
engine::test::require(
|
|
contract->request_option_keys.find("reference_text") != contract->request_option_keys.end(),
|
|
"contract should include reference_text");
|
|
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
void test_legacy_spec_contract_behavior_unchanged() {
|
|
const auto root = make_temp_root();
|
|
write_text(root, "toy_model.json", legacy_spec_text("[]"));
|
|
|
|
const engine::model_spec::ScopedSpecOverride spec_override(root);
|
|
const auto contract = engine::model_spec::model_contract("toy_model");
|
|
engine::test::require(!contract.has_value(), "legacy specs should not expose schema v1 contracts");
|
|
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
void test_experimental_spec_without_installable_package() {
|
|
const std::string experimental = R"JSON({
|
|
"schema_version": 1,
|
|
"family": "local_only_model",
|
|
"display_name": "Local Only Model",
|
|
"description": "Requires a local checkpoint conversion.",
|
|
"category": "tts",
|
|
"status": "experimental",
|
|
"tasks": ["tts"],
|
|
"modes": ["offline"],
|
|
"languages": ["en"],
|
|
"runtime": {"tags": ["gguf"]},
|
|
"capabilities": {},
|
|
"options": {"request": [], "session": [], "load": []},
|
|
"packages": [],
|
|
"dependencies": [],
|
|
"ui": {"tags": ["TTS"], "docs": ["docs/local.md"]},
|
|
"sources": [{
|
|
"format": "safetensors",
|
|
"roots": {"model": "."},
|
|
"files": {"config": "model:config.json"},
|
|
"tensors": {"weights": "model:model.safetensors"}
|
|
}]
|
|
})JSON";
|
|
engine::model_spec::validate_spec(
|
|
json::parse(experimental), "experimental_local_only");
|
|
|
|
auto community = experimental;
|
|
const auto status = community.find("\"status\": \"experimental\"");
|
|
engine::test::require(status != std::string::npos, "experimental status fixture");
|
|
community.replace(status, std::string("\"status\": \"experimental\"").size(),
|
|
"\"status\": \"community\"");
|
|
expect_rejects(
|
|
"community_requires_package", community,
|
|
"packages must not be empty unless status is experimental");
|
|
}
|
|
|
|
void test_contract_spec_prefers_workspace_over_package_local_spec() {
|
|
const auto root = make_temp_root();
|
|
const auto workspace = root / "workspace";
|
|
const auto model_root = root / "model";
|
|
std::filesystem::create_directories(workspace);
|
|
std::filesystem::create_directories(model_root);
|
|
const auto workspace_spec = write_text(
|
|
workspace,
|
|
"model_specs/toy_model.json",
|
|
schema_v1_spec_with_options(R"JSON({"request": [], "session": [], "load": []})JSON"));
|
|
const auto package_spec = write_text(
|
|
model_root,
|
|
"model_specs/toy_model.json",
|
|
schema_v1_spec_with_options(R"JSON({"request": [], "session": [], "load": []})JSON"));
|
|
|
|
{
|
|
ScopedCurrentPath cwd(workspace);
|
|
const engine::model_spec::ScopedSpecOverride model_scope(std::nullopt, model_root);
|
|
engine::test::require_eq(
|
|
engine::model_spec::default_contract_spec_path("toy_model").string(),
|
|
std::filesystem::weakly_canonical(workspace_spec).string(),
|
|
"contract spec path");
|
|
engine::test::require_eq(
|
|
engine::model_spec::default_package_spec_path("toy_model").string(),
|
|
std::filesystem::weakly_canonical(package_spec).string(),
|
|
"package spec path");
|
|
}
|
|
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
void test_contract_spec_priority_with_embedded_gguf() {
|
|
const auto root = make_temp_root();
|
|
const auto workspace = root / "workspace";
|
|
std::filesystem::create_directories(workspace);
|
|
const auto embedded = schema_v1_spec_with_options(
|
|
R"JSON({"request": [{"name": "old_option", "type": "string", "required": false, "description": "Existing option."}], "session": [], "load": []})JSON");
|
|
const auto current = schema_v1_spec_with_options(
|
|
R"JSON({"request": [{"name": "old_option", "type": "string", "required": false, "description": "Existing option."}, {"name": "new_option", "type": "string", "required": false, "description": "New option."}], "session": [], "load": []})JSON");
|
|
const auto gguf_path = root / "model.gguf";
|
|
auto * gguf = gguf_init_empty();
|
|
engine::test::require(gguf != nullptr, "create embedded-spec fixture");
|
|
gguf_set_val_u32(gguf, "audiocpp.model_spec.version", 1);
|
|
gguf_set_val_str(gguf, "audiocpp.model_spec.family", "toy_model");
|
|
gguf_set_val_str(gguf, "audiocpp.model_spec.json", embedded.c_str());
|
|
const bool written = gguf_write_to_file(gguf, gguf_path.string().c_str(), true);
|
|
gguf_free(gguf);
|
|
engine::test::require(written, "write embedded-spec fixture");
|
|
|
|
{
|
|
ScopedCurrentPath cwd(workspace);
|
|
const auto external = write_text(workspace, "model_specs/toy_model.json", current);
|
|
{
|
|
const engine::model_spec::ScopedSpecOverride model_scope(std::nullopt, gguf_path);
|
|
engine::test::require_eq(
|
|
engine::model_spec::default_contract_spec_path("toy_model").string(),
|
|
std::filesystem::weakly_canonical(external).string(), "external contract wins over embedded");
|
|
const auto contract = engine::model_spec::model_contract("toy_model");
|
|
engine::test::require(contract->request_option_keys.count("old_option") == 1,
|
|
"external contract retains existing option");
|
|
engine::test::require(contract->request_option_keys.count("new_option") == 1,
|
|
"external contract exposes new option");
|
|
engine::test::require_eq(
|
|
engine::model_spec::default_package_spec_path("toy_model").generic_string(),
|
|
std::string("@gguf/toy_model.json"), "package still uses embedded spec");
|
|
}
|
|
const auto override_path = write_text(root, "override.json", embedded);
|
|
{
|
|
const engine::model_spec::ScopedSpecOverride model_scope("embedded", gguf_path);
|
|
engine::test::require_eq(
|
|
engine::model_spec::default_contract_spec_path("toy_model").generic_string(),
|
|
std::string("@gguf/toy_model.json"), "explicit embedded contract wins over external");
|
|
const auto contract = engine::model_spec::model_contract("toy_model");
|
|
engine::test::require(contract->request_option_keys.count("old_option") == 1 &&
|
|
contract->request_option_keys.count("new_option") == 0,
|
|
"explicit embedded selection uses embedded options");
|
|
engine::test::require_eq(
|
|
engine::model_spec::default_package_spec_path("toy_model").generic_string(),
|
|
std::string("@gguf/toy_model.json"), "explicit embedded package selection");
|
|
bool mismatch_rejected = false;
|
|
try {
|
|
(void)engine::model_spec::default_contract_spec_path("different_model");
|
|
} catch (const std::runtime_error & error) {
|
|
mismatch_rejected = std::string(error.what()).find("not 'different_model'") != std::string::npos;
|
|
}
|
|
engine::test::require(mismatch_rejected, "explicit embedded selection checks family");
|
|
}
|
|
{
|
|
const engine::model_spec::ScopedSpecOverride model_scope(override_path, gguf_path);
|
|
engine::test::require_eq(
|
|
engine::model_spec::default_contract_spec_path("toy_model").string(),
|
|
std::filesystem::weakly_canonical(override_path).string(), "explicit override wins");
|
|
}
|
|
write_text(workspace, "model_specs/toy_model.json", "{");
|
|
{
|
|
const engine::model_spec::ScopedSpecOverride model_scope("embedded", gguf_path);
|
|
engine::test::require_eq(
|
|
engine::model_spec::default_contract_spec_path("toy_model").generic_string(),
|
|
std::string("@gguf/toy_model.json"), "explicit embedded selection ignores invalid external spec");
|
|
}
|
|
{
|
|
const engine::model_spec::ScopedSpecOverride model_scope(std::nullopt, gguf_path);
|
|
bool rejected = false;
|
|
try {
|
|
(void)engine::model_spec::default_contract_spec_path("toy_model");
|
|
} catch (const std::exception & error) {
|
|
rejected = std::string(error.what()).find("invalid candidate model spec") != std::string::npos;
|
|
}
|
|
engine::test::require(rejected, "invalid external spec must not silently fall back");
|
|
}
|
|
std::filesystem::remove(external);
|
|
{
|
|
const engine::model_spec::ScopedSpecOverride model_scope(std::nullopt, gguf_path);
|
|
engine::test::require_eq(
|
|
engine::model_spec::default_contract_spec_path("toy_model").generic_string(),
|
|
std::string("@gguf/toy_model.json"), "standalone embedded contract fallback");
|
|
const auto contract = engine::model_spec::model_contract("toy_model");
|
|
engine::test::require(contract->request_option_keys.count("old_option") == 1,
|
|
"embedded fallback retains existing option");
|
|
engine::test::require(contract->request_option_keys.count("new_option") == 0,
|
|
"embedded fallback uses its own contract");
|
|
}
|
|
{
|
|
const engine::model_spec::ScopedSpecOverride model_scope("embedded", workspace);
|
|
bool rejected = false;
|
|
try {
|
|
(void)engine::model_spec::default_contract_spec_path("kokoro_tts");
|
|
} catch (const std::runtime_error & error) {
|
|
rejected = std::string(error.what()).find("requires a GGUF with an embedded model spec") != std::string::npos;
|
|
}
|
|
engine::test::require(rejected, "missing embedded spec must not fall back to compiled catalog");
|
|
}
|
|
gguf = gguf_init_empty();
|
|
engine::test::require(gguf != nullptr, "create legacy-spec fixture");
|
|
gguf_set_val_u32(gguf, "audiocpp.model_spec.version", 1);
|
|
gguf_set_val_str(gguf, "audiocpp.model_spec.family", "toy_model");
|
|
gguf_set_val_str(gguf, "audiocpp.model_spec.json", legacy_spec_text("[]").c_str());
|
|
const bool legacy_written = gguf_write_to_file(gguf, gguf_path.string().c_str(), true);
|
|
gguf_free(gguf);
|
|
engine::test::require(legacy_written, "write legacy-spec fixture");
|
|
{
|
|
const engine::model_spec::ScopedSpecOverride model_scope("embedded", gguf_path);
|
|
bool rejected = false;
|
|
try {
|
|
(void)engine::model_spec::default_contract_spec_path("toy_model");
|
|
} catch (const std::runtime_error & error) {
|
|
rejected = std::string(error.what()).find("requires a schema-v1 embedded contract") != std::string::npos;
|
|
}
|
|
engine::test::require(rejected, "explicit embedded legacy contract must fail clearly");
|
|
}
|
|
}
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
void test_unknown_request_option_guidance() {
|
|
engine::model_spec::ModelContract contract;
|
|
contract.metadata.family = "kokoro_tts";
|
|
contract.request_option_keys.insert("speed");
|
|
engine::runtime::validate_spec_backed_request_options({{"speed", "1.0"}}, contract, "Kokoro TTS");
|
|
engine::runtime::validate_spec_backed_request_options({}, {{"speed", {"1.0"}}}, contract, "Kokoro TTS");
|
|
|
|
const std::string expected =
|
|
"unknown Kokoro TTS request option: speeed. "
|
|
"The option may be misspelled or require a newer audio.cpp runtime. "
|
|
"Supported options: https://github.com/0xShug0/audio.cpp/blob/main/model_specs/kokoro_tts.json. "
|
|
"Update audio.cpp: https://github.com/0xShug0/audio.cpp/releases";
|
|
for (const bool list_valued : {false, true}) {
|
|
bool rejected = false;
|
|
try {
|
|
if (list_valued) {
|
|
engine::runtime::validate_spec_backed_request_options(
|
|
{}, {{"speeed", {"1.0"}}}, contract, "Kokoro TTS");
|
|
} else {
|
|
engine::runtime::validate_spec_backed_request_options(
|
|
{{"speeed", "1.0"}}, contract, "Kokoro TTS");
|
|
}
|
|
} catch (const std::runtime_error & error) {
|
|
engine::test::require_eq(std::string(error.what()), expected, "unknown option guidance");
|
|
rejected = true;
|
|
}
|
|
engine::test::require(rejected, "unknown scalar and list options must still be rejected");
|
|
}
|
|
}
|
|
|
|
void test_loading_and_resource_bundle() {
|
|
// Resource bundles resolve required files, tensor source files, and missing optional files.
|
|
const auto root = make_temp_root();
|
|
const auto model_root = root / "model";
|
|
std::filesystem::create_directories(model_root);
|
|
write_text(model_root, "config.json", "{\"ok\":true}");
|
|
write_text(model_root, "model.safetensors", "");
|
|
const auto spec_path = write_text(root, "toy_model.json", legacy_spec_text("[]"));
|
|
|
|
const auto spec = engine::model_spec::load_spec(spec_path);
|
|
engine::test::require_eq(
|
|
spec.require("family").as_string(),
|
|
std::string("toy_model"),
|
|
"loaded spec family");
|
|
const auto bundle = engine::model_spec::load_resource_bundle(model_root, spec_path);
|
|
engine::test::require_eq(
|
|
bundle.read_text("config"),
|
|
std::string("{\"ok\":true}"),
|
|
"resource bundle config");
|
|
engine::test::require(bundle.has_file("weights"), "tensor source file should be registered");
|
|
engine::test::require(!bundle.has_file("tokenizer"), "missing optional file should not be registered");
|
|
|
|
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");
|
|
}
|
|
|
|
void test_vad_timestamp_capability() {
|
|
const auto root = make_temp_root();
|
|
auto spec = json::parse(schema_v1_spec_text("[]")).as_object();
|
|
spec["category"] = json::Value::make_string("audio_tools");
|
|
spec["tasks"] = json::parse(R"(["vad"])");
|
|
for (const bool segments : {false, true}) {
|
|
spec["capabilities"] = json::parse(segments ? R"({"vad":["speech_segments"]})" : R"({"vad":["chunk_planning"]})");
|
|
const auto path = write_text(root, "toy_model.json", json::stringify(json::Value::make_object(spec)));
|
|
(void)engine::model_spec::load_spec(path);
|
|
const engine::model_spec::ScopedSpecOverride spec_override(root);
|
|
const auto capabilities = engine::model_spec::advertised_capabilities("toy_model");
|
|
engine::test::require(capabilities.has_value(), "VAD capabilities should be projected");
|
|
engine::test::require_eq(capabilities->supports_timestamps, segments,
|
|
"only speech_segments should enable VAD timestamps");
|
|
}
|
|
std::filesystem::remove_all(root);
|
|
}
|
|
|
|
// 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<std::string> 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");
|
|
}
|
|
|
|
void test_package_load_resolvers() {
|
|
const auto registry = engine::runtime::make_default_registry();
|
|
// No directory or weights are created: resolution must use only the declared files.
|
|
const std::filesystem::path root = "uninstalled-package-root";
|
|
struct PackageCase {
|
|
const char * family;
|
|
std::vector<std::filesystem::path> files;
|
|
std::unordered_map<std::string, std::string> session_options;
|
|
std::unordered_map<std::string, std::string> load_options = {};
|
|
const char * entry = nullptr;
|
|
};
|
|
// Filenames are the installed-relative entries in the shipped package specs.
|
|
const std::vector<PackageCase> cases{
|
|
{"minimax_h3", {"configuration.json", "text_encoder_q4_k.gguf", "dit_int8.gguf", "video_vae.gguf"},
|
|
{}, {}, "dit_int8.gguf"},
|
|
{"minimax_h3", {"text_encoder_q4_k.gguf", "dit.gguf", "audio_vae_folded_f16.gguf"},
|
|
{}, {}, "dit.gguf"},
|
|
{"lfm2_audio", {"LFM2.5-Audio-1.5B-Q8_0.gguf", "mmproj-LFM2.5-Audio-1.5B-Q8_0.gguf",
|
|
"vocoder-LFM2.5-Audio-1.5B-Q8_0.gguf", "tokenizer-LFM2.5-Audio-1.5B-Q8_0.gguf"},
|
|
{{"lfm2_audio.model_gguf", "LFM2.5-Audio-1.5B-Q8_0.gguf"},
|
|
{"lfm2_audio.mmproj_gguf", "mmproj-LFM2.5-Audio-1.5B-Q8_0.gguf"},
|
|
{"lfm2_audio.vocoder_gguf", "vocoder-LFM2.5-Audio-1.5B-Q8_0.gguf"},
|
|
{"lfm2_audio.detokenizer_gguf", "tokenizer-LFM2.5-Audio-1.5B-Q8_0.gguf"}}},
|
|
{"lfm2_audio", {"tokenizer-LFM2.5-Audio-1.5B-JP-F32.gguf", "vocoder-LFM2.5-Audio-1.5B-JP-F32.gguf",
|
|
"mmproj-LFM2.5-Audio-1.5B-JP-F32.gguf", "LFM2.5-Audio-1.5B-JP-F32.gguf"},
|
|
{{"lfm2_audio.model_gguf", "LFM2.5-Audio-1.5B-JP-F32.gguf"},
|
|
{"lfm2_audio.mmproj_gguf", "mmproj-LFM2.5-Audio-1.5B-JP-F32.gguf"},
|
|
{"lfm2_audio.vocoder_gguf", "vocoder-LFM2.5-Audio-1.5B-JP-F32.gguf"},
|
|
{"lfm2_audio.detokenizer_gguf", "tokenizer-LFM2.5-Audio-1.5B-JP-F32.gguf"}}},
|
|
{"liveavatar", {"Wan2.2-S2V-Support-Q4_K_S-F16.gguf", "Wan2.2-S2V-VAE-F16.gguf",
|
|
"Wan2.2-S2V-14B-NVFP4-LORA.gguf"}, {},
|
|
{{"liveavatar.support_gguf", "Wan2.2-S2V-Support-Q4_K_S-F16.gguf"},
|
|
{"liveavatar.vae_gguf", "Wan2.2-S2V-VAE-F16.gguf"},
|
|
{"liveavatar.denoiser_gguf", "Wan2.2-S2V-14B-NVFP4-LORA.gguf"}}},
|
|
{"yue2", {"sidecars/yue2-model-config.json", "yue2-3b-bf16.gguf"},
|
|
{{"yue2.model_gguf", "yue2-3b-bf16.gguf"}}},
|
|
{"yue2", {"sidecars/yue2-vae-config.json", "yue2-vae-f32.gguf"},
|
|
{{"yue2.vae_gguf", "yue2-vae-f32.gguf"}}},
|
|
{"auk", {"config/auk-base.yaml", "auk-base-q8_0.gguf"},
|
|
{{"auk.model_gguf", "auk-base-q8_0.gguf"}, {"auk.variant", "base"}}},
|
|
{"auk", {"config/auk-flash.yaml", "auk-flash-f16.gguf"},
|
|
{{"auk.model_gguf", "auk-flash-f16.gguf"}, {"auk.variant", "flash"}}},
|
|
{"auk", {"tokenizer/tokenizer.json", "qwen2.5-omni-3b-q8_0.gguf"},
|
|
{{"auk.qwen_gguf", "qwen2.5-omni-3b-q8_0.gguf"}}},
|
|
{"auk", {"auk-vae-f32.gguf"}, {{"auk.vae_gguf", "auk-vae-f32.gguf"}}},
|
|
{"minimax_music3", {"config.json", "language_model_q4_0.gguf", "rvq_depth_decoder_q8_0.gguf",
|
|
"condition_encoder.gguf", "transformer_q4_0.gguf", "vocoder.gguf"},
|
|
{{"minimax_music3.language_model_gguf", "language_model_q4_0.gguf"},
|
|
{"minimax_music3.rvq_depth_decoder_gguf", "rvq_depth_decoder_q8_0.gguf"},
|
|
{"minimax_music3.flow_transformer_gguf", "transformer_q4_0.gguf"}}},
|
|
{"minimax_music3", {"language_model_q8_0.gguf", "rvq_depth_decoder_q8_0.gguf", "transformer_q8_0.gguf"},
|
|
{{"minimax_music3.language_model_gguf", "language_model_q8_0.gguf"},
|
|
{"minimax_music3.rvq_depth_decoder_gguf", "rvq_depth_decoder_q8_0.gguf"},
|
|
{"minimax_music3.flow_transformer_gguf", "transformer_q8_0.gguf"}}},
|
|
{"minimax_music3", {"language_model_bf16.gguf", "rvq_depth_decoder_bf16.gguf", "transformer_bf16.gguf"},
|
|
{{"minimax_music3.language_model_gguf", "language_model_bf16.gguf"},
|
|
{"minimax_music3.rvq_depth_decoder_gguf", "rvq_depth_decoder_bf16.gguf"},
|
|
{"minimax_music3.flow_transformer_gguf", "transformer_bf16.gguf"}}},
|
|
};
|
|
for (const auto & test : cases) {
|
|
if (!registry.supports_family(test.family)) continue;
|
|
const auto target = registry.resolve_package_load(test.family, {root, test.files, "gguf"});
|
|
const std::string label = std::string(test.family) + ": " + test.files.back().generic_string();
|
|
engine::test::require_eq(target.family, std::string(test.family), label + " family");
|
|
engine::test::require_eq(target.model_path, test.entry ? root / test.entry : root, label + " model path");
|
|
engine::test::require(target.session_options == test.session_options, label + " exact session options");
|
|
engine::test::require(target.load_options == test.load_options, label + " exact load options");
|
|
}
|
|
|
|
// Silero uses the base resolver; no VAD model is loaded.
|
|
const engine::assets::ModelPackage generic{root, {"config.json", "weights/z-first.GGUF", "weights/a-second.gguf"}, "gguf"};
|
|
const auto target = registry.resolve_package_load("silero_vad", generic);
|
|
engine::test::require_eq(target.family, std::string("silero_vad"), "generic package family");
|
|
engine::test::require_eq(target.model_path, root / "weights/z-first.GGUF", "generic first declared GGUF");
|
|
engine::test::require(target.load_options.empty() && target.session_options.empty(), "generic package adds no component defaults");
|
|
|
|
bool rejected = false;
|
|
try {
|
|
(void)registry.resolve_package_load("package_resolver_unknown_family", generic);
|
|
} catch (const std::runtime_error & error) {
|
|
rejected = true;
|
|
engine::test::require(std::string(error.what()).find("package_resolver_unknown_family") != std::string::npos,
|
|
"unknown package family error names the family");
|
|
}
|
|
engine::test::require(rejected, "unknown package family must not fall through to an automatic loader");
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
try {
|
|
test_legacy_dependencies_schema();
|
|
test_download_kinds_schema();
|
|
test_typed_schema_renamed_dependencies();
|
|
test_dependency_option_mapping_from_production_spec();
|
|
test_options_schema();
|
|
test_component_exec_plan_schema();
|
|
test_option_name_mapping_from_production_spec();
|
|
test_echo_text_chunk_size_contract();
|
|
test_schema_v1_metadata_projection();
|
|
test_contract_projection_ignores_package_metadata_validation();
|
|
test_legacy_spec_contract_behavior_unchanged();
|
|
test_experimental_spec_without_installable_package();
|
|
test_contract_spec_prefers_workspace_over_package_local_spec();
|
|
test_contract_spec_priority_with_embedded_gguf();
|
|
test_unknown_request_option_guidance();
|
|
test_loading_and_resource_bundle();
|
|
test_task_vocabulary_is_consistent();
|
|
test_vad_timestamp_capability();
|
|
test_shipped_model_specs_declare_known_tasks();
|
|
test_package_load_resolvers();
|
|
} catch (const std::exception & error) {
|
|
std::cerr << "model_spec_system_test failed: " << error.what() << "\n";
|
|
return 1;
|
|
}
|
|
std::cout << "model_spec_system_test passed\n";
|
|
return 0;
|
|
}
|