Ctrl K
ComunidadGuías…

internal/autogen/hash.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package autogen

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"
)

// hashCacheSuffix is appended to the output config path to store the digest of
// the inputs that produced it.
const hashCacheSuffix = ".modelhash"

// genVersion is folded into the inputs hash so a change to the config-emit logic
// (buildCmdLines/emitProfile output) forces a one-time regen even when the models,
// generate file, and sidecar are byte-identical. The hash otherwise only tracks
// inputs, not the generator, so an emit change would silently ship a stale config.
// Bump this whenever the emitted YAML for unchanged inputs changes.
//
//	v2: -b decoupled from -ub (logical batch fixed at 2048, clamped >=ub, <=ctx).
//	v3: draft-dflash default --spec-draft-n-max 6 -> 5 (own sweep on Qwen3.6-35B-A3B).
//	v4: draft-dflash no longer auto-defaults (real long-session use craters vs mtp
//	    on VRAM pressure); only an explicit spec: draft-dflash override selects it.
//	v5: flux.2 klein name-detected (arch is "flux", same as flux.1) to wire
//	    flux2Vae + qwenLlm instead of fluxVae/clip_l/t5.
//	v7: tts-server checkEndpoint none -> /health (gate readiness, kill 502-on-cold).
//	v14: settings.extraImageModels emit (hand-declared safetensors sd-server blocks).
//	v17: extraImageModels are override-aware (sidecar/file override overlaid) + emit
//	     --clip_g / --sampling-method so the config editor can tune them.
//	v19: MTP models default spec draft-mtp+ngram-mod (chain beats mtp alone); mmap
//	     defaults --no-mmap unless CPU offload (n-cpu-moe or partial layer offload).
//	v21: SAM emits capabilities.segmentation + auto --no-gpu when the card can't
//	     spare headroom beyond the primary VRAM budget (coexist placement).
//	v22: SAM exe fallback derives as sibling of ServerExe (backends dir) instead
//	     of bare "sam3_server" on PATH.
//	v23: SAM no longer bakes --no-gpu at generate (static-budget heuristic always
//	     tripped CPU on a full-budget card even when idle); CPU vs GPU is now a
//	     live spawn-time decision (LiveOffloadArgs .ggml branch, samGpuMinFreeGB).
//	v24: --chat-template-file emits via cmdPath (forward slashes, unescaped) —
//	     %q doubled every backslash and llama-server couldn't open the template.
//	v25: Parakeet ASR models emit a parakeet-server block (capabilities in:[audio]
//	     out:[text]) instead of routing to llama-server as a chat model.
//	v27: image models emit --lora-model-dir (per-model override, else
//	     settings.loraDir, else the model gguf's own directory) so LoRAs are
//	     listable via /sdapi/v1/loras and usable per-request.
//	v28: KV quant validated against llama's full kv_cache_types set (q5_0/q4_1/f32
//	     now accepted instead of silently emitted-but-unmodelled); bf16 KV costs
//	     2.0 B/elem instead of falling back to q8_0's 1.0625 (undersized ctx).
//	v29: KV default is f16 for EVERY arch (was q8_0 dense / f16 MoE), stepping down
//	     to q8_0 only when f16 can't reach denseMinCtx; settings.kvQuant pins it.
//	v30: vllm emit sizes --max-model-len and --gpu-memory-utilization from the VRAM
//	     budget (were the model's trained ctx and a flat 0.90), emits --tokenizer
//	     when set, and skips split-shard ggufs vllm cannot load.
//	v31: TTS is multi-backend like the LLM class — Kokoro/Parler/Orpheus GGUFs are
//	     detected and emitted as TTS.cpp (--model-path, no codec/voices dir), and the
//	     engine is resolved from the registry per model instead of always qwentts.
//	v32: TTS.cpp models emit useModelName (the gguf stem) — its server validates the
//	     request's "model" against its own file-stem map and 400s "Invalid Model".
//	v33: qwentts TTS models emit capabilities.voiceClone (TTS.cpp cannot clone), so
//	     the playground stops offering a clone button for a fixed voice pack.
//	v34: per-model estVramGB + top-level vramBudgetGB (VRAM-budget-aware
//	     multi-load): the router admits a model alongside the resident set while
//	     the estimates fit the budget instead of evicting on group membership.
//	v35: rope scaling extends the ctx ceiling past the model's trained length and
//	     derives --rope-scale from the chosen ctx (was: ctx hard-clamped to
//	     context_length, and a bare --rope-scaling kept llama.cpp's factor of 1).
//	v36: per-model estRamGB beside estVramGB, so the Models table can show what a
//	     partial offload costs in system memory before the model is loaded.
//	v38: the checkpoint reserve is carried into every re-priced placement (a forced
//	     or low-active-MoE offload used to drop it from both estVramGB and estRamGB,
//	     reading ~0.3 GB low), and the long-context budget headroom moved from the
//	     ctx-tier loop into sizeProfile so every long profile — tier, variant, a
//	     pinned long ctx, and the editor preview — sizes against the same budget.
//	v39: longCtxHeadroomGB is a floor on the total safety slack instead of an
//	     addition — it now tops vramOverheadGB up rather than stacking on it, so a
//	     long profile stops paying two independent 0.5 GB pads and recovers the
//	     layer it was offloading for nothing.
//	v40: Qwen 3.8 keeps its own chat template (the built-in fix is now gated on
//	     what the baked template does, not on arch alone) and models whose
//	     template validates a reasoning_effort ladder emit
//	     capabilities.reasoningEffort, which the proxy uses to translate a
//	     client's OpenAI reasoning_effort field into a chat_template_kwarg.
//	v41: llama-server flag migration — --cors-origins localhost locks the spawned
//	     upstream down (it echoed any Origin back with credentials, exposing
//	     /props and /slots to any page the user visits), preserve-thinking moves
//	     from --chat-template-kwargs to --reasoning-preserve, and the deprecated
//	     --no-webui / --mmap / --no-mmap / --mlock / -dio flags collapse into
//	     --no-ui and the --load-mode enum.
//	v43: server-side sampler defaults (--temp/--top-k/--top-p/--min-p/
//	     --presence-penalty). Qwen3-family models now emit --top-k 20 --min-p 0
//	     from the arch baseline: neither knob has an OpenAI-API field, so no
//	     client could reach them and every request silently ran on llama's
//	     top-k 40 / min-p 0.05 instead of the values the model cards specify.
//	v44: sampler baseline gains muse-glimmer (--top-k 64, no min-p — the card
//	     pins one and not the other). Only matters for a config generated under
//	     v43 for a muse-glimmer gguf already on disk; a newly downloaded model
//	     regenerates through the inputs hash anyway.
//	v46: -cms is now emitted ALWAYS, not just when it differs from our own
//	     default. llama-server's --checkpoint-min-step default is 8192, not the
//	     256 the reserve assumed, so every recurrent and plain-attention model
//	     launched 32x wider than checkpointReserveGB charged (~0.5 GB
//	     under-reserved on qwen3.6-27b) and — with upstream #24176/#25472 —
//	     evicted the previous turn's checkpoint on every turn.
//	v47: CPU-only TTS.cpp voices (Kokoro & friends) and CPU-only Parakeet ASR
//	     models move out of the exclusive swap group into persistent,
//	     never-evicting "tts" / "asr" coexistence groups beside the SAM one.
//	     They run on the CPU and are charged no VRAM, yet group membership
//	     alone made a playground read-aloud click (or a dictation) evict the
//	     chat model that produced the reply — and cold-reload it on the next
//	     turn. Coexistence groups are also appended to every listener, since
//	     they bind no port of their own.
//	v49: capabilities.reasoningEffort survives a --chat-template-file override.
//	     The ladder is now read from the drop-in template itself instead of being
//	     dropped wholesale, and templates that fold effort onto their own rungs
//	     without validating it (no raise) have their rungs read from the
//	     assignments. Every Qwen 3.8 running a drop-in template advertised no
//	     ladder at all, so the playground offered a bare thinking on/off.
//	v50: NO chat template is substituted automatically any more, and no model
//	     family gets special treatment. The bundled Qwen 3.5/3.6 drop-in is gone
//	     from the emit path: chat templates are the user's to manage, and the
//	     substitute silently dropped whatever the baked template supported that
//	     it did not. --chat-template-file is emitted only when the user sets a
//	     chatTemplateFile override. Affected models now advertise their own
//	     baked effort ladder rather than none.
//	v51: draft ggufs and mmproj projectors are inherited across a model's family
//	     (another quant of the same model, or a finetune at the same parameter
//	     count) when a model's own dir ships none, gated on the donor and the
//	     recipient agreeing on arch/n_embd/n_layer/n_vocab. Models that had no
//	     sidecar beside them can now emit -md / a -vision twin.
//	v52: MXFP4 is a recognised quant token, and an untabulated ggml tensor type
//	     no longer aborts the tensor walk. Both together: an MXFP4 gguf used to
//	     come back with VocabSize 0, which mis-sized its logits buffer and made
//	     the v51 header gate refuse it every family sidecar.
//	v53: quant tokens are recognised by FAMILY (Q/IQ, TQ, FP with its vendor
//	     prefix, BF/F) from one shared pattern in internal/quant, instead of
//	     from four hand-kept enumerations that had never heard of NVFP4. An
//	     unrecognised token is not cosmetic: the id never gets cut at it, so the
//	     model shares no base key with its own other quants (no sidecar
//	     inheritance) and every ctx tier and vision twin lists as a model of its
//	     own. ggml types 40 (NVFP4) and 41 (Q1_0) are tabulated too, and ids no
//	     longer re-append a quant the file name already spells mid-name, so
//	     "…-NVFP4-MTP-MID-HIGH" keeps the id it had.
//	v54: a "-vision" twin is sized twice — projector in VRAM and projector on
//	     the CPU — and emits --no-mmproj-offload whenever holding the CLIP
//	     tower on the GPU displaced text layers or cost more than a quarter of
//	     the context window. Changes both the emitted argv and the twin's
//	     estVram/ctx, so every existing config has to regenerate.
//	v55: the reserved "vision" variant carries its own mmproj placement, so a
//	     pin set there (gpu/ram/none) now decides the twin's argv - and its
//	     VRAM - instead of the model-wide value alone.
//	v56: --reasoning-preserve is emitted by DEFAULT wherever reasoning is on
//	     (Override.PreserveThinking nil => on); only an explicit false strips.
//	v57: the dense context ladder no longer tops out at 128k, and an install
//	     carrying the old shipped ladder (in the generate file or the settings
//	     sidecar) is migrated onto the new one. Every dense model trained past
//	     128k gets a larger -c and a larger KV reserve, so the emitted argv and
//	     the estimates change for inputs that did not.
//	v60: a model can name its vision projector explicitly (mmprojFile), which
//	     overrides discovery AND creates a "-vision" twin for a model whose
//	     own folder (and whose family) ships no mmproj at all. Auto-pairing is
//	     deliberately not widened (a projector belongs to one vision tower), but
//	     a shared projector kept in a folder of its own is now reachable, so a
//	     config can gain a twin and an --mmproj it did not have.
//	v61: -cms is hoisted back out of extraArgs. The launch-box editor did not
//	     parse the flag, so an edit round trip pushed it into extraArgs, where it
//	     was appended AFTER the computed copy (and grew by one per trip). Any
//	     config carrying one now emits a single -cms, and the hoisted value acts
//	     as the pin it was meant to be.
//	v62: a split gguf is charged the whole set's bytes, not shard 1's. Discovery
//	     represents a set by its first shard (correct: llama-server opens the
//	     siblings itself), but stat'ing that one file sized an 80B MoE at a
//	     quarter of itself, so the plan offloaded it whole and spent the phantom
//	     slack on context. Every split model's -ngl/--n-cpu-moe/-c changes for
//	     inputs that did not.
//	v63: a model larger than one card is split across every eligible GPU. The
//	     VRAM budget is pooled across the set instead of taken from the largest
//	     adapter, and a plan that needs more than one device emits -sm layer
//	     --tensor-split (plus CUDA_DEVICE_ORDER=PCI_BUS_ID, so llama.cpp's
//	     device order matches the telemetry order the split positions mean).
//	     Every model on a multi-GPU box changes budget and argv for inputs that
//	     did not. The split is planned against each card's stable capacity, not
//	     the free reading of the moment the config was generated: a card busy
//	     for that one sample used to bake a single-device plan that spawn time
//	     could not repair, since the live retune rewrites an existing
//	     --tensor-split and cannot add one.

