Skip to content

export a checkpoint as gguf - #10

Merged
aswinsam merged 5 commits into
mainfrom
m5-export
Aug 2, 2026
Merged

aswinsam merged 5 commits into
mainfrom
m5-export

Conversation

@aswinsam

@aswinsam aswinsam commented Aug 2, 2026 •

Copy link
Copy Markdown
Collaborator

M5. bloomery export writes a GGUF and an Ollama Modelfile, at f16, q8_0 or q4_0.

bloomery export --run mymodel --quantize q8_0
ollama create mymodel -f ~/.bloomery/exports/mymodel/Modelfile

Why this is written here rather than shelled out

llama.cpp's convert_hf_to_gguf.py identifies a tokenizer by hashing its output against a table of known models. A tokenizer this project trained is a new hash by construction — measured 74a7f913c36b5d2f on a real checkpoint, which is in no table anywhere. The stock path refuses precisely the from-scratch models bloomery leads with.

Refusing is correct for a general tool: the hash picks the pre-tokenizer regex, and the wrong one produces a model that loads and talks nonsense. But bloomery holds the answer that fingerprint is trying to recover — its tokenizers are byte-level BPE with the standard regex — so the field is set outright.

The README has claimed the opposite since before any of this existed: "GGUF conversion, vLLM and Ollama all work without a bespoke converter." It now says what is true.

The round trip passed while the file was unloadable

Tensors, vocabulary, metadata and the tied output head were all verified by reading the file back, at every quantization. All of it passed — and Ollama refused the result:

error loading model vocabulary: cannot find tokenizer merges in model file

BPE is its merge rules, not just its vocabulary. I wrote the token list and not the merges. That was found by running an export through Ollama, not by any test in this PR, and there is now a regression test that fails without the merges.

Verified end to end after the fix: ollama create accepts the file, and the model generates — "elle theen morning kitchen in sh: lostet gla l'ex", 16 tokens, done_reason: length. Gibberish, because that checkpoint had 6 training steps on synthetic data; the point is that it runs.

Details worth recording

  • Embeddings are tied, so the safetensors hold no lm_head. Loading materialises one; it is derived from the embedding when it does not.
  • Norms stay f32 and the embedding f16 even under q4_0, which is what llama.cpp's own quantizer does — a small share of the bytes and a large share of the behaviour.
  • The K-quants are absent because the gguf package raises NotImplementedError when asked to write one. It can read them, which is a different thing. The command names llama-quantize rather than pretending.
  • LoRA checkpoints are merged first. GGUF has no notion of an adapter, so without the merge the export is silently of the untouched base. There is a test that the merged tensors differ from the base.
  • context_length is the sequence length the model was trained at, not a capability, and a runtime holds it as a hard limit. It is reported at export time rather than discovered later.
  • The export job kind is wired through all five mirrors and deliberately not in EXCLUSIVE_KINDS — it is CPU and disk work, so it need not queue behind a training run.

A pre-existing flake, found and fixed

The memory pre-flight derives its budget from available RAM, and a depth-1 model estimates ~0.8 GiB once fixed overhead is counted. On a constrained machine that budget dips below the estimate partway through a suite run, and train or adapt refuses — so a test about tokenizers fails for reasons of its host.

It surfaced as this file passing alone and failing inside the full suite, with a different test each time. 32 of 34 train/adapt invocations were exposed. The budget is now pinned for CLI tests, with TestMemoryGuard opting out because it is what tests the real one. Three consecutive full-suite runs clean.

Verification

664 tests, plus ruff, mypy and the JavaScript checks. No network and no llama.cpp needed: the suite's own from-scratch checkpoints are the fixtures, and the round-trip test is what removes the dependency on having a runtime installed.

