diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d44dd02..ecfc068c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ All notable changes to ForgeLM are documented here. ### Added +- `forgelm export` now writes `.sha256` next to the GGUF (`sha256sum` format), the file `forgelm verify-gguf` + and the documentation always expected. It had never been written, so a sidecar-based check had nothing to compare. + A sidecar that cannot be written fails the export (exit 2) instead of leaving a GGUF without it; for a K-quant + request it belongs to the intermediate `*.f16.gguf`. A `model_integrity.json` that cannot be updated is now logged + as a warning, not at debug level (#84). - `CITATION.cff`, so the repository can be cited through GitHub's "Cite this repository" panel (#964). - A contributor can claim a `good first issue` by commenting `/assign` and give it @@ -68,6 +73,10 @@ All notable changes to ForgeLM are documented here. ### Fixed +- Correct the GGUF pages (English and Turkish): the export example shows the real output, K-quants are described as + the two-step operation they are, the invented "manifest" and `verify-gguf` output (vocabulary and tokenizer checks that + do not exist) are replaced by the real ones, a clean `verify-gguf` exit is said to prove only what ran, and the sidecar + is described as corruption detection, not tamper evidence (#83, #84, #85). - Fix the KTO bullet on the Alignment overview user-manual page (English and Turkish): it named the fields `(prompt, response, good?)`, but the KTO loader reads `prompt`, `completion` and a boolean `label` (#965). diff --git a/docs/reference/verify_gguf_subcommand-tr.md b/docs/reference/verify_gguf_subcommand-tr.md index 0b092908..f53fe5dd 100644 --- a/docs/reference/verify_gguf_subcommand-tr.md +++ b/docs/reference/verify_gguf_subcommand-tr.md @@ -43,7 +43,7 @@ Kodlar `forgelm/cli/subcommands/_verify_gguf.py::_run_verify_gguf_cmd` tarafınd | **Meta veri bloğu** | İsteğe bağlı `gguf` paketi yüklüyse. Üst kaynak okuyucu ile meta veri + tensor tanımlarını ayrıştırır. | Okuyucu ayrıştırma sırasında istisna fırlatır → çıkış kodu sidecar'ın ne kanıtladığına bağlıdır; çünkü ayrıştırma hatası tek başına bozuk bir dosyayı, bu dosyanın format revizyonunu okuyamayacak kadar eski bir `gguf` paketinden ayırt edemez. **Sidecar yok** → çıkış `6` (bozulmayı eleyecek hiçbir şey yok; dosya kesilmiş / hasarlı sayılmalı). **Eşleşen sidecar** → çıkış `1` (dosya export edilenle bayt-bayt aynı — `gguf`'u yükseltip yeniden çalıştırın; artefakt sahibini çağırmayın). **Uyuşmayan sidecar** → çıkış `6`, sidecar uyuşmazlığı olarak raporlanır (checksum daha güçlü kanıttır ve baskın gelir). Paket yoksa → kontrol atlanır (magic + sidecar kontrolleri yük taşır). | | **SHA-256 sidecar** | `.sha256` mevcutsa. Dosyanın SHA-256'sını yeniden hesaplar ve sidecar'ın ilk boşluk-ayrılı token'ı ile karşılaştırır (sha256sum formatı ` *` desteklenir). | İyi-biçimli ama uyuşmayan digest → çıkış `6` (dosya export sonrası değişmiş). Sidecar mevcut ama içeriği 64 karakterlik hex digest değilse → çıkış `1` (bozuk-sidecar maskelenmesine karşı kapalı başarısızlık — hiçbir şey karşılaştırılmadı). Sidecar yoksa → kontrol sessizce atlanır. | -Exporter sidecar'ı varsayılan olarak yazar (bkz. [`docs/usermanuals/tr/deployment/gguf-export.md`](../usermanuals/tr/deployment/gguf-export.md)); GGUF dosyalarını üçüncü taraflardan alan operatörler sidecar'ı da talep etmelidir. +Exporter sidecar'ı varsayılan olarak yazar (K-quant isteğinde sidecar ara f16 dosyasına aittir; bkz. [`docs/usermanuals/tr/deployment/gguf-export.md`](../usermanuals/tr/deployment/gguf-export.md)); GGUF dosyalarını üçüncü taraflardan alan operatörler sidecar'ı da talep etmelidir. ## Emit edilen audit event'leri @@ -142,7 +142,7 @@ Aynı dosyanın yanında **hiç** sidecar yoksa karar bunun yerine `6` olur — ### İsteğe bağlı bağımlılık yok -`gguf` paketi yüklü değilse meta veri ayrıştırma katmanı sessizce atlanır — magic + sidecar kontrolleri yük taşımaya devam eder: +`gguf` paketi yüklü değilse meta veri ayrıştırma katmanı atlanır (`metadata_parsed: False`). O zaman denetlenen yalnızca magic ve sidecar'dır; sidecar da yoksa temiz bir çıkış yalnızca magic header'ı kanıtlar: ```shell $ pip uninstall -y gguf diff --git a/docs/reference/verify_gguf_subcommand.md b/docs/reference/verify_gguf_subcommand.md index c91506ca..55a970cf 100644 --- a/docs/reference/verify_gguf_subcommand.md +++ b/docs/reference/verify_gguf_subcommand.md @@ -43,7 +43,7 @@ The codes are emitted by `forgelm/cli/subcommands/_verify_gguf.py::_run_verify_g | **Metadata block** | When the optional `gguf` package is installed. Parses the metadata + tensor descriptors via the upstream reader. | Reader raises mid-parse → the exit code depends on what the sidecar can prove, because the parse error alone cannot tell a corrupted file apart from a `gguf` package too old for this file's format revision. **No sidecar** → exit `6` (nothing available to rule out corruption; treat the file as truncated / damaged). **Matching sidecar** → exit `1` (the file is byte-identical to what was exported — upgrade `gguf` and re-run; do not page the artifact owner). **Mismatching sidecar** → exit `6`, reported as the sidecar mismatch (the checksum is the stronger evidence and dominates). Package absent → check is skipped (the magic + sidecar checks remain load-bearing). | | **SHA-256 sidecar** | When `.sha256` exists. Recomputes file SHA-256 and compares against the sidecar's first whitespace-separated token (sha256sum format ` *` is supported). | Well-formed digest that disagrees → exit `6` (file changed after export). Sidecar present but contents are not a 64-character hex digest → exit `1` (fail closed against malformed-sidecar masquerade — nothing was compared). Sidecar absent → check is skipped silently. | -The exporter writes the sidecar by default (see [`docs/usermanuals/en/deployment/gguf-export.md`](../usermanuals/en/deployment/gguf-export.md)); operators receiving GGUF files from third parties should request the sidecar alongside. +The exporter writes the sidecar by default (for a K-quant request it belongs to the intermediate f16 file; see [`docs/usermanuals/en/deployment/gguf-export.md`](../usermanuals/en/deployment/gguf-export.md)); operators receiving GGUF files from third parties should request the sidecar alongside. ## Audit events emitted @@ -142,7 +142,7 @@ With **no** sidecar beside the same file the verdict is `6` instead — nothing ### Optional dependency absent -When the `gguf` package is not installed, the metadata-parse layer is skipped silently — the magic + sidecar checks remain load-bearing: +When the `gguf` package is not installed, the metadata-parse layer is skipped (`metadata_parsed: False`). Magic and sidecar are then all that is checked, and with no sidecar either, a clean exit proves only the magic header: ```shell $ pip uninstall -y gguf diff --git a/docs/usermanuals/en/deployment/gguf-export.md b/docs/usermanuals/en/deployment/gguf-export.md index 0303839f..a18f8c2f 100644 --- a/docs/usermanuals/en/deployment/gguf-export.md +++ b/docs/usermanuals/en/deployment/gguf-export.md @@ -5,21 +5,20 @@ description: Quantise to GGUF for local inference with llama.cpp, Ollama, or LM # GGUF Export -GGUF (GPT-Generated Unified Format) is the de facto file format for local CPU/GPU inference via `llama.cpp`. ForgeLM exports any fine-tuned checkpoint to GGUF with six quantisation levels and a SHA-256 manifest. +GGUF (GPT-Generated Unified Format) is the de facto file format for local CPU/GPU inference via `llama.cpp`. ForgeLM exports a fine-tuned checkpoint to GGUF and writes a SHA-256 sidecar next to the file. `f16` and `q8_0` come out of a single command; the K-quant levels (`q2_k`, `q3_k_m`, `q4_k_m`, `q5_k_m`) need one more `llama-quantize` step, described below. ## Quick example ```shell $ forgelm export ./checkpoints/customer-support \ --output model.gguf \ - --quant q4_k_m -✓ merged LoRA into base -✓ converted to GGUF -✓ quantised to q4_k_m (4.1 GB → 4.1 GB) -✓ wrote model.gguf and model.gguf.sha256 + --quant q8_0 +Export complete: model.gguf (quant=q8_0, sha256=3f2a91c0b4d7…) ``` -The output is a single `.gguf` file plus a `.sha256` manifest. +(Log output, abridged.) The result is `model.gguf` plus `model.gguf.sha256` next to it. The sidecar is in `sha256sum` format (` *model.gguf`), so both `sha256sum -c model.gguf.sha256` and [`forgelm verify-gguf`](#/deployment/verify-gguf) accept it. When the checkpoint directory already holds a `model_integrity.json`, ForgeLM also tries to record the digest there under `exported_artifacts` (best effort: if the file is not valid JSON or cannot be written, a warning is logged and the export still succeeds; `--no-integrity-update` skips this). + +The sidecar detects corruption and accidental change. It sits beside the file, so it is **not** tamper evidence: whoever can replace the GGUF can replace the sidecar too. If you need that, sign the file or keep the digest where the file's writer cannot reach it. ## Quantisation levels @@ -34,51 +33,68 @@ The output is a single `.gguf` file plus a `.sha256` manifest. `q4_k_m` is the sweet spot — fits easily on consumer hardware, minimal quality loss versus full precision. +### K-quants take one more step + +The converter ForgeLM calls writes `f16` and `q8_0` directly. For a K-quant (`q2_k`, `q3_k_m`, `q4_k_m` — the default — and `q5_k_m`) `forgelm export` writes an intermediate `f16` GGUF, named like the output with its `.gguf` suffix replaced by `.f16.gguf` (`model.gguf` becomes `model.f16.gguf`; an output without the suffix gets `.f16.gguf` appended), with its own `.sha256`. It reports the substitution (`requested_quant`, `manual_step_required` and the exact `followup_command` in `--output-format json`) and logs the `llama-quantize` command that finishes the job: + +```shell +$ forgelm export ./checkpoints/customer-support --output model.gguf --quant q4_k_m +Export complete: model.f16.gguf (quant=f16, sha256=9c1e47ab02d5…) +Manual quantization step required — run: llama-quantize model.f16.gguf model.gguf Q4_K_M +$ llama-quantize model.f16.gguf model.gguf Q4_K_M +$ sha256sum model.gguf > model.gguf.sha256 +``` + +`llama-quantize` comes from llama.cpp; ForgeLM does not install it. `forgelm export` writes no sidecar for the quantised file, so the last command creates one and `forgelm verify-gguf model.gguf` has a digest to compare. + GGUF export is always a separate, explicit `forgelm export` invocation — there is no config-driven auto-export toggle. `forgelm/config.py` has no `output:` block and no `gguf` field anywhere in the schema, so a training YAML cannot trigger export as a side effect of a passing eval gate; run `forgelm export` yourself once training finishes. ## Multi-quant export `forgelm export --quant` accepts a single value per invocation (one of `{q2_k, q3_k_m, q4_k_m, q5_k_m, q8_0, f16}`). To produce several -quantisations from one checkpoint, run the command once per quant: +quantisations from one checkpoint, run the command once per quant. For +the two levels the converter writes directly: ```shell -$ for q in q4_k_m q5_k_m q8_0; do +$ for q in q8_0 f16; do forgelm export ./checkpoints/run \ --output "./gguf/model.${q}.gguf" \ --quant "${q}" done -✓ wrote gguf/model.q4_k_m.gguf (4.1 GB) -✓ wrote gguf/model.q5_k_m.gguf (4.8 GB) -✓ wrote gguf/model.q8_0.gguf (7.2 GB) +Export complete: ./gguf/model.q8_0.gguf (quant=q8_0, sha256=3f2a91c0b4d7…) +Export complete: ./gguf/model.f16.gguf (quant=f16, sha256=b81d05e6c392…) ``` -The manifest: +Each run writes its own sidecar (`model.q8_0.gguf.sha256`, `model.f16.gguf.sha256`). For K-quants, run the loop with the K-quant names and finish each intermediate file with `llama-quantize`, as above. + +When the checkpoint directory holds a `model_integrity.json`, each run tries to append an entry to its `exported_artifacts` list (best effort, as above): ```json { - "base_model": "Qwen/Qwen2.5-7B-Instruct", - "fine_tune_run_id": "abc123", - "quants": { - "q4_k_m": {"size_bytes": 4100000000, "sha256": "..."}, - "q5_k_m": {"size_bytes": 4800000000, "sha256": "..."}, - "q8_0": {"size_bytes": 7200000000, "sha256": "..."} - }, - "exported_at": "2026-04-29T15:00:00Z" + "exported_artifacts": [ + {"file": "model.q8_0.gguf", "format": "gguf", "quant": "q8_0", "sha256": "…", "size_bytes": 7200000000}, + {"file": "model.f16.gguf", "format": "gguf", "quant": "f16", "sha256": "…", "size_bytes": 13000000000} + ] } ``` ## Verifying GGUF integrity ```shell -$ forgelm verify-gguf model.q4_k_m.gguf -✓ valid GGUF magic -✓ vocab size matches base -✓ sha256 matches manifest -✓ tokenizer round-trip OK +$ forgelm verify-gguf model.q8_0.gguf +OK: model.q8_0.gguf + GGUF magic OK, metadata parsed, SHA-256 sidecar match + magic_ok: True + metadata_parsed: True + sidecar_present: True + sidecar_match: True + tensor_count: 291 + sha256_actual: a4c1f2… + sha256_expected: a4c1f2… ``` -This catches corruption from bad transfers, silent disk errors, and the occasional `llama.cpp` upstream incompatibility. +This checks the GGUF magic header, parses the metadata block when the optional `gguf` package is installed, and compares the `.sha256` sidecar when it exists. It does not compare the vocabulary or the tokenizer with the base model. It catches corruption from bad transfers, silent disk errors and a file edited after export; it does not show who produced the file. [Verify GGUF](#/deployment/verify-gguf) says what each layer proves. ## Loading GGUF in popular tools @@ -117,7 +133,7 @@ The result is full-precision GGUF, ~13 GB for a 7B model. ## Common pitfalls :::warn -**Exporting LoRA without merging.** `forgelm export` always merges LoRA adapters into the base before quantising. If you want adapter-only inference, you don't want GGUF — use `forgelm chat` against the adapter directly or load with PEFT. +**Forgetting `--adapter`.** `forgelm export` merges a LoRA adapter into the base model only when you pass `--adapter `; without it, the model path must already be a full model directory. If you want adapter-only inference, you don't want GGUF — use `forgelm chat` against the adapter directly or load with PEFT. ::: :::warn diff --git a/docs/usermanuals/en/deployment/verify-gguf.md b/docs/usermanuals/en/deployment/verify-gguf.md index 8fecc66a..ddbf4fc7 100644 --- a/docs/usermanuals/en/deployment/verify-gguf.md +++ b/docs/usermanuals/en/deployment/verify-gguf.md @@ -5,11 +5,11 @@ description: Three-layer integrity check for exported GGUF model files before se # Verify GGUF -`forgelm verify-gguf` is the read-only verifier paired with the GGUF export step. It runs a three-layer integrity check on a GGUF model file: the 4-byte `GGUF` magic header, the metadata block (when the optional `gguf` Python package is installed), and a SHA-256 comparison against the `.sha256` sidecar (when present). Use it as the final pre-serve gate so a corrupted or tampered GGUF never reaches `llama.cpp`, Ollama, vLLM, or LM Studio. +`forgelm verify-gguf` is the read-only verifier paired with the GGUF export step. It runs a three-layer integrity check on a GGUF model file: the 4-byte `GGUF` magic header, the metadata block (when the optional `gguf` Python package is installed), and a SHA-256 comparison against the `.sha256` sidecar (when present). Use it as the final pre-serve gate so a corrupted or accidentally changed GGUF does not reach `llama.cpp`, Ollama, vLLM, or LM Studio. The sidecar sits next to the file, so this is not tamper evidence (see the pitfalls below). ## When to use it -- **Before loading a GGUF into a serving runtime.** A clean `verify-gguf` exit is the minimum signal the file is what the exporter wrote. +- **Before loading a GGUF into a serving runtime.** A clean `verify-gguf` exit proves only what ran: with neither the `gguf` package nor a `.sha256` sidecar it proves that the first four bytes are `GGUF` and nothing else, not that the file is complete or unmodified. - **In CI/CD release gates after GGUF export.** Run after every `forgelm export` step and **fail the release on any non-zero exit code** (the public contract is the `0/1/2/3/4/5/6` set; see the in-manual [Exit Codes](#/reference/exit-codes) reference. `1` is the most common gate trip for a bad path or malformed sidecar — nothing was compared, or what was compared came out clean — while `6` means the file *is* a GGUF and a comparison genuinely disagreed; `2` covers genuine I/O failures on existing files, and other non-zero codes propagate from upstream pipelines). - **When receiving a GGUF from a third-party trainer or model hub.** Recompute the magic + metadata + SHA-256 layers to detect drift between what they sent and what they signed. - **After moving a GGUF between machines.** Any byte-level corruption in transit shows up as a SHA-256 mismatch. @@ -100,7 +100,7 @@ Pipe to `jq` to filter on the `valid` flag without parsing the human-readable te | **Metadata block** | When the optional `gguf` Python package is installed. | The reader raised mid-parse. That alone cannot tell a truncated file apart from a `gguf` package too old for this file's format revision, so the verdict is decided by what the sidecar can prove — see [the three metadata outcomes](#metadata-parse-failure-depends-on-the-sidecar) below. | `6` (no sidecar) / `1` (matching sidecar) / `6` (mismatching sidecar) | | **SHA-256 sidecar** | When `.sha256` exists alongside the file. | Either the file was modified after export (a well-formed digest that disagrees — integrity failure), OR the sidecar itself is malformed (non-hex / wrong length / `TODO` placeholder — nothing was compared). The verifier fails closed on a malformed sidecar so a corrupted manifest can never silently masquerade as "verified". | `6` (modified) / `1` (malformed sidecar) | -The exporter writes the sidecar by default. When you receive a GGUF from a third party, request the sidecar alongside it; without one, only the magic + metadata layers run. +The exporter writes the sidecar by default (for a K-quant request it belongs to the intermediate f16 file; create one for the quantised file with `sha256sum model.gguf > model.gguf.sha256`). When you receive a GGUF from a third party, request the sidecar alongside it; without one, only the magic + metadata layers run. ### Metadata parse failure depends on the sidecar @@ -116,7 +116,7 @@ The middle row is the one to internalise in CI: exit `6` means "page whoever own ### Optional dependency: the `gguf` package -The metadata parse uses the upstream `gguf` Python package. When the package is not installed, the layer is skipped silently — the magic + sidecar checks remain load-bearing: +The metadata parse uses the upstream `gguf` Python package. When the package is not installed, the layer is skipped (the text output shows `metadata_parsed: False`). The magic and the sidecar are then all that is checked, and with no sidecar either, a clean exit means only that the file starts with `GGUF`: ```shell $ pip uninstall -y gguf diff --git a/docs/usermanuals/tr/deployment/gguf-export.md b/docs/usermanuals/tr/deployment/gguf-export.md index 5832e786..24c77d40 100644 --- a/docs/usermanuals/tr/deployment/gguf-export.md +++ b/docs/usermanuals/tr/deployment/gguf-export.md @@ -5,21 +5,20 @@ description: llama.cpp, Ollama veya LM Studio ile yerel inference için GGUF'a k # GGUF Export -GGUF (GPT-Generated Unified Format), `llama.cpp` üzerinden yerel CPU/GPU inference için fiili dosya formatıdır. ForgeLM herhangi bir fine-tuned checkpoint'i altı kuantizasyon seviyesi ve SHA-256 manifest ile GGUF'a export eder. +GGUF (GPT-Generated Unified Format), `llama.cpp` üzerinden yerel CPU/GPU inference için fiili dosya formatıdır. ForgeLM fine-tuned bir checkpoint'i GGUF'a export eder ve dosyanın yanına bir SHA-256 sidecar yazar. `f16` ve `q8_0` tek komutla çıkar; K-quant seviyeleri (`q2_k`, `q3_k_m`, `q4_k_m`, `q5_k_m`) aşağıda anlatılan bir `llama-quantize` adımı daha ister. ## Hızlı örnek ```shell $ forgelm export ./checkpoints/customer-support \ --output model.gguf \ - --quant q4_k_m -✓ LoRA base'e merge edildi -✓ GGUF'a dönüştürüldü -✓ q4_k_m'a kuantize edildi (4.1 GB → 4.1 GB) -✓ model.gguf ve model.gguf.sha256 yazıldı + --quant q8_0 +Export complete: model.gguf (quant=q8_0, sha256=3f2a91c0b4d7…) ``` -Çıktı tek `.gguf` dosyası artı `.sha256` manifest. +(Log çıktısı, kısaltılmış.) Sonuç `model.gguf` ve yanında `model.gguf.sha256`'dır. Sidecar `sha256sum` formatındadır (` *model.gguf`); bu yüzden hem `sha256sum -c model.gguf.sha256` hem de [`forgelm verify-gguf`](#/deployment/verify-gguf) onu kabul eder. Checkpoint dizininde zaten bir `model_integrity.json` varsa ForgeLM digest'i oraya da `exported_artifacts` altında yazmayı dener (best-effort: dosya geçerli JSON değilse ya da yazılamıyorsa bir uyarı loglanır ve export yine de başarılı olur; `--no-integrity-update` bunu atlar). + +Sidecar bozulmayı ve kazara değişikliği yakalar. Dosyanın yanında durduğu için kurcalama kanıtı **değildir**: GGUF'u değiştirebilen sidecar'ı da değiştirebilir. Buna ihtiyacınız varsa dosyayı imzalayın ya da digest'i dosyanın yazarının erişemediği bir yerde tutun. ## Kuantizasyon seviyeleri @@ -34,6 +33,20 @@ $ forgelm export ./checkpoints/customer-support \ `q4_k_m` tatlı nokta — consumer donanıma rahat sığar, full precision'a karşı minimum kalite kaybı. +### K-quant'lar bir adım daha ister + +ForgeLM'in çağırdığı dönüştürücü `f16` ve `q8_0`'ı doğrudan yazar. Bir K-quant için (`q2_k`, `q3_k_m`, `q4_k_m` — varsayılan — ve `q5_k_m`) `forgelm export`, kendi `.sha256`'sıyla birlikte, çıktı adındaki `.gguf` uzantısı `.f16.gguf` ile değiştirilerek adlandırılan (`model.gguf` → `model.f16.gguf`; bu uzantıyla bitmeyen bir çıktıya `.f16.gguf` eklenir) ara bir `f16` GGUF yazar. Değişikliği bildirir (`--output-format json`'da `requested_quant`, `manual_step_required` ve tam `followup_command`) ve işi bitiren `llama-quantize` komutunu loglar: + +```shell +$ forgelm export ./checkpoints/customer-support --output model.gguf --quant q4_k_m +Export complete: model.f16.gguf (quant=f16, sha256=9c1e47ab02d5…) +Manual quantization step required — run: llama-quantize model.f16.gguf model.gguf Q4_K_M +$ llama-quantize model.f16.gguf model.gguf Q4_K_M +$ sha256sum model.gguf > model.gguf.sha256 +``` + +`llama-quantize` llama.cpp'den gelir; ForgeLM onu kurmaz. `forgelm export` kuantize edilmiş dosya için sidecar yazmaz; son komut onu oluşturur ve `forgelm verify-gguf model.gguf` karşılaştıracak bir digest bulur. + GGUF export her zaman ayrı, açık bir `forgelm export` çağrısıdır — config-driven bir otomatik export toggle'ı yoktur. `forgelm/config.py`'de bir `output:` bloğu ve şemada hiçbir yerde `gguf` alanı yoktur, dolayısıyla bir eğitim YAML'ı eval kapısının geçmesinin yan etkisi olarak export'u tetikleyemez; eğitim bittiğinde `forgelm export`'u kendiniz çalıştırın. ## Çoklu-quant export @@ -41,45 +54,48 @@ GGUF export her zaman ayrı, açık bir `forgelm export` çağrısıdır — con `forgelm export --quant` invocation başına tek değer kabul eder (seçenekler: `{q2_k, q3_k_m, q4_k_m, q5_k_m, q8_0, f16}`). Tek bir checkpoint'ten birden çok quantisation üretmek için komutu her -quant için bir kez çalıştırın: +quant için bir kez çalıştırın. Dönüştürücünün doğrudan yazdığı iki +seviye için: ```shell -$ for q in q4_k_m q5_k_m q8_0; do +$ for q in q8_0 f16; do forgelm export ./checkpoints/run \ --output "./gguf/model.${q}.gguf" \ --quant "${q}" done -✓ gguf/model.q4_k_m.gguf yazıldı (4.1 GB) -✓ gguf/model.q5_k_m.gguf yazıldı (4.8 GB) -✓ gguf/model.q8_0.gguf yazıldı (7.2 GB) +Export complete: ./gguf/model.q8_0.gguf (quant=q8_0, sha256=3f2a91c0b4d7…) +Export complete: ./gguf/model.f16.gguf (quant=f16, sha256=b81d05e6c392…) ``` -Manifest: +Her çalıştırma kendi sidecar'ını yazar (`model.q8_0.gguf.sha256`, `model.f16.gguf.sha256`). K-quant'lar için döngüyü K-quant adlarıyla çalıştırın ve her ara dosyayı yukarıdaki gibi `llama-quantize` ile tamamlayın. + +Checkpoint dizininde bir `model_integrity.json` varsa her çalıştırma onun `exported_artifacts` listesine (yukarıdaki gibi best-effort) bir kayıt eklemeyi dener: ```json { - "base_model": "Qwen/Qwen2.5-7B-Instruct", - "fine_tune_run_id": "abc123", - "quants": { - "q4_k_m": {"size_bytes": 4100000000, "sha256": "..."}, - "q5_k_m": {"size_bytes": 4800000000, "sha256": "..."}, - "q8_0": {"size_bytes": 7200000000, "sha256": "..."} - }, - "exported_at": "2026-04-29T15:00:00Z" + "exported_artifacts": [ + {"file": "model.q8_0.gguf", "format": "gguf", "quant": "q8_0", "sha256": "…", "size_bytes": 7200000000}, + {"file": "model.f16.gguf", "format": "gguf", "quant": "f16", "sha256": "…", "size_bytes": 13000000000} + ] } ``` ## GGUF bütünlük doğrulama ```shell -$ forgelm verify-gguf model.q4_k_m.gguf -✓ valid GGUF magic -✓ vocab boyutu base ile eşleşiyor -✓ sha256 manifest ile eşleşiyor -✓ tokenizer round-trip OK +$ forgelm verify-gguf model.q8_0.gguf +OK: model.q8_0.gguf + GGUF magic OK, metadata parsed, SHA-256 sidecar match + magic_ok: True + metadata_parsed: True + sidecar_present: True + sidecar_match: True + tensor_count: 291 + sha256_actual: a4c1f2… + sha256_expected: a4c1f2… ``` -Bu kötü transferden bozulmaları, sessiz disk hatalarını ve ara sıra `llama.cpp` upstream uyumsuzluklarını yakalar. +Bu komut GGUF magic header'ını denetler, isteğe bağlı `gguf` paketi kuruluysa meta veri bloğunu ayrıştırır ve varsa `.sha256` sidecar'ını karşılaştırır. Kelime dağarcığını ya da tokenizer'ı base modelle karşılaştırmaz. Kötü transferden bozulmaları, sessiz disk hatalarını ve export sonrası düzenlenmiş bir dosyayı yakalar; dosyayı kimin ürettiğini göstermez. Her katmanın neyi kanıtladığı için [Verify GGUF](#/deployment/verify-gguf) sayfasına bakın. ## Yaygın araçlarda GGUF yükleme @@ -118,7 +134,7 @@ Sonuç full-precision GGUF, 7B model için ~13 GB. ## Sık hatalar :::warn -**LoRA'yı merge etmeden export.** `forgelm export` her zaman LoRA adapter'ı kuantizasyondan önce base'e merge eder. Adapter-only inference istiyorsanız GGUF istemiyorsunuz — adapter'a karşı doğrudan `forgelm chat` kullanın veya PEFT ile yükleyin. +**`--adapter`'ı unutmak.** `forgelm export` bir LoRA adapter'ı base modele yalnızca `--adapter ` verdiğinizde merge eder; vermezseniz model yolu zaten tam bir model dizini olmalıdır. Adapter-only inference istiyorsanız GGUF istemiyorsunuz — adapter'a karşı doğrudan `forgelm chat` kullanın veya PEFT ile yükleyin. ::: :::warn diff --git a/docs/usermanuals/tr/deployment/verify-gguf.md b/docs/usermanuals/tr/deployment/verify-gguf.md index de55e2a3..37383a20 100644 --- a/docs/usermanuals/tr/deployment/verify-gguf.md +++ b/docs/usermanuals/tr/deployment/verify-gguf.md @@ -5,11 +5,11 @@ description: llama.cpp, Ollama, vLLM ya da LM Studio üzerinden servis etmeden # GGUF Doğrulama -`forgelm verify-gguf`, GGUF export adımıyla eşleşen salt-okunur doğrulayıcıdır. Bir GGUF model dosyası üzerinde üç katmanlı bütünlük kontrolü yapar: 4-baytlık `GGUF` magic header, isteğe bağlı `gguf` Python paketi yüklüyse meta veri bloğu ve mevcutsa `.sha256` sidecar'ına karşı SHA-256 karşılaştırması. Bozulmuş veya tahrif edilmiş bir GGUF'un asla `llama.cpp`, Ollama, vLLM ya da LM Studio'ya ulaşmaması için son ön-servis kapısı olarak kullanın. +`forgelm verify-gguf`, GGUF export adımıyla eşleşen salt-okunur doğrulayıcıdır. Bir GGUF model dosyası üzerinde üç katmanlı bütünlük kontrolü yapar: 4-baytlık `GGUF` magic header, isteğe bağlı `gguf` Python paketi yüklüyse meta veri bloğu ve mevcutsa `.sha256` sidecar'ına karşı SHA-256 karşılaştırması. Bozulmuş veya kazara değişmiş bir GGUF'un `llama.cpp`, Ollama, vLLM ya da LM Studio'ya ulaşmaması için son ön-servis kapısı olarak kullanın. Sidecar dosyanın yanında durduğu için bu bir kurcalama kanıtı değildir (aşağıdaki tuzaklara bakın). ## Ne zaman kullanılır -- **Bir GGUF'u servis runtime'ına yüklemeden önce.** Temiz bir `verify-gguf` çıkışı, dosyanın exporter'ın yazdığı şey olduğuna dair minimum sinyaldir. +- **Bir GGUF'u servis runtime'ına yüklemeden önce.** Temiz bir `verify-gguf` çıkışı yalnızca çalışan kontrolü kanıtlar: ne `gguf` paketi ne de `.sha256` sidecar'ı varsa ilk dört baytın `GGUF` olduğunu kanıtlar, başka hiçbir şeyi değil; dosyanın eksiksiz ya da değiştirilmemiş olduğunu değil. - **GGUF export sonrası CI/CD yayın kapılarında.** Her `forgelm export` adımından sonra çalıştırın ve **sıfırdan farklı herhangi bir çıkış kodunda yayını başarısız sayın** (kamuya açık sözleşme `0/1/2/3/4/5/6` setidir; manuel içindeki [Çıkış Kodları](#/reference/exit-codes) referansına bakın. Bozuk bir yol veya bozuk sidecar için `1` en sık kapı tripi'dir — hiçbir şey karşılaştırılmadı ya da karşılaştırılan şey temiz çıktı — `6` ise dosyanın *gerçekten* bir GGUF olduğu ve bir karşılaştırmanın gerçekten uyuşmadığı anlamına gelir; `2` mevcut dosyalardaki gerçek I/O hatalarını kapsar, üst pipeline'lardan diğer sıfırdan farklı kodlar da iletilebilir). - **Üçüncü-taraf bir trainer'dan veya model hub'ından GGUF alındığında.** Gönderilen ile imzalanan arasındaki sapmayı tespit etmek için magic + meta veri + SHA-256 katmanlarını yeniden hesaplayın. - **Bir GGUF'u makineler arasında taşıdıktan sonra.** Aktarımda oluşan herhangi bir bayt-seviyesi bozulma SHA-256 uyuşmazlığı olarak ortaya çıkar. @@ -100,7 +100,7 @@ $ forgelm verify-gguf --output-format json \ | **Meta veri bloğu** | İsteğe bağlı `gguf` Python paketi yüklüyse. | Okuyucu ayrıştırma sırasında istisna fırlattı. Bu tek başına, kesilmiş bir dosyayı bu dosyanın format revizyonunu okuyamayacak kadar eski bir `gguf` paketinden ayırt edemez; dolayısıyla kararı sidecar'ın kanıtladığı şey belirler — aşağıdaki [üç meta veri sonucuna](#meta-veri-ayrıştırma-hatası-sidecara-bağlıdır) bakın. | `6` (sidecar yok) / `1` (eşleşen sidecar) / `6` (uyuşmayan sidecar) | | **SHA-256 sidecar** | Dosyanın yanında `.sha256` mevcutsa. | Ya dosya export sonrası değiştirildi (uyuşmayan iyi-biçimli bir digest — bütünlük arızası) YA DA sidecar'ın kendisi bozuk (hex değil / yanlış uzunluk / `TODO` placeholder — hiçbir şey karşılaştırılmadı). Doğrulayıcı bozuk sidecar'da kapalı başarısız olur; böylece bozuk bir manifest asla "doğrulanmış" gibi sessizce maskelenemez. | `6` (değiştirilmiş) / `1` (bozuk sidecar) | -Exporter sidecar'ı varsayılan olarak yazar. Üçüncü taraftan bir GGUF aldığınızda yanında sidecar'ı da talep edin; sidecar olmazsa yalnızca magic + meta veri katmanları çalışır. +Exporter sidecar'ı varsayılan olarak yazar (K-quant isteğinde sidecar ara f16 dosyasına aittir; kuantize edilmiş dosya için `sha256sum model.gguf > model.gguf.sha256` ile bir tane oluşturun). Üçüncü taraftan bir GGUF aldığınızda yanında sidecar'ı da talep edin; sidecar olmazsa yalnızca magic + meta veri katmanları çalışır. ### Meta veri ayrıştırma hatası sidecar'a bağlıdır @@ -116,7 +116,7 @@ CI'da içselleştirilmesi gereken satır ortadaki: çıkış `6` "artefakt sahib ### İsteğe bağlı bağımlılık: `gguf` paketi -Meta veri ayrıştırması üst kaynak `gguf` Python paketini kullanır. Paket yüklü değilse katman sessizce atlanır — magic + sidecar kontrolleri yük taşımaya devam eder: +Meta veri ayrıştırması üst kaynak `gguf` Python paketini kullanır. Paket yüklü değilse katman atlanır (metin çıktısı `metadata_parsed: False` gösterir). O zaman denetlenen yalnızca magic ve sidecar'dır; sidecar da yoksa temiz bir çıkış yalnızca dosyanın `GGUF` ile başladığı anlamına gelir: ```shell $ pip uninstall -y gguf diff --git a/forgelm/export.py b/forgelm/export.py index 595a6c78..6e97a447 100644 --- a/forgelm/export.py +++ b/forgelm/export.py @@ -17,6 +17,7 @@ from __future__ import annotations +import contextlib import hashlib import logging import os @@ -189,6 +190,10 @@ def _merge_adapter(model_path: str, adapter_path: str, merged_dir: str) -> None: # SHA-256 helper # --------------------------------------------------------------------------- +# Suffix of the digest file written next to the GGUF. ``forgelm verify-gguf`` looks for +# ``.sha256`` (``forgelm.verify._gguf._SIDECAR_SUFFIX``); tests/test_export.py keeps the two equal. +GGUF_SIDECAR_SUFFIX = ".sha256" + def _sha256_file(path: str) -> str: """Compute the SHA-256 digest of a file.""" @@ -199,6 +204,29 @@ def _sha256_file(path: str) -> str: return h.hexdigest() +def _write_sha256_sidecar(gguf_path: str, digest: str) -> str: + """Write ``.sha256`` in ``sha256sum`` binary-mode format (`` *``). + + The file is written next to the GGUF through a temporary name and renamed into place, so a reader never + sees a half-written digest. ``sha256sum -c`` accepts the result, and so does ``forgelm verify-gguf``. + The sidecar detects corruption and accidental change; it is not tamper evidence, because whoever can + replace the GGUF can replace the sidecar next to it. Returns the sidecar path. + """ + sidecar_path = gguf_path + GGUF_SIDECAR_SUFFIX + temporary_path = sidecar_path + ".tmp" + try: + with open(temporary_path, "w", encoding="utf-8") as fh: + fh.write(f"{digest} *{os.path.basename(gguf_path)}\n") + os.replace(temporary_path, sidecar_path) + except OSError: + with contextlib.suppress( + OSError + ): # best-effort tidy-up of our own temporary file; the original error is re-raised + os.remove(temporary_path) + raise + return sidecar_path + + # --------------------------------------------------------------------------- # Compliance integration # --------------------------------------------------------------------------- @@ -241,7 +269,7 @@ def _update_integrity_manifest(model_dir: str, export_result: ExportResult) -> N # ``json.JSONDecodeError`` since it subclasses ``ValueError``). # Updating the manifest is non-fatal: the artefact itself was already # exported successfully and its on-disk SHA-256 is recoverable. - logger.debug("Could not update model_integrity.json: %s", e) + logger.warning("Could not update model_integrity.json: %s", e) # --------------------------------------------------------------------------- @@ -407,7 +435,9 @@ def export_model( converter can exceed one hour; raise this for large models. Returns: - :class:`ExportResult` with SHA-256, file size, and output path. + :class:`ExportResult` with SHA-256, file size, and output path. The digest is also written next to the + GGUF as ``.sha256`` (``sha256sum`` format), which ``forgelm verify-gguf`` compares against. For + a K-quant request the file written is the intermediate f16 GGUF and the sidecar belongs to it. """ fmt = output_format.lower() quant = quant.lower() @@ -466,6 +496,20 @@ def export_model( digest = _sha256_file(actual_output_path) size_bytes = os.path.getsize(actual_output_path) + try: + _write_sha256_sidecar(actual_output_path, digest) + except OSError as e: + return ExportResult( + success=False, + output_path=actual_output_path, + format=fmt, + quant=actual_quant, + sha256=digest, + size_bytes=size_bytes, + error=f"GGUF written to {actual_output_path} but its SHA-256 sidecar could not be written: {e}", + error_kind="runtime", + ) + # Structured substitution signal: a K-quant request is served as an # intermediate f16 GGUF and needs a manual llama-quantize follow-up. Expose # the requested quant + the exact follow-up command so JSON/CI consumers can diff --git a/forgelm/verify/_gguf.py b/forgelm/verify/_gguf.py index 04c1f2de..68bb0811 100644 --- a/forgelm/verify/_gguf.py +++ b/forgelm/verify/_gguf.py @@ -192,7 +192,8 @@ def _maybe_parse_metadata(path: str) -> Dict[str, Any]: artefacts on a minimal install legitimately do not have it. Absent ``gguf`` package = ``parsed=False``, ``error=None`` and the caller treats this as "metadata check skipped" (the magic-header - + SHA-256-sidecar checks are the load-bearing integrity surface). + and SHA-256-sidecar checks then remain; with no sidecar either, a + clean result proves only the magic header). Raising ``ImportError`` here would break the subcommand for the optional-extra-not-installed path and contradict the project standard. Genuine corruption (file present but reader crashes) diff --git a/tests/test_export.py b/tests/test_export.py index 319874bb..25071f9e 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -10,11 +10,13 @@ from unittest.mock import MagicMock, patch from forgelm.export import ( + GGUF_SIDECAR_SUFFIX, SUPPORTED_FORMATS, SUPPORTED_QUANTS, ExportResult, _sha256_file, _update_integrity_manifest, + _write_sha256_sidecar, export_model, ) @@ -69,6 +71,110 @@ def test_large_file_consistent(self, tmp_path): # --------------------------------------------------------------------------- +class TestSha256Sidecar: + """`forgelm export` writes `.sha256`, the file `forgelm verify-gguf` looks for (#84).""" + + @staticmethod + def _gguf(tmp_path, name="model.gguf", body=b"GGUF" + b"\x00" * 64): + path = tmp_path / name + path.write_bytes(body) + return str(path) + + def test_suffix_matches_what_verify_gguf_looks_for(self): + from forgelm.verify._gguf import _SIDECAR_SUFFIX + + assert GGUF_SIDECAR_SUFFIX == _SIDECAR_SUFFIX + + def test_sidecar_is_in_sha256sum_binary_format(self, tmp_path): + gguf = self._gguf(tmp_path) + digest = _sha256_file(gguf) + sidecar = _write_sha256_sidecar(gguf, digest) + assert sidecar == gguf + ".sha256" + assert open(sidecar, encoding="utf-8").read() == f"{digest} *model.gguf\n" + + # The fake file is not a parseable GGUF, so the metadata layer is switched off: these tests are about the sidecar, + # and must give the same answer whether or not the optional `gguf` package is installed. + _NO_METADATA = {"parsed": False, "error": None, "tensor_count": None} + + def test_verify_gguf_accepts_what_the_exporter_wrote(self, tmp_path): + from forgelm.verify._gguf import verify_gguf + + gguf = self._gguf(tmp_path) + _write_sha256_sidecar(gguf, _sha256_file(gguf)) + with patch("forgelm.verify._gguf._maybe_parse_metadata", return_value=self._NO_METADATA): + result = verify_gguf(gguf) + assert result.valid is True + assert result.checks["sidecar_present"] is True and result.checks["sidecar_match"] is True + + def test_verify_gguf_catches_a_change_after_export(self, tmp_path): + from forgelm.verify._gguf import verify_gguf + + gguf = self._gguf(tmp_path) + _write_sha256_sidecar(gguf, _sha256_file(gguf)) + with open(gguf, "ab") as fh: + fh.write(b"tampered") + with patch("forgelm.verify._gguf._maybe_parse_metadata", return_value=self._NO_METADATA): + result = verify_gguf(gguf) + assert result.valid is False and result.checks["sidecar_match"] is False + + def test_a_failed_write_leaves_no_temporary_file(self, tmp_path): + gguf = self._gguf(tmp_path) + with patch("forgelm.export.os.replace", side_effect=OSError("disk full")): + try: + _write_sha256_sidecar(gguf, "0" * 64) + except OSError: + pass + else: # pragma: no cover - the patched replace must raise + raise AssertionError("expected OSError") + assert not os.path.exists(gguf + ".sha256") and not os.path.exists(gguf + ".sha256.tmp") + + +class TestExportWritesTheSidecar: + def _run_export(self, tmp_path, quant, content=b"GGUF-mock-bytes"): + output_path = str(tmp_path / "model.gguf") + + def fake_run(cmd, **kwargs): + actual = cmd[cmd.index("--outfile") + 1] if "--outfile" in cmd else output_path + with open(actual, "wb") as f: + f.write(content) + result = MagicMock() + result.returncode, result.stderr, result.stdout = 0, "", "" + return result + + stub = MagicMock() + os.makedirs(str(tmp_path / "llama_cpp"), exist_ok=True) + stub.__file__ = str(tmp_path / "llama_cpp" / "__init__.py") + open(str(tmp_path / "llama_cpp" / "convert_hf_to_gguf.py"), "w").close() + with patch.dict(sys.modules, {"llama_cpp": stub}), patch("subprocess.run", side_effect=fake_run): + return output_path, export_model(str(tmp_path / "model"), output_path, quant=quant, update_integrity=False) + + def test_sidecar_sits_next_to_the_file_with_the_reported_digest(self, tmp_path): + output_path, result = self._run_export(tmp_path, "q8_0") + assert result.success is True and result.output_path == output_path + text = open(output_path + ".sha256", encoding="utf-8").read() + assert text == f"{result.sha256} *model.gguf\n" + + def test_k_quant_sidecar_belongs_to_the_intermediate_f16_file(self, tmp_path): + output_path, result = self._run_export(tmp_path, "q4_k_m") + assert result.success is True and result.output_path.endswith(".f16.gguf") + assert os.path.isfile(result.output_path + ".sha256") + assert not os.path.exists(output_path + ".sha256") # the requested q4_k_m file does not exist yet + + def test_a_sidecar_that_cannot_be_written_fails_the_export_loudly(self, tmp_path): + with patch("forgelm.export._write_sha256_sidecar", side_effect=OSError("read-only file system")): + _, result = self._run_export(tmp_path, "q8_0") + assert result.success is False and result.error_kind == "runtime" + assert "SHA-256 sidecar could not be written" in result.error and "read-only" in result.error + assert result.sha256 is not None # the file exists and its digest is still reported + + def test_a_manifest_that_cannot_be_updated_is_logged_as_a_warning(self, tmp_path, caplog): + (tmp_path / "model_integrity.json").write_text("{not json") + result = ExportResult(success=True, output_path=str(tmp_path / "m.gguf"), sha256="abc") + with caplog.at_level(logging.WARNING, logger="forgelm.export"): + _update_integrity_manifest(str(tmp_path), result) + assert any("Could not update model_integrity.json" in r.message for r in caplog.records) + + class TestUpdateIntegrityManifest: def test_updates_existing_manifest(self, tmp_path): integrity_path = tmp_path / "model_integrity.json"