//	v67: custom launch arguments size the plan, not just the argv (autogen/pins.go).
//	     A pinned -c/-ctk/-ub/-ngl/--n-cpu-moe/--parallel/... is folded into the
//	     sizer, so the emitted flags and the baked estVramGB/estRamGB describe the
//	     launch that actually runs instead of the one the sizer would have picked
//	     alone. Every model whose sidecar carries customArgs changes -c/-ngl/
//	     --n-cpu-moe, and a pinned window is no longer rounded to a 4096 multiple,
//	     for inputs that did not.

//	v68: an integrated GPU's shared system-memory pool counts toward its budget.
//	     A 780M with a 2GB BIOS carve-out used to sit under the 3GB inference
//	     floor and be dropped entirely, so an APU-only box sized against no
//	     GPU at all and put every layer on the CPU (issue #37). The reading is
//	     now carve-out + GTT, and the device is one the sizer can plan on, so
//	     -ngl/--tensor-split/-c all change for inputs that did not.

// v69: TRELLIS.2 package directories are discovered and emitted as 3D models.
//
//	A directory holding a pipeline.json plus ckpts/ is now a model of its
//	own (a 3D entry with an image-in / 3d-out capability line) instead of a
//	pile of files nothing serves, and a BiRefNet gguf stops being listed as
//	a language model. Inputs that did not change gain a model.

// v70: TRELLIS.2 models launch with --mesh-postprocess-simplify. The upstream
//
//	default keeps the full mesh (~5.5M triangles and ~180 MB at the 512
//	profile); a served model is more useful at ~1M triangles and ~40 MB.

// v71: a declared settings.encoders.qwenLlm of the matching caption width wins
//
//	over the encoder scan's pick. The scan matches text encoders by hidden width
//	and breaks ties on rank, then rounded size, then path, so a Qwen3-4B and a
//	Qwen3-VL-4B file (both 2560 wide, both 3.99 GiB) resolved to whichever path
//	sorted first: Z-Image was fed the VL file Krea-2 wants while the user's own
//	qwenLlm pin named Qwen3-4B. Configs that declare qwenLlm change --llm for
//	inputs that did not.
//
// v72: a per-model backend pin now reaches every emitted class, not just chat,
//
//	image, tts and 3d: embedding and ASR launch the pinned entry, a
//	hand-declared extra image model carries its pin too, and the single-device
//	env pin is derived from the resolved binary. Switching the ★ default no
//	longer moves a model that named a backend.
//
// v74: MiniMax-H3 generation defaults corrected. --steps is no longer pinned at
//
//	4 (the base model is not distilled; 4 steps belongs to the turbo LoRAs,
//	which arrive per request), and --video-frames moves 25 -> 56 because H3
//	aligns UP to the 17k+5 grid and 25 was silently becoming 39.
//
// v75: text encoders match on captionFactor (exact width, or a whole fraction of
//
//	it for a DiT that concatenates encoder layers) instead of strict equality,
//	and settings.encoders.qwenLlm is no longer substituted when the scan
//	classified it and Llm rejected it. Flux.2 klein 9B now wires Qwen3-8B
//	instead of silently taking the global Qwen3-4B pin.
//
// v76: LTX-2.x is a video family of its own. A joint audio+video DiT wires both
//
//	VAEs and its own with-proj text encoder, generation defaults land on the
//	8k+1 frame grid, and a distilled checkpoint is launched at its own step and
//	cfg numbers instead of the generic video ones.
//
// v77: --max-vram is priced as the graph-cut headroom left after the resident
//
//	weights and GPU-side VAEs, not as the whole card, and --stream-layers is
//	emitted only where the diffusion params are in RAM for it to stream from.
//	Every sd-server line changes; a v76 config was generated before either.
//
// v78: audio.cpp is a backend. A gguf whose header names it
// (general.architecture=audiocpp) now emits an audiocpp_server entry instead of
// falling through to the chat path, with a new audiocpp: block carrying the
// models[] entry its --config needs. A family we do not serve yet (music,
// separation, voice conversion) emits no entry at all, only a comment.
// v79: audio.cpp entries carry --device. Left alone the server takes device 0 of
// its backend, which is the integrated GPU on any box that enumerates one first
// (an APU desktop, a laptop), so the model lands in shared system memory with
// nothing in the log saying so. The index comes from the binary's own
// --list-devices, whose [GPU]/[IGPU] tag says which is discrete.
// v80: the per-process GPU-runtime constant is split by BACKEND, not just by GPU
// vendor - a ROCm/HIP llama-server reserves 0.8GB where the Vulkan one on the
// same card reserves 0.4GB (computeRocmCtxGB). Every estVramGB on a ROCm box
// moves by 0.4GB for unchanged inputs, so the hash has to force the regen.
// v81: Qwen-Image 2.1. Its caption projection is an MLP (txt_in.in_layer), so
// condHiddenFrom can now measure it and the model wires a 4096-wide Qwen3-VL-8B
// instead of emitting a permanent "needs encoder [llm]" warning; and the Wan-3D
// VAE family records its latent channel count, so 2.1's 64-channel RGBA VAE and
// the 16-channel Wan/Qwen-Image one stop being separable only by filename sort.
// Both change the emitted --llm/--vae for qwen_image models.
// v82: Qwen-Image 2.1 is a UNIFIED checkpoint (one file for generation and
// reference editing), so the edit-name heuristics missed it. It now matches
// unifiedEditRe, which both pairs its vision projector (--llm_vision) and flips
// the emitted capability to in: [text, image].
// v83: refEdit "on" now implies --llm_vision. Pinning reference editing without
// also pinning the projector produced a model that could not generate at all on
// Qwen-Image 2.1, which errors rather than silently ignoring the reference.
// v84: prompt enhancers are auto-paired by name (detectEnhancers) and carry a
// per-image-model system prompt, so an image model can gain promptEnhancer,
// promptEnhancerEdit and either prompt key with nothing on disk having changed;
// PE ggufs also leave the text-encoder pool, which moves --llm on Qwen-Image.
// v85: an i2i prompt enhancer auto-pairs to the model's "-vision" twin instead
// of the base profile, so promptEnhancerEdit gains a "-vision" suffix on models
// where nothing on disk changed. The base profile keeps its projector in RAM,
// and an i2i rewriter is handed an image on every call.
// v86: video models now emit the prompt-enhancer block the image path already
// did, so a video model whose override already named an enhancer (saved from the
// model modal, silently dropped at emit) gains promptEnhancer /
// promptEnhancerEdit and either prompt key with nothing on disk having changed.
// v88: recurrent LLMs (GatedDeltaNet hybrids, SSMs) leave the text-encoder
// pool: sd.cpp cannot load one as a conditioner, yet a same-width hybrid could
// outrank the real encoder on file size and move --llm with nothing on disk
// having changed for the image model.
// v89: a third same-id row falls back to -<publisher>-<repo> then -N instead of
// reusing the second's key, and a clashing or incomplete extraImageModels entry
// leaves a "# SKIPPED" comment instead of vanishing.
// v90: Hugging Face model folders (config.json + safetensors) are discovered
// and served through vllm, or leave a "# SKIPPED" comment with no vllm backend.
// v91: vllm's --gpu-memory-utilization and estVramGB are sized to weights + KV
// + overhead when that is under the budget, not to the whole budget.
// v92: vllm models get --enable-auto-tool-choice and a --tool-call-parser.
// v93: vllm models pin their KV pool with --kv-cache-memory-bytes (issue #93).
// v94: a prompt enhancer is emitted as ONE profile (no fleet variants, no
// -vision twin, 32k window, projector in VRAM for i2i and absent for t2i), and
// promptEnhancerEdit pairs to that base id instead of the retired twin.
// v96: qwen_image models get built-in generation defaults (imageDefaultsFor):
// a turbo checkpoint launches at --steps 8 --cfg-scale 1, Qwen-Image 2.1 base
// at --steps 40 --cfg-scale 1, instead of sd-server's 20 steps at cfg 7.0.
// v97: diffusion tensor markers match the bare name (ComfyUI's
// model.diffusion_model. prefix stripped), and a zero-KV Qwen-Image gguf is
// sniffed as qwen_image instead of falling through to llama-server.
// v98: image/video models with unresolved components carry
// missingComponents: [...] so the Load path can log what is missing.
// v99: llama.cpp decision models (<arch>.decision.type) emit their own
// /v1/systemone block instead of an LLM or embedder one.
// v100: decision models charge computeBufferGB at their real -ub (plus the
// n_head x ub^2 score matrix for whole-batch types) instead of a flat pad.
// v102: a non-causal classifier with no decision.type or pooling_type is
// skipped instead of emitted as a chat LLM.
// v104: ace_step models carry sessionOptions ace_step.mem_saver, which makes a
// seed reproduce byte for byte regardless of what the process rendered before.
// v105: MiniMax Music 3 session options carry the family prefix audio.cpp
// looks them up by; bare keys were ignored and the server wanted q4_0 files.
// v106: the 24 audio.cpp families upstream added since the table was written
// are classified (TTS/ASR emitted, the rest skipped by name), and YuE2 is
// emitted as a folder model with its VAE companion.
// v107: on a Vulkan audio.cpp build MiniMax Music 3's q8_0/q4_0 quants load
// with weight_type f16 (native rendered a hiss) and are priced at that size.
// v109: (reverted) a full-precision YuE2 took the f32 VAE; it cannot decode on
// Vulkan (one 3.6 GB allocation over the device limit), so f16 stays first.
// v110: SheetSage2 is emitted as task "midi" with musicTranscribe: abc.
// v112: a full-precision YuE2 takes the f32 VAE again, but only when its
// audio.cpp build is not Vulkan (HIP/CUDA decode it; Vulkan cannot).
// v113: qwen3_asr takes the Qwen3 ForcedAligner (and an absolute VAD path)
// as session options when one is on disk, for word timestamps.
// v114: parakeet-server is health-checked on /health instead of "none".
// v115: MOSS-Image (recurrent Qwen3.5 encoder + --tokenizer) and FLUX.3 klein
// (8-layer caption concat) are wired; the FLUX.2 small decoder leaves the pool.
// v116: FLUX.2 / FLUX.3 (dev and klein) declare in: [text, image]. One
// checkpoint generates and edits, so no edit token in the name caught it, and
// the playground fell through to img2img. Capability only: no --llm_vision.
// v117: FLUX.3, FLUX.2 klein and Z-Image restrict their text encoder by gguf
// ARCHITECTURE (imageEncoderArch), so a same-width Qwen3 / Qwen3-VL no longer
// wins on a global pin or file size; FLUX.3 moves from Qwen3-4B to Qwen3-VL-4B.
// v118: a declared settings.encoders path whose file is gone is cleared before the
// pool fill (declaredGone), so the scan supplies the part or it is reported missing
// instead of emitting a flag at a path sd-server cannot open.
const genVersion = "v118"

// hashedInput reports whether a file under a models root can change what
// discovery sees: a gguf, a safetensors (encoder pool components and HF weight
// files), or an HF folder's config.json.
func hashedInput(name string) bool {
	switch strings.ToLower(filepath.Ext(name)) {
	case ".gguf", ".safetensors":
		return true
	}
	return strings.EqualFold(name, hfConfigFile)
}

// InputsHash digests everything that can change the generated config: the set of
// model files under modelsRoot (path + size + mtime, see hashedInput) plus the raw bytes of the
// generate control file. A stable hash means a regen would produce the same
// config, so it can be skipped.
func InputsHash(modelsRoot string, generateFileBytes []byte) (string, error) {
	return InputsHashRoots([]string{modelsRoot}, generateFileBytes)
}

// InputsHashRoots is InputsHash over multiple scan folders (settings.RootList).
// Each gguf's hash key is prefixed by its root index so identically-named files
// in different roots don't collide.
func InputsHashRoots(roots []string, generateFileBytes []byte) (string, error) {
	type entry struct {
		rel   string
		size  int64
		mtime int64
	}
	var entries []entry
	for ri, modelsRoot := range roots {
		if strings.TrimSpace(modelsRoot) == "" {
			continue
		}
		err := filepath.WalkDir(modelsRoot, func(path string, d fs.DirEntry, err error) error {
			if err != nil {
				return nil
			}
			if d.IsDir() || !hashedInput(d.Name()) {
				return nil
			}
			fi, e := d.Info()
			if e != nil {
				return nil
			}
			rel, e := filepath.Rel(modelsRoot, path)
			if e != nil {
				rel = path
			}
			entries = append(entries, entry{fmt.Sprintf("%d\x00%s", ri, filepath.ToSlash(rel)), fi.Size(), fi.ModTime().UnixNano()})
			return nil
		})
		if err != nil {
			return "", err
		}
	}
	sort.Slice(entries, func(i, j int) bool { return entries[i].rel < entries[j].rel })

	h := sha256.New()
	for _, e := range entries {
		fmt.Fprintf(h, "%s\x00%d\x00%d\n", e.rel, e.size, e.mtime)
	}
	h.Write([]byte("\x00generate\x00"))
	h.Write(generateFileBytes)
	return hex.EncodeToString(h.Sum(nil)), nil
}

// readHashCache returns the stored inputs hash, or "" when absent/unreadable.
func readHashCache(path string) string {
	b, err := os.ReadFile(path)
	if err != nil {
		return ""
	}
	return strings.TrimSpace(string(b))
}

// buildHashInput assembles the byte blob whose digest gates regeneration:
// resolved modelsRoot + raw generate file + UI sidecar + the binary's own
// directory. Kept in one place so EnsureConfig and CurrentInputsHash always
// hash identical inputs.
//
// The exe dir is folded in because slotKvPath/DefaultSlotCachePath bake an
// absolute path (next to the binary) into the emitted config at generate
// time; without this, moving or renaming the install dir leaves the stale
// config pointing --slot-save-path/slotCache.path at the old location
// forever, since none of the gguf/generate/sidecar inputs changed.
func buildHashInput(roots []string, rawGenerate, sidecarBytes []byte) []byte {
	out := append([]byte("genver\x00"+genVersion+"\x00"), []byte(strings.Join(roots, "\x00")+"\x00")...)
	out = append(out, rawGenerate...)
	out = append(out, "\x00sidecar\x00"...)
	out = append(out, sidecarBytes...)
	out = append(out, "\x00exedir\x00"...)
	if exe, err := os.Executable(); err == nil {
		out = append(out, []byte(filepath.ToSlash(filepath.Dir(exe)))...)
	}
	return out
}

// regenMu serializes every inputs walk and config write of this process.
// EnsureConfig is reached from several goroutines at once — the hub
// download-completion hook (regenerate + hot-reload over a models tree that
// JUST changed), the watch-models poller, the UI settings save, and the
// backend install goroutine — and each one walks the models tree, prunes the
// sidecar and rewrites the config + hash cache. Two of them at once can interleave
// their writes and tear config.yaml, and a hash walk running over a sidecar
// mid-prune sees bytes that are neither revision. One mutex keeps every walk
// and every write whole; it is held for the duration of the scan, which is
// the slow part, so callers that only need a change check (CurrentInputsHash)
// pay the same price instead of racing the writer.
var regenMu sync.Mutex

// CurrentInputsHash computes the inputs hash for the generate file's present
// state (models folder + generate bytes + sidecar). It is the same value
// EnsureConfig compares against its cache, so callers can cheaply detect whether
// a regen would produce a different config without running one. Serialized
// with EnsureConfig via regenMu: a walk that ran concurrently with a regen
// could hash a sidecar mid-prune and report a change that is already applied.
func CurrentInputsHash(generatePath, modelsDirOverride string) (string, error) {
	regenMu.Lock()
	defer regenMu.Unlock()
	rawGenerate, err := os.ReadFile(generatePath)
	if err != nil {
		return "", fmt.Errorf("reading generate file: %w", err)
	}
	gf, err := LoadGenerateFile(generatePath, modelsDirOverride)
	if err != nil {
		return "", err
	}
	sidecarBytes, _ := os.ReadFile(SidecarPath(generatePath))
	roots := gf.Settings.RootList()
	return InputsHashRoots(roots, buildHashInput(roots, rawGenerate, sidecarBytes))
}

