From fc32e0d71ff11f9a496e3f07bbad2970a5997e15 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 2 Nov 2026 15:22:21 -0500 Subject: [PATCH 1/2] Preserve intrinsic tiny decoder policy across model refresh --- CMakeLists.txt | 21 ++- README.md | 23 ++++ patches/README.md | 5 + patches/intrinsic_tiny_decoder_policy.patch | 128 +++++++++++++++++++ src/sd_dart_decoder_policy.cpp | 33 +++++ src/sd_dart_exit.cpp | 13 +- src/sd_dart_internal.h | 6 + src/sd_dart_wrapper.h | 11 ++ tests/native/decoder_policy_runtime_test.cpp | 94 ++++++++++++++ tests/native/exit_teardown_test.cpp | 4 + tests/test_decoder_policy_runtime.py | 69 ++++++++++ tests/test_tools.py | 1 + 12 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 patches/intrinsic_tiny_decoder_policy.patch create mode 100644 src/sd_dart_decoder_policy.cpp create mode 100644 tests/native/decoder_policy_runtime_test.cpp create mode 100644 tests/test_decoder_policy_runtime.py diff --git a/CMakeLists.txt b/CMakeLists.txt index ab27d3c..6a571bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,10 +32,15 @@ function(sd_native_carry_patch patch) get_target_property(sources stable-diffusion SOURCES) foreach(path IN LISTS ARGN) set(upstream_file "${SD_NATIVE_UPSTREAM_DIR}/${path}") - if(NOT "${upstream_file}" IN_LIST sources) + string(MD5 source_key "${upstream_file}") + get_property(current_source GLOBAL PROPERTY "SD_NATIVE_PATCHED_${source_key}") + if(NOT current_source) + set(current_source "${upstream_file}") + endif() + if(NOT "${current_source}" IN_LIST sources) message(FATAL_ERROR "patches/${patch}: ${path} is not a stable-diffusion source") endif() - file(READ "${upstream_file}" text) + file(READ "${current_source}" text) # A Windows checkout can convert upstream's line endings; patches use LF. string(REPLACE "\r\n" "\n" text "${text}") file(WRITE "${patched_dir}/${path}" "${text}") @@ -57,15 +62,21 @@ function(sd_native_carry_patch patch) foreach(path IN LISTS ARGN) set(upstream_file "${SD_NATIVE_UPSTREAM_DIR}/${path}") + string(MD5 source_key "${upstream_file}") + get_property(current_source GLOBAL PROPERTY "SD_NATIVE_PATCHED_${source_key}") + if(NOT current_source) + set(current_source "${upstream_file}") + endif() set(patched_file "${patched_dir}/${path}") - file(READ "${upstream_file}" original) + file(READ "${current_source}" original) string(REPLACE "\r\n" "\n" original "${original}") file(READ "${patched_file}" patched) if(original STREQUAL patched) message(FATAL_ERROR "patches/${patch} left ${path} unchanged:\n${output}") endif() - list(REMOVE_ITEM sources "${upstream_file}") + list(REMOVE_ITEM sources "${current_source}") list(APPEND sources "${patched_file}") + set_property(GLOBAL PROPERTY "SD_NATIVE_PATCHED_${source_key}" "${patched_file}") # Quoted includes in the copy must still find the upstream files next to it. get_filename_component(upstream_file_dir "${upstream_file}" DIRECTORY) set_source_files_properties("${patched_file}" TARGET_DIRECTORY stable-diffusion @@ -78,10 +89,12 @@ endfunction() sd_native_carry_patch(pickle_line_span.patch src/model_io/pickle_io.cpp) sd_native_carry_patch(fp8_metal_f16_load.patch src/model_manager.cpp src/core/backend_fit.cpp) sd_native_carry_patch(lora_immediate_fallback.patch src/pipeline/diffusion_engine.cpp) +sd_native_carry_patch(intrinsic_tiny_decoder_policy.patch src/stable-diffusion.cpp src/pipeline/diffusion_engine.cpp) # Wrapper exports are compiled into the upstream library target, so they share # its SD_API export macro and reach upstream without patching it. target_sources(stable-diffusion PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src/sd_dart_decoder_policy.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/sd_dart_device.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/sd_dart_exit.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/sd_dart_internal.h" diff --git a/README.md b/README.md index ac35ba5..e22537d 100644 --- a/README.md +++ b/README.md @@ -669,3 +669,26 @@ Archive names: ## License MIT. Release archives include the stable-diffusion.cpp and ggml licenses. + +### Intrinsic tiny-decoder policy + +`sd_dart_new_sd_ctx_intrinsic_tiny_v1(params)` creates a tracked context that +requires an intrinsic SDXS (512-DS or 09) tiny decoder without an external VAE +or TAE. It rejects other candidates before runner construction, load-time +backend probes and auto-fit. Automatic source refresh rechecks the candidate +under the context execution lock before rebuilding; image and video calls +check the decoder again after refresh, before generation. An atomic source +replacement by a full-VAE model therefore fails instead of silently changing +the decoder. A refusal returns null on load, or false with no images on +generation; `sd_dart_last_error()` reports the reason. Dispose with +`sd_dart_exit_free()`. Existing unrestricted contexts keep their behavior. + +The versioned export is the capability probe; an older runtime without it +cannot offer this invariant. This is the owner prerequisite for +[llamadart #1047](https://github.com/leehack/llamadart/issues/1047), not physical +iPhone qualification or an artifact publication. Run the model-backed +regression with `SD_INTRINSIC_TINY_MODEL= +SD_FULL_VAE_MODEL= python3 -m unittest discover -s tests +-p test_decoder_policy_runtime.py`. It runs unchanged and refreshed SDXS, +refuses the SDXS-to-SD-Turbo replacement, and keeps an unrestricted reload +control on CPU and the available Metal backend. diff --git a/patches/README.md b/patches/README.md index a44241e..57cfd9a 100644 --- a/patches/README.md +++ b/patches/README.md @@ -15,3 +15,8 @@ an upstream issue or pull request to retire it. | `pickle_line_span.patch` | `src/model_io/pickle_io.cpp` | A legacy PyTorch checkpoint above 2 GiB is rejected: the pickle scanner narrows the rest of the file to `int` before looking for a newline ([#23](https://github.com/leehack/stable-diffusion-native/issues/23)). | `tests/test_pickle_span.py` | | `fp8_metal_f16_load.patch` | `src/model_manager.cpp`, `src/core/backend_fit.cpp` | An FP8 (E4M3 or E5M2) checkpoint aborts the process on Metal: ggml-metal claims FP8 `MUL_MAT` but has no FP8 kernel. Weights a Metal backend computes with now load as F16, which holds every FP8 value, so they take twice their file size; other backends keep FP8 ([#29](https://github.com/leehack/stable-diffusion-native/issues/29)). Auto-fit plans them at that size, and FP8 LoRA tensors, which crashed ggml-cpu, load as F16 on every backend ([#31](https://github.com/leehack/stable-diffusion-native/issues/31)). | `tests/test_fp8_runtime.py` | | `lora_immediate_fallback.patch` | `src/pipeline/diffusion_engine.cpp` | The `immediately` LoRA apply mode, which `auto` selects for unquantized weights, crashes the CPU merging into FP8 weights (ggml-cpu can neither multiply nor write FP8) and aborts Metal merging into F16 weights (ggml-metal adds only equal types), which every model has: convolution weights load as F16. FP8 weights, and every model on a backend that cannot run that add outside host memory, now take the LoRA `at_runtime`, with a warning when `immediately` was requested ([#31](https://github.com/leehack/stable-diffusion-native/issues/31)). | `tests/test_fp8_runtime.py` | + +| `intrinsic_tiny_decoder_policy.patch` | `src/stable-diffusion.cpp`, `src/pipeline/diffusion_engine.cpp` | Preserves an opt-in intrinsic tiny-decoder context policy across automatic model source refresh, rejecting a full-VAE replacement before runner probes or decode ([llamadart #1047](https://github.com/leehack/llamadart/issues/1047)). Retire when upstream provides a context-level decoder invariant. | `tests/test_decoder_policy_runtime.py` | + +Patches touching the same source compose in declaration order. Each next +patch applies to the preceding build-tree copy; the submodule stays pristine. diff --git a/patches/intrinsic_tiny_decoder_policy.patch b/patches/intrinsic_tiny_decoder_policy.patch new file mode 100644 index 0000000..bc455fd --- /dev/null +++ b/patches/intrinsic_tiny_decoder_policy.patch @@ -0,0 +1,128 @@ +--- a/src/stable-diffusion.cpp ++++ b/src/stable-diffusion.cpp +@@ -11,6 +11,10 @@ + #include "pipeline/diffusion_engine.h" + #include "pipeline/generation.h" + #include "pipeline/request.h" ++ ++// Implemented by the owner wrapper; policy state outlives C exit teardown. ++bool sd_dart_intrinsic_tiny_required(const void* engine); ++void sd_dart_set_intrinsic_tiny_required(const void* engine, bool required); + + #define NONE_STR "NONE" + +@@ -645,7 +649,7 @@ + StableDiffusionGGML* sd = nullptr; + }; + +-sd_ctx_t* new_sd_ctx(const sd_ctx_params_t* sd_ctx_params) { ++static sd_ctx_t* new_sd_ctx_with_decoder_policy(const sd_ctx_params_t* sd_ctx_params, bool intrinsic_tiny) { + sd_ctx_t* sd_ctx = (sd_ctx_t*)malloc(sizeof(sd_ctx_t)); + if (sd_ctx == nullptr) { + return nullptr; +@@ -657,7 +661,11 @@ + return nullptr; + } + ++ if (intrinsic_tiny) { ++ sd_dart_set_intrinsic_tiny_required(sd_ctx->sd, true); ++ } + if (!sd_ctx->sd->init(sd_ctx_params)) { ++ sd_dart_set_intrinsic_tiny_required(sd_ctx->sd, false); + delete sd_ctx->sd; + sd_ctx->sd = nullptr; + free(sd_ctx); +@@ -666,8 +674,31 @@ + return sd_ctx; + } + ++sd_ctx_t* new_sd_ctx(const sd_ctx_params_t* sd_ctx_params) { ++ return new_sd_ctx_with_decoder_policy(sd_ctx_params, false); ++} ++ ++sd_ctx_t* sd_dart_new_sd_ctx_intrinsic_tiny_impl(const sd_ctx_params_t* params) { ++ return new_sd_ctx_with_decoder_policy(params, true); ++} ++ ++static bool decoder_policy_valid(const StableDiffusionGGML& sd) { ++ if (!sd_dart_intrinsic_tiny_required(&sd)) { ++ return true; ++ } ++ if ((sd.version == VERSION_SDXS_512_DS || sd.version == VERSION_SDXS_09) && ++ sd.config_ && sd.uses_tae() && !sd.tae_preview_only() && ++ strlen(SAFE_STR(sd.config_->params.vae_path)) == 0 && ++ strlen(SAFE_STR(sd.config_->params.taesd_path)) == 0) { ++ return true; ++ } ++ LOG_ERROR("intrinsic tiny decoder policy v1 rejects the current decoder"); ++ return false; ++} ++ + void free_sd_ctx(sd_ctx_t* sd_ctx) { + if (sd_ctx->sd != nullptr) { ++ sd_dart_set_intrinsic_tiny_required(sd_ctx->sd, false); + delete sd_ctx->sd; + sd_ctx->sd = nullptr; + } +@@ -759,7 +790,7 @@ + if (!result_out || !sd_ctx || !sd_ctx->sd || !params) + return false; + StableDiffusionGGML::ExecutionScope execution(*sd_ctx->sd); +- if (!execution.ready) ++ if (!execution.ready || !decoder_policy_valid(*sd_ctx->sd)) + return false; + sd_image_t* images = nullptr; + int count = 0; +@@ -785,7 +816,7 @@ + if (!result_out || !sd_ctx || !sd_ctx->sd || !params) + return false; + StableDiffusionGGML::ExecutionScope execution(*sd_ctx->sd); +- if (!execution.ready) ++ if (!execution.ready || !decoder_policy_valid(*sd_ctx->sd)) + return false; + auto* result = static_cast(calloc(1, sizeof(sd_video_generation_result_t))); + if (!result) +--- a/src/pipeline/diffusion_engine.cpp ++++ b/src/pipeline/diffusion_engine.cpp +@@ -218,10 +218,31 @@ + return candidate.refresh_files(false) && apply_model_update(std::move(candidate)); + } + ++bool sd_dart_intrinsic_tiny_required(const void* engine); ++ ++static bool intrinsic_tiny_candidate_allowed(const void* engine, SDVersion version, ++ const sd_ctx_params_t* params) { ++ if (!sd_dart_intrinsic_tiny_required(engine)) { ++ return true; ++ } ++ if ((version == VERSION_SDXS_512_DS || version == VERSION_SDXS_09) && params && ++ strlen(SAFE_STR(params->vae_path)) == 0 && strlen(SAFE_STR(params->taesd_path)) == 0) { ++ return true; ++ } ++ LOG_ERROR("intrinsic tiny decoder policy v1 rejects the updated model or external decoder"); ++ return false; ++} ++ + bool StableDiffusionGGML::apply_model_update(ModelLoader candidate, + std::unique_ptr next_config, + RunnerGroups groups) { + const SDVersion next_version = candidate.get_sd_version(); ++ const auto* decoder_config = next_config ? next_config.get() : config_.get(); ++ // Reject a changed family before runners, probes or weight placement. ++ if (!intrinsic_tiny_candidate_allowed(this, next_version, ++ decoder_config ? &decoder_config->params : nullptr)) { ++ return false; ++ } + if (next_version == VERSION_COUNT) { + LOG_ERROR("cannot identify updated diffusion model"); + return false; +@@ -975,6 +996,9 @@ + LOG_INFO("Version: %s ", sd_version_name(version)); + } + ++ if (!intrinsic_tiny_candidate_allowed(this, version, &configuration->params)) { ++ return false; ++ } + if (auto_fit_enabled) { + if (!sd::backend_fit::derive_backend_specs(model_loader, + sd_type_to_ggml_type(sd_ctx_params->wtype), diff --git a/src/sd_dart_decoder_policy.cpp b/src/sd_dart_decoder_policy.cpp new file mode 100644 index 0000000..c6b7193 --- /dev/null +++ b/src/sd_dart_decoder_policy.cpp @@ -0,0 +1,33 @@ +#include "sd_dart_internal.h" + +#include +#include + +namespace { +struct Policies { + std::mutex mutex; + std::unordered_set intrinsic_tiny; +}; + +Policies& policies() { + // Native calls and exit teardown can still use the registry at C exit. + static auto* value = new Policies(); + return *value; +} +} // namespace + +bool sd_dart_intrinsic_tiny_required(const void* engine) { + auto& state = policies(); + std::lock_guard lock(state.mutex); + return state.intrinsic_tiny.count(engine) != 0; +} + +void sd_dart_set_intrinsic_tiny_required(const void* engine, bool required) { + auto& state = policies(); + std::lock_guard lock(state.mutex); + if (required) { + state.intrinsic_tiny.insert(engine); + } else { + state.intrinsic_tiny.erase(engine); + } +} diff --git a/src/sd_dart_exit.cpp b/src/sd_dart_exit.cpp index c5045b6..f449061 100644 --- a/src/sd_dart_exit.cpp +++ b/src/sd_dart_exit.cpp @@ -414,7 +414,7 @@ SdDartStaticsCall::~SdDartStaticsCall() { } } -sd_ctx_t* sd_dart_new_sd_ctx(const sd_ctx_params_t* sd_ctx_params) { +static sd_ctx_t* new_context(const sd_ctx_params_t* sd_ctx_params, bool intrinsic_tiny) { Call call(kObjectCall | kWorkCall); ErrorWindow errors; // The pinned upstream asserts when a VRAM budget prevents its load-time @@ -451,11 +451,20 @@ sd_ctx_t* sd_dart_new_sd_ctx(const sd_ctx_params_t* sd_ctx_params) { sd_ctx_params = &unbatched; } #endif - sd_ctx_t* context = new_sd_ctx(sd_ctx_params); + sd_ctx_t* context = intrinsic_tiny ? sd_dart_new_sd_ctx_intrinsic_tiny_impl(sd_ctx_params) + : new_sd_ctx(sd_ctx_params); insert(context, free_context, cancel_context, SD_DART_EXIT_STAGE_CONTEXT); return context; } +sd_ctx_t* sd_dart_new_sd_ctx(const sd_ctx_params_t* params) { + return new_context(params, false); +} + +sd_ctx_t* sd_dart_new_sd_ctx_intrinsic_tiny_v1(const sd_ctx_params_t* params) { + return new_context(params, true); +} + namespace { // Moves the images out of upstream's result into an array that diff --git a/src/sd_dart_internal.h b/src/sd_dart_internal.h index b880a61..5fdcd84 100644 --- a/src/sd_dart_internal.h +++ b/src/sd_dart_internal.h @@ -2,6 +2,12 @@ #define SD_DART_INTERNAL_H #include +#include "stable-diffusion.h" + + +bool sd_dart_intrinsic_tiny_required(const void* engine); +void sd_dart_set_intrinsic_tiny_required(const void* engine, bool required); +sd_ctx_t* sd_dart_new_sd_ctx_intrinsic_tiny_impl(const sd_ctx_params_t* params); // What the wrapper's sources share with each other. Not shipped and not // exported. diff --git a/src/sd_dart_wrapper.h b/src/sd_dart_wrapper.h index 0d85aec..c442805 100644 --- a/src/sd_dart_wrapper.h +++ b/src/sd_dart_wrapper.h @@ -226,6 +226,17 @@ SD_API void sd_dart_exit_teardown(void); // not modified. SD_API sd_ctx_t* sd_dart_new_sd_ctx(const sd_ctx_params_t* sd_ctx_params); +// Version 1 of the persistent intrinsic tiny-decoder policy. Like +// sd_dart_new_sd_ctx(), but requires an intrinsic SDXS (512-DS or 09) decoder +// and no external VAE/TAE. Rejected before load-time decoder probes. Every +// automatic refresh validates the candidate under the context execution lock +// before rebuilding runners, and image/video generation validates it again +// after refresh. A later source replacement cannot select a full VAE. +// Returns null on policy refusal; use sd_dart_last_error() for the reason. +// Free a returned context with sd_dart_exit_free(). Old runtimes without this +// export cannot enforce the policy. +SD_API sd_ctx_t* sd_dart_new_sd_ctx_intrinsic_tiny_v1(const sd_ctx_params_t* sd_ctx_params); + // generate_image() as a call in flight. Enables native log recording and // returns false if Metal reports a failed command buffer, even if upstream // returns success. Returns the images as an array of `*num_images_out` that diff --git a/tests/native/decoder_policy_runtime_test.cpp b/tests/native/decoder_policy_runtime_test.cpp new file mode 100644 index 0000000..0009780 --- /dev/null +++ b/tests/native/decoder_policy_runtime_test.cpp @@ -0,0 +1,94 @@ +#include "sd_dart_wrapper.h" + +#include +#include +#include + +#define CHECK(condition) do { if (!(condition)) { \ + std::fprintf(stderr, "CHECK(%s) failed at %d\n", #condition, __LINE__); return 1; } } while (0) + +std::string logs() { + std::string result; + char text[8192]; + int32_t level; + size_t length; + for (uint64_t next = 0; (next = sd_dart_log_read(next, text, sizeof(text), &level, &length)) != 0;) { + result.append(text, length); + result += '\n'; + } + return result; +} + +int main(int argc, char** argv) { + CHECK(argc == 5); + const std::string mode = argv[1]; + const bool guarded = mode != "unrestricted-reload"; + const bool refuse_load = mode == "refuse-full-load" || mode == "refuse-external-load"; + sd_dart_log_enable(); + sd_dart_log_set_level(SD_LOG_INFO); + sd_ctx_params_t params; + sd_ctx_params_init(¶ms); + params.model_path = argv[3]; + params.backend = argv[2]; + params.n_threads = 2; + params.flash_attn_mode = "off"; + params.conv_direct_mode = "off"; + if (mode == "refuse-external-load") params.taesd_path = argv[4]; + sd_ctx_t* context = guarded ? sd_dart_new_sd_ctx_intrinsic_tiny_v1(¶ms) : sd_dart_new_sd_ctx(¶ms); + if (refuse_load) { + CHECK(context == nullptr); + CHECK(sd_dart_exit_tracked_count() == 0); + const auto recorded = logs(); + CHECK(recorded.find("intrinsic tiny decoder policy v1 rejects") != std::string::npos); + CHECK(recorded.find("using VAE for encoding / decoding") == std::string::npos); + std::puts("POLICY_LOAD_REFUSED_BEFORE_RUNNERS"); + return 0; + } + if (context == nullptr) std::fprintf(stderr, "%s", logs().c_str()); + CHECK(context != nullptr); + CHECK(sd_dart_exit_tracked_count() == 1); + CHECK(std::string(sd_get_model_version_name(context)).find("SDXS") == 0); + if (mode != "unchanged") CHECK(std::rename(argv[4], argv[3]) == 0); + sd_img_gen_params_t request; + sd_img_gen_params_init(&request); + request.prompt = "a red fox"; + request.width = 256; + request.height = 256; + request.sample_params.sample_steps = 1; + request.sample_params.guidance.txt_cfg = 1; + request.seed = 42; + sd_image_t* images = nullptr; + int count = 0; + const bool ok = sd_dart_generate_image(context, &request, &images, &count); + if (mode == "refuse-reload") { + CHECK(!ok && images == nullptr && count == 0); + CHECK(std::string(sd_get_model_version_name(context)).find("SDXS") == 0); + sd_vid_gen_params_t video; + sd_vid_gen_params_init(&video); + sd_video_generation_result_t* result = reinterpret_cast(1); + CHECK(!generate_video(context, &video, &result) && result == nullptr); + std::puts("POLICY_REFRESH_REFUSED_IMAGE_AND_VIDEO"); + } else { + CHECK(ok && images != nullptr && count == 1); + CHECK(images[0].width == 256 && images[0].height == 256); + free_sd_images(images, count); + std::printf("GENERATED %s %s\n", mode.c_str(), sd_get_model_version_name(context)); + } + const auto recorded = logs(); + if (guarded) { + CHECK(recorded.find("using VAE for encoding / decoding") == std::string::npos); + CHECK(recorded.find("using TAE for encoding / decoding") != std::string::npos); + if (mode == "refuse-reload") CHECK(recorded.find("intrinsic tiny decoder policy v1 rejects") != std::string::npos); + if (mode == "refresh-tiny") { + const auto first = recorded.find("using TAE for encoding / decoding"); + CHECK(recorded.find("using TAE for encoding / decoding", first + 1) != std::string::npos); + } + } else { + CHECK(recorded.find("using VAE for encoding / decoding") != std::string::npos); + CHECK(std::string(sd_get_model_version_name(context)) == "SD 2.x"); + } + sd_dart_exit_free(context); + CHECK(sd_dart_exit_tracked_count() == 0); + std::puts("CONTEXT_RELEASED"); + return 0; +} diff --git a/tests/native/exit_teardown_test.cpp b/tests/native/exit_teardown_test.cpp index a7fdd0c..e84ebd0 100644 --- a/tests/native/exit_teardown_test.cpp +++ b/tests/native/exit_teardown_test.cpp @@ -1844,6 +1844,10 @@ sd_ctx_t* new_sd_ctx(const sd_ctx_params_t* sd_ctx_params) { return new sd_ctx_t(); } +sd_ctx_t* sd_dart_new_sd_ctx_intrinsic_tiny_impl(const sd_ctx_params_t* params) { + return new_sd_ctx(params); +} + void free_sd_ctx(sd_ctx_t* sd_ctx) { CHECK(!sd_ctx->generating.load()); calls_after_teardown.fetch_add(teardown_returned.load() ? 1 : 0); diff --git a/tests/test_decoder_policy_runtime.py b/tests/test_decoder_policy_runtime.py new file mode 100644 index 0000000..762b275 --- /dev/null +++ b/tests/test_decoder_policy_runtime.py @@ -0,0 +1,69 @@ +"""Opt-in real SDXS/source-refresh regression; uses existing local model files. + +No download or physical-device claim. CPU always runs; set +SD_DECODER_POLICY_BACKENDS=cpu,MTL0 for a local Mac with Metal. +""" +import os +import platform +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "tools")) +import build # noqa: E402 + + +class DecoderPolicyRuntimeTest(unittest.TestCase): + def test_intrinsic_policy_survives_real_source_refresh(self): + tiny = Path(os.environ.get("SD_INTRINSIC_TINY_MODEL", "")) + full = Path(os.environ.get("SD_FULL_VAE_MODEL", "")) + if not tiny.is_file() or not full.is_file(): + self.skipTest("set SD_INTRINSIC_TINY_MODEL and SD_FULL_VAE_MODEL to existing SDXS and SD-Turbo GGUFs") + target_name = os.environ.get("SD_DECODER_POLICY_TARGET") + if not target_name and sys.platform == "darwin": + target_name = "macos-arm64" if platform.machine() == "arm64" else "macos-x64" + if not target_name: + self.fail("set SD_DECODER_POLICY_TARGET to a built target") + target = build.TARGETS[target_name] + library = build.BIN_ROOT / target.name / "lib" / target.library + self.assertTrue(library.is_file(), f"no built library at {library}") + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + binary = directory / "decoder_policy" + subprocess.run([ + os.environ.get("CXX") or shutil.which("c++"), "-std=c++17", "-O1", + f"-I{ROOT / 'src'}", f"-I{build.HEADER.parent}", + str(ROOT / "tests/native/decoder_policy_runtime_test.cpp"), + f"-L{library.parent}", "-lstable-diffusion", f"-Wl,-rpath,{library.parent}", + "-o", str(binary), + ], check=True) + for backend in os.environ.get("SD_DECODER_POLICY_BACKENDS", "cpu").split(","): + for mode in ("unchanged", "refresh-tiny", "refuse-reload", "unrestricted-reload", + "refuse-full-load", "refuse-external-load"): + with self.subTest(backend=backend, mode=mode): + live = directory / "live.gguf" + replacement = directory / "replacement.gguf" + for dest in (live, replacement): + dest.unlink(missing_ok=True) + def copy(source, destination): + if sys.platform == "darwin": + subprocess.run(["cp", "-c", str(source), str(destination)], check=True) + else: + shutil.copyfile(source, destination) + copy(full if mode == "refuse-full-load" else tiny, live) + copy(tiny if mode in ("refresh-tiny", "unchanged") else full, replacement) + if mode == "refresh-tiny": + stat = live.stat() + os.utime(replacement, ns=(stat.st_atime_ns, stat.st_mtime_ns + 2_000_000_000)) + result = subprocess.run([str(binary), mode, backend, str(live), str(replacement)], + capture_output=True, text=True, timeout=180) + print(f"{backend} {mode}: {result.returncode} {result.stdout.strip()}", flush=True) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tools.py b/tests/test_tools.py index d945f66..55a85ea 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -79,6 +79,7 @@ def test_wrapper_header_adds_only_the_documented_exports(self): "sd_dart_log_read", "sd_dart_log_set_level", "sd_dart_new_sd_ctx", + "sd_dart_new_sd_ctx_intrinsic_tiny_v1", "sd_dart_progress_enable", "sd_dart_progress_read", ], From 4a5fec23c99c24089e3b5f88d05f45d65354dc04 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 2 Nov 2026 15:30:09 -0500 Subject: [PATCH 2/2] Document owner-only carried context contracts --- patches/README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/patches/README.md b/patches/README.md index 57cfd9a..c3736cf 100644 --- a/patches/README.md +++ b/patches/README.md @@ -1,14 +1,20 @@ # Carried upstream patches -Fixes the pinned stable-diffusion.cpp lacks, carried until a pin includes them. +Owner-carried changes the pinned stable-diffusion.cpp public C API cannot provide. `CMakeLists.txt` applies each patch to build-tree copies of the upstream sources it names and compiles the copies, so the submodule stays pristine. Configuring fails when a patch no longer applies or changes nothing; then drop or rebase it. Build info and the release manifest list the patches next to the upstream commit. -A patch here needs a regression test that fails against the unpatched pin and -an upstream issue or pull request to retire it. +Every patch needs a regression test that fails against the unpatched pin. +An upstream bug fix must link an upstream issue or pull request that defines +its retirement. A versioned owner-only context contract may instead link the +canonical consumer requirement when upstream's public C API cannot enforce +it. Keep those patches to the minimum insertion points, exercise causal +controls against real refresh/execution paths, and retire them when upstream +exposes an equivalent contract. Consumer requirements are not upstream bug +reports. | Patch | Sources | Fixes | Test | | --- | --- | --- | --- | @@ -16,7 +22,7 @@ an upstream issue or pull request to retire it. | `fp8_metal_f16_load.patch` | `src/model_manager.cpp`, `src/core/backend_fit.cpp` | An FP8 (E4M3 or E5M2) checkpoint aborts the process on Metal: ggml-metal claims FP8 `MUL_MAT` but has no FP8 kernel. Weights a Metal backend computes with now load as F16, which holds every FP8 value, so they take twice their file size; other backends keep FP8 ([#29](https://github.com/leehack/stable-diffusion-native/issues/29)). Auto-fit plans them at that size, and FP8 LoRA tensors, which crashed ggml-cpu, load as F16 on every backend ([#31](https://github.com/leehack/stable-diffusion-native/issues/31)). | `tests/test_fp8_runtime.py` | | `lora_immediate_fallback.patch` | `src/pipeline/diffusion_engine.cpp` | The `immediately` LoRA apply mode, which `auto` selects for unquantized weights, crashes the CPU merging into FP8 weights (ggml-cpu can neither multiply nor write FP8) and aborts Metal merging into F16 weights (ggml-metal adds only equal types), which every model has: convolution weights load as F16. FP8 weights, and every model on a backend that cannot run that add outside host memory, now take the LoRA `at_runtime`, with a warning when `immediately` was requested ([#31](https://github.com/leehack/stable-diffusion-native/issues/31)). | `tests/test_fp8_runtime.py` | -| `intrinsic_tiny_decoder_policy.patch` | `src/stable-diffusion.cpp`, `src/pipeline/diffusion_engine.cpp` | Preserves an opt-in intrinsic tiny-decoder context policy across automatic model source refresh, rejecting a full-VAE replacement before runner probes or decode ([llamadart #1047](https://github.com/leehack/llamadart/issues/1047)). Retire when upstream provides a context-level decoder invariant. | `tests/test_decoder_policy_runtime.py` | +| `intrinsic_tiny_decoder_policy.patch` | `src/stable-diffusion.cpp`, `src/pipeline/diffusion_engine.cpp` | Owner-only `sd_dart_new_sd_ctx_intrinsic_tiny_v1` context contract for [llamadart #1047](https://github.com/leehack/llamadart/issues/1047), not an upstream defect: automatic model refresh is valid upstream behavior. The public C API cannot preserve a decoder invariant, so minimal context creation/free and candidate/execution insertion points reject a full-VAE replacement before runner probes or decode. Real unchanged/refreshed SDXS and unrestricted reload controls exercise the boundary. Retire when upstream exposes an equivalent persistent decoder policy. | `tests/test_decoder_policy_runtime.py` | Patches touching the same source compose in declaration order. Each next patch applies to the preceding build-tree copy; the submodule stays pristine.