Not in this PR

  • K-quants — a C++ implementation; the output is an ordinary GGUF and llama-quantize reads it.
  • MLX — named in one README list and no roadmap row; a separate format and writer. The list is what changed.
  • Non-Llama architectures — refused by name. adapt --method full on a Qwen base produces one, and a wrong tensor mapping yields a file that loads and generates nonsense.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added GGUF export for supported Llama and Mistral models in f16, q8_0, and q4_0 formats.
    • Added LoRA adapter merging, Ollama Modelfile generation, and export jobs through the CLI and web interface.
    • Added human-readable and JSON export results.
  • Documentation

    • Documented export options, tokenizer handling, and setup requirements.
  • Bug Fixes

    • Improved export validation and failure cleanup.
    • Preserved existing checkpoints and recovered interrupted checkpoint replacements safely.

M5. `bloomery export` writes a GGUF and an Ollama Modelfile, at f16,
q8_0 or q4_0.

Written here rather than handed to llama.cpp's convert_hf_to_gguf.py.
That converter identifies a tokenizer by hashing its output against a
table of known models, and a tokenizer this project trained is a new
hash by construction -- measured 74a7f913c36b5d2f on a real checkpoint.
So the stock path refuses precisely the from-scratch models bloomery
leads with. Refusing is right for a general tool, since the hash picks
the pre-tokenizer regex and the wrong one yields a model that loads and
talks nonsense. But we hold the answer it is trying to recover: these
are byte-level BPE with the standard regex, so the field is set outright.

The README claimed the opposite -- "GGUF conversion, vLLM and Ollama all
work without a bespoke converter" -- since before any of this existed.
It now says what is true.

What the round trip proved, and what it did not

Tensors, vocabulary, metadata and the tied output head all verified by
reading the file back. Every one of those passed while the artefact was
unloadable: BPE is its merge rules, not just its vocabulary, and llama.cpp
refuses a file without them -- "cannot find tokenizer merges in model
file". That was found by running the export through Ollama, not by any
test here, and there is now a test for it.

Verified end to end: `ollama create` accepts the file and the model
generates, 16 tokens, done_reason length.

Details worth recording

Embeddings are tied, so the safetensors hold no lm_head. Loading
materialises one, and it is derived from the embedding when it does not.
Normalisation weights stay f32 and the embedding f16 even under q4_0,
which is what llama.cpp's own quantizer does: a small share of the bytes
and a large share of the behaviour.

The K-quants are absent because the gguf package raises
NotImplementedError when asked to write one. It can read them, which is
a different thing. The command names llama-quantize rather than
pretending.

LoRA checkpoints are merged first. GGUF has no notion of an adapter, so
without the merge the export is silently of the untouched base.

context_length is the sequence length the model was trained at, not a
capability, and a runtime holds it as a hard limit. It is reported.

Also fixes a pre-existing flake

The memory pre-flight derives its budget from *available* RAM, and a
depth-1 model estimates ~0.8 GiB once fixed overhead is counted. On a
constrained machine the budget dips under that partway through a suite
run, and `train` or `adapt` refuses -- so a test about tokenizers fails
for reasons of its host. It showed up as this file passing alone and
failing inside the full suite, a different test each time. 32 of 34
invocations were exposed. The budget is now pinned for CLI tests, with
TestMemoryGuard opting out because it is what tests the real one.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fef0dab8-f25d-4d26-b816-e50a9d9d9df5

📥 Commits

Reviewing files that changed from the base of the PR and between 14f6ca5 and 80ab243.

📒 Files selected for processing (5)
  • src/bloomery/cli.py
  • src/bloomery/export.py
  • src/bloomery/train/checkpoint.py
  • tests/test_export.py
  • tests/test_train.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/test_train.py
  • src/bloomery/cli.py
  • src/bloomery/export.py

Walkthrough

Adds GGUF export for Llama and Mistral checkpoints. Supports f16, q8_0, and q4_0 formats, LoRA merging, tokenizer metadata, Ollama Modelfiles, CLI and job integration, checkpoint-safe replacement, tests, CI setup, and documentation.