// CachedConfigHash returns the inputs hash recorded alongside the last generated
// config, or "" when no config has been generated yet.
func CachedConfigHash(outConfigPath string) string {
	return readHashCache(outConfigPath + hashCacheSuffix)
}

// EnsureConfig generates outConfigPath from the generate control file when the
// inputs changed (or the config is missing), and skips regeneration otherwise.
// modelsDirOverride (from --models-dir) wins over the file's settings.modelsRoot.
// logf receives one human-readable status line. Returns whether a regen ran.
//
// The whole scan-and-write is serialized under regenMu, so a download that
// lands in the models folder can trigger a completion-hook regen and a
// watch-models poll in the same instant without their writes interleaving.
func EnsureConfig(generatePath, outConfigPath, modelsDirOverride string, logf func(string)) (regenerated bool, err error) {
	regenMu.Lock()
	defer regenMu.Unlock()
	rawGenerate, err := os.ReadFile(generatePath)
	if err != nil {
		return false, fmt.Errorf("reading generate file: %w", err)
	}

	// Reap per-model overrides whose gguf was deleted, before hashing — a pruned
	// sidecar changes the hash, so the trim itself triggers the regen below.
	if pruned, err := PruneSidecar(generatePath); err != nil {
		return false, fmt.Errorf("pruning sidecar: %w", err)
	} else if len(pruned) > 0 && logf != nil {
		logf(fmt.Sprintf("pruned %d override(s) for deleted models: %s", len(pruned), strings.Join(pruned, ", ")))
	}

	gf, err := LoadGenerateFile(generatePath, modelsDirOverride)
	if err != nil {
		return false, err
	}

	// The hash covers the resolved modelsRoot too, so a --models-dir change
	// triggers a regen even when the file is unchanged. It also folds in the
	// UI-owned sidecar so editing an override there forces a regen.
	sidecarBytes, _ := os.ReadFile(SidecarPath(generatePath))
	roots := gf.Settings.RootList()
	hashInput := buildHashInput(roots, rawGenerate, sidecarBytes)
	hash, err := InputsHashRoots(roots, hashInput)
	if err != nil {
		return false, fmt.Errorf("hashing models: %w", err)
	}

	// AutoVram bakes a live VRAM snapshot into the config, which the inputs hash
	// can't see — so never short-circuit on the hash when it's enabled; always
	// regen so each boot re-measures available VRAM.
	cachePath := outConfigPath + hashCacheSuffix
	_, statErr := os.Stat(outConfigPath)
	if statErr == nil && !gf.Settings.AutoVram && readHashCache(cachePath) == hash {
		if logf != nil {
			logf(fmt.Sprintf("config up to date (models + generate file unchanged); using %s", outConfigPath))
		}
		return false, nil
	}

	// Resolve the eligible GPU set on every regen, not only under autoVram: the
	// split ratio and the extra-device overhead are hardware facts the sizer needs
	// whether or not the BUDGET is being re-measured. ResolveAutoVram does both.
	ResolveAutoVram(&gf.Settings, logf)

	if logf != nil {
		if strings.TrimSpace(gf.Settings.ModelsRoot) == "" {
			logf(fmt.Sprintf("no modelsRoot set; generating %s with an empty catalog (set settings.modelsRoot or --models-dir, or pick a folder in the setup UI)", outConfigPath))
		} else {
			logf(fmt.Sprintf("generating %s from %s (models root %s)", outConfigPath, generatePath, gf.Settings.ModelsRoot))
		}
	}
	out, err := Generate(gf, DefaultNow())
	if err != nil {
		return false, fmt.Errorf("generating config: %w", err)
	}
	if err := os.WriteFile(outConfigPath, []byte(out), 0o644); err != nil {
		return false, fmt.Errorf("writing %s: %w", outConfigPath, err)
	}
	if err := os.WriteFile(cachePath, []byte(hash), 0o644); err != nil {
		return false, fmt.Errorf("writing hash cache: %w", err)
	}
	if logf != nil {
		logf(fmt.Sprintf("wrote %s", outConfigPath))
	}
	return true, nil
}