Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ All notable changes to ForgeLM are documented here.

### Added

- `forgelm export` now writes `<output>.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
Expand Down Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/verify_gguf_subcommand-tr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** | `<path>.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ı `<hex> *<filename>` 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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/verify_gguf_subcommand.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<path>.sha256` exists. Recomputes file SHA-256 and compares against the sidecar's first whitespace-separated token (sha256sum format `<hex> *<filename>` 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

Expand Down Expand Up @@ -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
Expand Down
72 changes: 44 additions & 28 deletions docs/usermanuals/en/deployment/gguf-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<digest> *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

Expand All @@ -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 `<file>.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

Expand Down Expand Up @@ -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 <adapter-dir>`; 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
Expand Down
8 changes: 4 additions & 4 deletions docs/usermanuals/en/deployment/verify-gguf.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<path>.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 `<path>.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.
Expand Down Expand Up @@ -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 `<path>.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

Expand All @@ -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
Expand Down
Loading
Loading