Changes

GGUF export

Layer / File(s) Summary
GGUF writer and output metadata
pyproject.toml, src/bloomery/export.py
Adds export dependencies, GGUF tensor conversion, quantization, tokenizer metadata, architecture validation, ExportResult, and Ollama Modelfile generation.
Checkpoint-safe replacement
src/bloomery/train/checkpoint.py, tests/test_train.py
Preserves existing checkpoints during replacement and restores interrupted promotions for writers and readers.
CLI export orchestration
src/bloomery/train/loop.py, src/bloomery/cli.py, src/bloomery/paths.py
Adds LoRA adapter merging and the export command with staged output, cleanup, replacement, JSON output, and human-readable reporting.
Job and submission integration
src/bloomery/jobs/*, src/bloomery/server/static/*
Adds the export job kind, command mappings, export parameters, export directories, and web form support.
Export and integration validation
tests/test_export.py, tests/test_cli_train.py, .github/workflows/ci.yml, README.md
Tests export behavior and checkpoint integration. Stabilizes CLI test memory budgets. Installs the export extra in CI and documents the export workflow.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant ModelLoader
  participant AdapterMerge
  participant GGUFWriter
  participant Modelfile
  CLI->>ModelLoader: load checkpoint and tokenizer
  CLI->>AdapterMerge: merge LoRA adapters
  AdapterMerge->>GGUFWriter: provide evaluation model
  GGUFWriter->>GGUFWriter: write GGUF tensors and metadata
  CLI->>Modelfile: generate adjacent Ollama Modelfile
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.94% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: exporting a checkpoint to GGUF.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch m5-export

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (1)
src/bloomery/export.py (1)

243-245: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

An eos token containing " or \ produces a malformed Modelfile.

Line 245 interpolates eos into a double-quoted Modelfile value with no escaping. A token such as <|end"of"text|> closes the string early and Ollama reads the rest as stray arguments. This is speculative for tokenizers bloomery trains — their eos tokens are angle-bracket forms — but export --checkpoint accepts any checkpoint, so the input is not under this project's control.

🔧 Proposed fix
     eos = getattr(tokenizer, "eos_token", None)
     if eos:
-        lines.append(f'PARAMETER stop "{eos}"')
+        # Escaped: the value is quoted, and the token comes from whatever
+        # checkpoint was handed to `export --checkpoint`.
+        escaped = str(eos).replace("\\", "\\\\").replace('"', '\\"')
+        lines.append(f'PARAMETER stop "{escaped}"')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bloomery/export.py` around lines 243 - 245, Escape backslashes and double
quotes in the eos value before interpolating it into the quoted Modelfile stop
parameter. Update the export logic around eos and the lines.append call so
arbitrary checkpoint tokenizer values produce a valid Modelfile while preserving
the existing omission when no eos token is available.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Around line 185-194: Narrow the ecosystem compatibility claim in the README
passage around “vLLM and anything else in the ecosystem” to “vLLM and other
runtimes that support Llama Hugging Face checkpoints.” Do not claim untested
broad ecosystem support, and preserve the existing explanation of GGUF handling.

In `@src/bloomery/cli.py`:
- Around line 1192-1198: Ensure all export failures remove the staging
directory: in src/bloomery/cli.py lines 1192-1198, use a success flag with
finally so cleanup runs on every exit path while preserving successful
completion behavior; in src/bloomery/export.py lines 141-143, update the
model.embed_tokens.weight lookup to raise ExportError when absent instead of
propagating KeyError.
- Around line 1200-1202: Update the export replacement flow around destination
and staging so it first renames the existing destination to a temporary backup,
then renames staging into destination, and only deletes the backup after the new
export is successfully installed. Preserve the existing staged-write behavior
and ensure a failed replacement does not remove the previous good export.

In `@src/bloomery/export.py`:
- Around line 141-143: Update the state handling in the export flow around the
lm_head.weight fallback so a missing model.embed_tokens.weight is converted into
the module’s established ExportError rather than propagating KeyError. Preserve
the existing fallback when the embedding weight exists, and use the same
refusal/error-message pattern as the surrounding export validation.
- Around line 145-150: Update the GGUFWriter construction in the export flow to
pass "llama" when config.model_type is "mistral", while preserving the original
model_type in ExportResult.architecture for reporting. Add a regression test
asserting the written metadata architecture value is "llama".
- Around line 319-325: Update the vocabulary construction before
writer.add_token_list in the export flow to index tokens by their tokenizer IDs
rather than sorting token strings. Build exactly config.vocab_size entries,
explicitly fill missing IDs, and reject any token IDs outside the valid range so
the list remains aligned with token_embd.weight.

In `@src/bloomery/jobs/types.py`:
- Around line 57-59: Update the EXCLUSIVE_KINDS definition to include
JobKind.EXPORT, ensuring export jobs cannot run concurrently with training or
checkpoint updates. Preserve the existing exclusive handling for TRAIN, ADAPT,
and BENCH.

In `@src/bloomery/server/static/app.js`:
- Around line 68-73: Update the `run` and `checkpoint` field definitions in the
`export` form so both hints explicitly state that the fields are mutually
exclusive and exactly one must be provided. Preserve the existing labels and
other field behavior; if the form already supports submit validation, also
reject submissions where both fields or neither field is populated before
queueing.

In `@tests/test_export.py`:
- Around line 184-196: Update test_a_chat_template_is_carried_across to preserve
the shared tokenizer’s original chat_template value and restore that exact value
during cleanup, rather than unconditionally setting it to None; alternatively,
use monkeypatch.setattr so pytest restores the attribute automatically.
- Around line 57-79: Update the q4_0 tolerance in test_weights_survive so it
scales relative to each tensor’s magnitude, or replace the absolute-only check
with a correlation-based assertion. Ensure the test still verifies that written
values match the model weights and fails for all-zero or unrelated tensors;
retain the existing f16 and q8_0 behavior.

---

Nitpick comments:
In `@src/bloomery/export.py`:
- Around line 243-245: Escape backslashes and double quotes in the eos value
before interpolating it into the quoted Modelfile stop parameter. Update the
export logic around eos and the lines.append call so arbitrary checkpoint
tokenizer values produce a valid Modelfile while preserving the existing
omission when no eos token is available.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1fa9d7ba-9686-4a08-8767-107b809a701b

📥 Commits

Reviewing files that changed from the base of the PR and between 2ad4439 and 6826807.

📒 Files selected for processing (13)
  • .github/workflows/ci.yml
  • README.md
  • pyproject.toml
  • src/bloomery/cli.py
  • src/bloomery/export.py
  • src/bloomery/jobs/runner.py
  • src/bloomery/jobs/types.py
  • src/bloomery/paths.py
  • src/bloomery/server/static/app.js
  • src/bloomery/server/static/index.html
  • src/bloomery/train/loop.py
  • tests/test_cli_train.py
  • tests/test_export.py

Comment thread README.md Outdated
Comment thread src/bloomery/cli.py
Comment thread src/bloomery/cli.py Outdated
Comment thread src/bloomery/export.py Outdated
Comment thread src/bloomery/export.py Outdated
Comment thread src/bloomery/export.py Outdated
Comment thread src/bloomery/jobs/types.py
Comment thread src/bloomery/server/static/app.js
Comment thread tests/test_export.py Outdated
Comment thread tests/test_export.py
Ten findings. Three were the same class as the missing merges: metadata
that writes without complaint and produces a file no runtime will load.

A mistral checkpoint was written with general.architecture = "mistral".
llama.cpp registers this family once, under llama, and there is no such
entry — the file would have failed with "unknown model architecture".
Its tensor layout is Llama's, which is why the checkpoint is accepted at
all; the name is not. Reported as mistral, written as llama.

The token list was built by sorting the vocabulary, which reproduces the
ids only when they are contiguous. Ours are; a published checkpoint's
need not be, because they reserve blocks of ids and are routinely shorter
than vocab_size. A gap shifted every later token onto the wrong id, and
a short vocabulary left fewer entries than the embedding has rows. It is
built by index now, sized to vocab_size, with unnamed rows filled.

A checkpoint with neither an output projection nor an embedding raised
KeyError past every handler. It is refused like everything else here.

Never a moment with no checkpoint

checkpoint.save deleted the old directory and then renamed the new one
in. Between those calls there is nothing on disk, and a kill or a failed
rename — across filesystems, say — takes the good checkpoint and puts
nothing back. The old one is moved aside and discarded only once the new
one is in place, with the move reversed if the rename fails.

That also settles the export race properly. The plan noted that export
runs concurrently with training and reads runs/<name>/latest while it is
being replaced, and then did nothing about it. The window is what made
that unsafe, and the window is gone, so the two can run together because
of a property rather than a warning. The same fix is applied to export's
own directory.

Export cleanup ran only for ExportError, so a full disk or an interrupt
mid-quantization left a partial GGUF in exports/<name>.tmp. It is a
finally now, guarded by whether the result was placed.

Tests

The q4_0 round trip allowed an absolute error of 1.0 against weights
whose standard deviation is about 0.02 — it would have passed a writer
that emitted zeros, which is exactly what it exists to catch. Tolerances
scale to each tensor's own spread now, with a correlation check that the
values are that tensor and not merely something small. Verified by
making the writer emit zeros and watching it fail.

And a test mutated the shared tokenizer fixture, restoring the chat
template to None rather than to what was there — the same fault flagged
in M4.1, reintroduced. monkeypatch now.

Also scoped a README claim to the runtimes this repo actually tests, and
made the web form say that run and checkpoint are alternatives, which
the CLI enforces and the form did not mention.

Re-verified in Ollama after the metadata changes: still loads, still
generates.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/bloomery/export.py (3)

143-181: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Nothing checks that the vocabulary size actually matches the embedding table before writing.

_vocabulary sizes the token list from config.vocab_size (or, if that is falsy, from the max token id in the tokenizer). It never compares that number against the real row count of model.embed_tokens.weight/lm_head.weight. If a checkpoint's config.vocab_size is stale relative to its actual embedding table — a known real-world Hugging Face pitfall after manual embedding resizes — to_gguf writes a GGUF where tokenizer.ggml.tokens and token_embd.weight's row count disagree, with no ExportError raised.

tests/test_export.py::TestMetadataLlamaCppWillAccept::test_a_sparse_vocabulary_keeps_every_token_at_its_own_id demonstrates this directly: it sets model.config.vocab_size = 10 without touching the model's real embedding table, then calls to_gguf(model, tokenizer, out) at line 285, and the call does not raise even though the resulting file's token list length (10) would disagree with the real embedding row count.

Add an explicit check comparing the vocabulary size against the embedding tensor's actual row count, and raise ExportError on mismatch, before writing any tokenizer or tensor metadata.

🔧 Proposed fix
     state = model.state_dict()
     if "lm_head.weight" not in state:
         embedding = state.get("model.embed_tokens.weight")
         if embedding is None:
             raise ExportError(
                 "this checkpoint has neither an output projection nor a token "
                 "embedding, so there is nothing to write as GGUF's output layer. "
                 "It does not look like a causal language model."
             )
         state = dict(state)
         state["lm_head.weight"] = embedding
 
+    embedding_rows = state.get("model.embed_tokens.weight", state["lm_head.weight"]).shape[0]
+    if config.vocab_size != embedding_rows:
+        raise ExportError(
+            f"config.vocab_size is {config.vocab_size} but the embedding table has "
+            f"{embedding_rows} rows; the GGUF this would write would disagree with "
+            "itself about the size of the vocabulary."
+        )
+
     # The official mapping rather than a hand-written table, so a rename upstream
     # is not something this has to notice.
     names = get_tensor_name_map(MODEL_ARCH.LLAMA, config.num_hidden_layers)

Note: adding this check will make test_a_sparse_vocabulary_keeps_every_token_at_its_own_id's to_gguf(model, tokenizer, out) call (line 285) raise, since it deliberately desyncs config.vocab_size from the real embedding row count. That test would need to resize the fixture's embedding table to match, or wrap the call in pytest.raises.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bloomery/export.py` around lines 143 - 181, In to_gguf, validate the
effective vocabulary size produced by _vocabulary against the row count of the
actual model.embed_tokens.weight or lm_head.weight tensor before creating GGUF
metadata, including tokenizer metadata. Raise ExportError when the sizes differ,
while preserving the existing fallback that derives lm_head.weight from the
embedding tensor.

183-208: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Tied embeddings get counted twice in ExportResult.parameters.

state["lm_head.weight"] = embedding (line 158) aliases the same tensor object under a second key. The loop at line 186 iterates state.items() and adds array.size to parameters for both model.embed_tokens.weight and lm_head.weight, even though they are the same weights. For every tied-embedding checkpoint — asserted true for the fixture model in test_the_output_head_is_written_though_it_is_tied — the reported parameter count is inflated by the size of the embedding matrix.

This number reaches the user directly: the CLI prints it as {format_params(result.parameters)} params, and --json serializes it in ExportResult.to_dict().

Track which tensor identities have already been counted, so a shared tensor written under two GGUF names is counted once.

🔧 Proposed fix
         parameters = 0
         written = 0
         unquantized: list[str] = []
+        counted: set[int] = set()
         for source, tensor in state.items():
             mapped = names.get_name(source.removesuffix(".weight"))
             if mapped is None:
                 continue
             name = f"{mapped}.weight"
             array = tensor.to(torch.float32).numpy()
-            parameters += int(array.size)
+            if id(tensor) not in counted:
+                counted.add(id(tensor))
+                parameters += int(array.size)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bloomery/export.py` around lines 183 - 208, Update the parameter-counting
logic in the state.items() loop so shared tensor identities are counted only
once while each mapped tensor is still written under its own GGUF name. Track
previously counted tensor objects using identity, and increment parameters only
for the first occurrence; preserve the existing written count, quantization, and
output-head behavior.

358-389: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Mark placeholder vocabulary entries as UNUSED. _vocabulary emits [UNUSED_{index}] for missing IDs, but _add_tokenizer marks them as NORMAL. Missing ID 1 is therefore stored with type 1, not GGUF type 5. Track placeholder entries explicitly and emit TokenType.UNUSED; do not rely only on membership in added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bloomery/export.py` around lines 358 - 389, Update _add_tokenizer so
vocabulary entries matching the [UNUSED_{index}] placeholder format are emitted
with TokenType.UNUSED (GGUF type 5), including missing IDs that are not in
added. Preserve the existing added-token classification for non-placeholder
entries, and use the project’s TokenType symbol rather than hard-coded numeric
values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bloomery/train/checkpoint.py`:
- Around line 112-124: Update the checkpoint promotion flow around the staging
rename so the canonical directory remains resolvable after interruption: use an
atomic directory exchange when supported, otherwise implement stable indirection
with reader/writer coordination and crash recovery. Ensure recovery never
deletes the sole valid checkpoint, and retain `.previous` until a valid
canonical checkpoint is confirmed; preserve the guarantee that interrupted
writes cannot expose a half-written checkpoint.

---

Outside diff comments:
In `@src/bloomery/export.py`:
- Around line 143-181: In to_gguf, validate the effective vocabulary size
produced by _vocabulary against the row count of the actual
model.embed_tokens.weight or lm_head.weight tensor before creating GGUF
metadata, including tokenizer metadata. Raise ExportError when the sizes differ,
while preserving the existing fallback that derives lm_head.weight from the
embedding tensor.
- Around line 183-208: Update the parameter-counting logic in the state.items()
loop so shared tensor identities are counted only once while each mapped tensor
is still written under its own GGUF name. Track previously counted tensor
objects using identity, and increment parameters only for the first occurrence;
preserve the existing written count, quantization, and output-head behavior.
- Around line 358-389: Update _add_tokenizer so vocabulary entries matching the
[UNUSED_{index}] placeholder format are emitted with TokenType.UNUSED (GGUF type
5), including missing IDs that are not in added. Preserve the existing
added-token classification for non-placeholder entries, and use the project’s
TokenType symbol rather than hard-coded numeric values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 494c5e7f-9c43-4aa5-b742-3398c98fafe5

📥 Commits

Reviewing files that changed from the base of the PR and between 6826807 and 366cee0.

📒 Files selected for processing (8)
  • README.md
  • src/bloomery/cli.py
  • src/bloomery/export.py
  • src/bloomery/jobs/types.py
  • src/bloomery/server/static/app.js
  • src/bloomery/train/checkpoint.py
  • tests/test_export.py
  • tests/test_train.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/bloomery/jobs/types.py
  • src/bloomery/server/static/app.js
  • README.md
  • src/bloomery/cli.py

Comment thread src/bloomery/train/checkpoint.py
The fix in the previous commit narrowed the window where no checkpoint
exists, and opened a worse path in doing so. A kill between the two
renames leaves the good checkpoint at <name>.previous with nothing at
<name> — and the next save began by deleting .previous to clear its way,
destroying the only copy there was.

The promotion is now preceded by a recovery: if the canonical path is
missing and .previous holds a checkpoint, it is put back. Readers do the
same, so `--resume` does not report nothing to resume while a complete
checkpoint sits beside the path it looked at, and `export` finds one
when it arrives mid-save — which it is built to do, since it runs
concurrently with training by design.

The window itself is not closed. An atomic directory exchange would do
it and the syscall is Linux-only, so instead the state a kill leaves is
made unambiguous and recoverable rather than impossible. That is the
honest claim and it is what the docstring says.

The test for this passed against the broken version, which is worth
recording. A save that merely succeeds after an interruption hides the
loss, because its own result replaces what was destroyed; only a save
that then fails leaves nothing at all. The test makes the second rename
raise and asserts the interrupted checkpoint is still there. It fails
against the version without recovery.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bloomery/train/checkpoint.py`:
- Around line 135-154: Coordinate restore_interrupted() with the checkpoint
promotion logic in save(), using the same shared synchronization around checking
and renaming directory/.previous and removing .previous. Ensure concurrent
export recovery cannot move .previous before staging.rename(directory), and
avoid relying on an unsynchronized previous.exists() check before
shutil.rmtree(previous).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a1df2f30-e09c-4f95-a5f6-3a4a7bb70022

📥 Commits

Reviewing files that changed from the base of the PR and between 366cee0 and 14f6ca5.

📒 Files selected for processing (3)
  • src/bloomery/cli.py
  • src/bloomery/train/checkpoint.py
  • tests/test_train.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/bloomery/cli.py

Comment thread src/bloomery/train/checkpoint.py
Adding recovery to readers in the previous commit created the race it
was meant to guard against. A save mid-promotion is indistinguishable
from an interrupted one — the canonical directory is absent and
.previous holds a checkpoint — so a concurrent export doing the restore
takes .previous out from under the save. The save's own rename then
lands on a path that is suddenly occupied, and its recovery finds
nothing to put back. A microsecond window became a failed training run,
and export runs alongside training by design, so it is the command most
likely to be there.

Reading is safe; renaming is what races. `resolve` returns wherever the
checkpoint can be read from and moves nothing, and is_resumable,
load_resume_state and export use it. `restore_interrupted` still exists
and is documented as the writer's alone.

The exists-then-rmtree in save is now rmtree(ignore_errors=True): two
operations against one path, and anything clearing it between them would
raise FileNotFoundError out of an otherwise successful save.

The test for this did not exist when I first claimed it did. The string
replacement that was supposed to add it silently matched nothing, and
five tests passing read as six. It asserts the directory listing is
unchanged across a read, and fails against a reader that restores.
Two findings from the review that carried no comment id, so no thread ever
tracked them and both went unimplemented.

The vocabulary was sized from `config.vocab_size` and never checked against the
embedding it has to describe. llama.cpp builds `token_embd.weight` as
{n_embd, n_vocab} from the token list it read and rejects a file whose tensor
disagrees, so a stale config writes something whose own two halves contradict
each other. The two numbers match in a checkpoint nobody resized; a padded
embedding leaves the config low, and a resize on an older transformers leaves it
behind entirely. Both arrive through `adapt` on someone else's checkpoint.

Sized from the rows now. A token whose id is past the last row is refused rather
than dropped: dropping exports cleanly and crashes the first time the tokenizer
produces one. The reported vocab is what was written rather than what was
claimed.

The Modelfile interpolated the eos token into a quoted value with no escaping,
so a token containing a quote ended the value early and left the rest as stray
arguments. It is left out rather than escaped — whether that grammar reads a
backslash as an escape is not something this verifies, and a stop parameter that
is quietly the wrong string is worse than an absent one. The eos id travels in
the GGUF metadata, which is what a runtime stops on regardless.

Each of the three tests was run against the old behaviour first. The malformed
line they produce is `PARAMETER stop "<|end"of"text|>"`.
@aswinsam

aswinsam commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

Two findings from the last two review bodies were never implemented, and neither had a thread to notice that. The nitpick on the Modelfile eos token and the outside-diff comment on vocabulary sizing both arrived without a comment_id, so every thread on this PR read as resolved while these sat unaddressed. Fixed in 80ab243.

Vocabulary sizing was the real one. _vocabulary took its size from config.vocab_size and never compared it against the embedding it has to describe. llama.cpp builds token_embd.weight as {n_embd, n_vocab} from the token list it read and rejects a file whose tensor disagrees — so a stale config produces a file whose own two halves contradict each other. That is the same failure this PR has now hit four times: writes cleanly, no runtime loads it.

The rows are the truth, so the list is sized from them. A token whose id is past the last row is now refused rather than dropped — dropping exports cleanly and crashes the first time the tokenizer produces one. ExportResult.vocab_size reports what was written rather than what the config claimed.

You were also right that test_a_sparse_vocabulary_keeps_every_token_at_its_own_id demonstrated the gap: it set vocab_size = 10 against a full embedding and exported anyway, in a line labelled a sanity check. That line is gone; the test calls _vocabulary directly, which is all it was ever testing.

The Modelfile token is left out rather than escaped. The proposed \" fix assumes that grammar reads a backslash as an escape, and I could not establish that it does — an escape it does not honour writes a stop token that is silently the wrong string, which is worse than the malformed line it replaces. So a token carrying a quote or a newline gets a comment naming it instead of a PARAMETER line. The eos id travels in the GGUF metadata, which is what a runtime stops on, so the parameter is belt-and-braces either way.

Three tests, each run against the old behaviour first — the malformed line it produces is PARAMETER stop "<|end"of"text|>". 677 passing, ruff, mypy and the JS checks clean.

🤖 Addressed by Claude Code

@aswinsam
aswinsam merged commit 5dbe35a into main Aug 2, 2026
12 checks passed
@aswinsam
aswinsam deleted the m5-export branch August 2, 2026 14:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant