diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 099634301e89..3b19e3b67730 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,6 +6,12 @@ target_compile_features(test-server-prefill PRIVATE cxx_std_17) add_test(NAME test-server-prefill COMMAND test-server-prefill) set_tests_properties(test-server-prefill PROPERTIES LABELS "main") +# Model-free validator: whether a saved page's KV belongs to the loaded adapters. +add_executable(test-server-page-adapters test-server-page-adapters.cpp) +target_compile_features(test-server-page-adapters PRIVATE cxx_std_17) +add_test(NAME test-server-page-adapters COMMAND test-server-page-adapters) +set_tests_properties(test-server-page-adapters PROPERTIES LABELS "main") + function(llama_build source) set(TEST_SOURCES ${source} ${ARGN}) diff --git a/tests/test-server-page-adapters.cpp b/tests/test-server-page-adapters.cpp new file mode 100644 index 000000000000..bd75684ebdc5 --- /dev/null +++ b/tests/test-server-page-adapters.cpp @@ -0,0 +1,64 @@ +#include "../tools/server/server-page-adapters.h" + +#include +#include +#include + +static void require(bool condition, const char * message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +using page = std::vector; +using loaded = std::vector; + +static std::optional> decide(const page & p, const loaded & l) { + std::string why; + auto scales = server_page_adapter_scales(p, l, why); + require(scales.has_value() == why.empty(), "a refusal always names its reason, a match never does"); + return scales; +} + +int main() { + const server_loaded_adapter gene { "/genome/delia/adapter.gguf", 4365792, 100 }; + const server_loaded_adapter dream { "/engine-train/gene-b758.gguf", 4365792, 100 }; + + // what this catches: the IntelMac 11-03 refusal. The page carried the gene and the dream + // stage's runtime adapter, both at 0.0; the relaunched engine loads only the gene. + auto s = decide({ { gene.path, 0.0f, gene.size, gene.mtime }, { dream.path, 0.0f, dream.size, dream.mtime } }, { gene }); + require(s && s->size() == 1 && (*s)[0] == 0.0f, "a page whose adapters were all at 0.0 restores under any loaded set"); + + // the same lists in both: the active adapter keeps the page's scale + s = decide({ { gene.path, 0.5f, gene.size, gene.mtime } }, { gene }); + require(s && (*s)[0] == 0.5f, "an active adapter runs at the scale the KV was computed under"); + + // an adapter loaded now but absent from the page runs at 0.0, never at its default scale + s = decide({ { gene.path, 0.5f, gene.size, gene.mtime } }, { dream, gene }); + require(s && (*s)[0] == 0.0f && (*s)[1] == 0.5f, "an extra loaded adapter restores at 0.0, in load order"); + + // a page computed with no adapters restores on an engine that has some loaded + s = decide({}, { gene }); + require(s && (*s)[0] == 0.0f, "an adapter-free page is base-model KV"); + + // an active adapter that is not loaded: its KV is not this engine's + require(!decide({ { dream.path, 1.0f, dream.size, dream.mtime } }, { gene }), "an active adapter missing from the engine refuses"); + + // the same path with new weights (a gene adopted in place) + require(!decide({ { gene.path, 1.0f, gene.size, gene.mtime - 1 } }, { gene }), "an active adapter whose file changed refuses"); + // an unreadable file proves nothing about its weights + require(!decide({ { gene.path, 1.0f, gene.size, gene.mtime } }, { { gene.path, 0, 0 } }), "an active adapter whose file cannot be read refuses"); + // ... even when the page was saved while it was unreadable too: two unknowns never match + require(!decide({ { gene.path, 1.0f, 0, 0 } }, { { gene.path, 0, 0 } }), "an unreadable revision never equals another"); + // an inactive adapter's revision is irrelevant: it never touched the KV + require(decide({ { gene.path, 0.0f, 1, 1 } }, { gene }).has_value(), "an inactive adapter's revision never refuses"); + + // one loaded copy cannot stand for two active entries + require(!decide({ { gene.path, 0.5f, gene.size, gene.mtime }, { gene.path, 0.5f, gene.size, gene.mtime } }, { gene }), + "each active entry claims its own loaded adapter"); + + require(!decide({ { gene.path, std::numeric_limits::quiet_NaN(), gene.size, gene.mtime } }, { gene }), + "a non-finite recorded scale refuses"); + return 0; +} diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp index 87672928aeb3..a6202ca09a41 100644 --- a/tools/server/server-context.cpp +++ b/tools/server/server-context.cpp @@ -9,6 +9,7 @@ #include "server-queue.h" #include "server-schema.h" #include "server-stream.h" +#include "server-page-adapters.h" #include "server-prefill.h" #include "build-info.h" @@ -2892,9 +2893,10 @@ struct server_context_impl { size_t nread = 0; try { // Continuum fork: the adapters the state was computed under (see SLOT_SAVE), read - // FIRST so a page whose identity is unknown or foreign never touches the KV. A - // page without them cannot be reused safely (its KV may belong to other adapters), - // so it is refused: the caller's cold prefill is correct; a mislabelled reuse is not. + // FIRST so a page whose identity is unknown or foreign never touches the KV. A page + // without them cannot be reused safely, so it is refused: the caller's cold prefill + // is correct; a mislabelled reuse is not. Its identity is its ACTIVE adapters + // (server-page-adapters.h): an adapter it carried at 0.0 never refuses it. std::vector page_lora = params_base.lora_adapters; // copy: ptr, task_name, prompt_prefix { const std::string lora_path = filepath + ".lora"; @@ -2903,27 +2905,38 @@ struct server_context_impl { auto get = [&](void * ptr, size_t n) { in.read(reinterpret_cast(ptr), n); ok = ok && (bool) in; }; uint32_t magic = 0, version = 0, n_lora = 0; get(&magic, sizeof(magic)); get(&version, sizeof(version)); get(&n_lora, sizeof(n_lora)); - ok = ok && magic == 0x4C4F5241u && version == 2 && n_lora == page_lora.size(); + ok = ok && magic == 0x4C4F5241u && version == 2 && n_lora <= 4096; + std::vector recorded; for (uint32_t i = 0; ok && i < n_lora; ++i) { uint32_t n_path = 0; get(&n_path, sizeof(n_path)); ok = ok && n_path <= 4096; if (!ok) break; - std::string path(n_path, '\0'); - get(path.data(), n_path); - float scale = 0.0f; - get(&scale, sizeof(scale)); - uint64_t size = 0; int64_t mtime = 0; - get(&size, sizeof(size)); get(&mtime, sizeof(mtime)); - // the same file AS LOADED: a gene adopted in place (same path, new weights) - // is another adapter, and KV computed under the old weights is not its own; - // an unreadable file proves nothing, so it refuses (BigMama on #54) - const auto rev = lora_file_revision(page_lora[i].path); - ok = ok && path == page_lora[i].path && std::isfinite(scale) && rev.first != 0 && size == rev.first && mtime == rev.second; - if (ok) page_lora[i].scale = scale; + server_page_adapter a { std::string(n_path, '\0'), 0.0f, 0, 0 }; + get(a.path.data(), n_path); + get(&a.scale, sizeof(a.scale)); + get(&a.size, sizeof(a.size)); get(&a.mtime, sizeof(a.mtime)); + recorded.push_back(std::move(a)); } if (!ok) { - throw std::runtime_error("the page's adapter identity is missing or does not match the loaded adapters: " + lora_path); + throw std::runtime_error("the page's adapter identity is missing or unreadable: " + lora_path); + } + // the same file AS LOADED: a gene adopted in place (same path, new weights) is + // another adapter, and KV computed under the old weights is not its own; an + // unreadable file proves nothing, so it refuses (BigMama on #54) + std::vector loaded; + loaded.reserve(page_lora.size()); + for (const auto & l : page_lora) { + const auto rev = lora_file_revision(l.path); + loaded.push_back({ l.path, rev.first, rev.second }); + } + std::string why; + const auto scales = server_page_adapter_scales(recorded, loaded, why); + if (!scales) { + throw std::runtime_error("the page's adapter identity does not match the loaded adapters (" + why + "): " + lora_path); + } + for (size_t i = 0; i < page_lora.size(); ++i) { + page_lora[i].scale = (*scales)[i]; } } diff --git a/tools/server/server-page-adapters.h b/tools/server/server-page-adapters.h new file mode 100644 index 000000000000..11574ea4e52d --- /dev/null +++ b/tools/server/server-page-adapters.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Continuum fork: whether a saved page's KV belongs to this engine's adapters. +// +// A page's `.lora` sidecar records every adapter its slot carried, with its scale and the +// file revision (size, mtime) it was computed under. An adapter at scale 0 adds nothing to +// the KV, so the page's identity is the base model plus its ACTIVE adapters (scale != 0). +// Refusing on the full loaded list made every relaunch discard valid pages: an engine that +// had a training adapter attached at 0.0 saved pages a relaunched engine, without it, could +// never restore, and each turn re-prefilled its whole prompt (IntelMac, 2026-11-03). + +// One adapter as the page recorded it. +struct server_page_adapter { + std::string path; + float scale; + uint64_t size; + int64_t mtime; +}; + +// One adapter as this engine has it loaded: its path and its file's revision now +// (size 0 when the file cannot be read, which proves nothing about its weights). +struct server_loaded_adapter { + std::string path; + uint64_t size; + int64_t mtime; +}; + +// The scale the restored slot runs each LOADED adapter at, in load order, or nullopt with +// `why` when the page's KV is not this engine's. Each active page adapter claims one loaded +// adapter at the same path and revision and keeps the page's scale, so the slot computes +// exactly what the KV holds; every other loaded adapter runs at 0.0, never at its default. +inline std::optional> server_page_adapter_scales( + const std::vector & page, + const std::vector & loaded, + std::string & why) { + std::vector scales(loaded.size(), 0.0f); + std::vector claimed(loaded.size(), false); + for (const auto & p : page) { + if (!std::isfinite(p.scale)) { + why = "a recorded scale is not finite: " + p.path; + return std::nullopt; + } + if (p.scale == 0.0f) { + continue; + } + bool matched = false; + for (size_t i = 0; i < loaded.size() && !matched; ++i) { + const auto & l = loaded[i]; + if (claimed[i] || l.path != p.path) { + continue; + } + if (l.size == 0 || l.size != p.size || l.mtime != p.mtime) { + why = "an active adapter's file changed since the page was saved: " + p.path; + return std::nullopt; + } + claimed[i] = true; + scales[i] = p.scale; + matched = true; + } + if (!matched) { + why = "an active adapter is not loaded: " + p.path; + return std::nullopt; + } + } + return scales; +}