Skip to content

fix(loader): a file named config.json must hold a config, or not exist - #75

Merged
ciresnave merged 1 commit into
mainfrom
a-file-named-config-json-must-hold-a-config
Sep 10, 2026
Merged

fix(loader): a file named config.json must hold a config, or not exist#75
ciresnave merged 1 commit into
mainfrom
a-file-named-config-json-must-hold-a-config

Conversation

@ciresnave-bot

@ciresnave-bot ciresnave-bot commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #72.

save_with_metadata — a public API — wrote raw_config to a file called
config.json unconditionally. Only the HuggingFace loader has a source config to
copy; GGUF and ONNX set raw_config to Value::Null.

Measured on a real GGUF checkpoint whose configuration MLMF had read correctly:

config.architecture = LLaMA     <- read from the file
config.vocab_size   = 49152     <- read from the file
config.json         = null      <- 4 bytes, written to disk

⚠️ A downstream reader gets null, not an error. A write path producing a
plausible artifact with no data, with the data in hand on the same struct
the severe half of the class this crate has spent its recent history removing from
the read side (#37, #43, #50, #58, #59, #62).

The file is omitted, not faked

An absent config.json says the true thing: this source format has no config
file.
metadata.json still carries what MLMF read.

⚠️ Deliberately not "serialise ModelConfig instead". That would put
MLMF's normalised view under a name that, on the HF path, means the bytes the
model shipped with
. One filename, two provenances, and a consumer unable to tell
which it holds — solving this by manufacturing #48's problem inside the field
meant to fix it.

The omission is announced on stderr rather than silent: a caller gets one file
fewer than the HF path produces, and silence about that is how someone builds a
loader that trips over the missing name later.

The second consumer: one hash shared by two whole formats

serde_json::to_string(&Value::Null) is the four characters "null", so
config_hash was identical for every GGUF and every ONNX model — a collision
by construction, in a field whose only purpose is telling two models apart.

update_hashes already takes Option<String> and the field is already Option,
so "there is no config to hash" was representable all along.

⚠️ Not replaced with a hash of ModelConfig's fields — and I checked before
designing one.
config_hash is written here, stored on ModelMetadata, and
read by nothing in this workspace. Inventing a scheme would have meant choosing
between a hand-written field list that drifts silently and a Debug string that is
unstable across compilers — trade-offs on behalf of nobody. If a consumer
appears, it can say what it needs.

Tests: two arms, one variable

Both load the same HuggingFace fixture and set raw_config to Null on one
arm. ⚠️ A GGUF fixture would have varied the format, the loader and the tensor
set at once
, and a difference in the output would not have named its cause.

sabotage config-json test hash test
write config.json unconditionally RED green
hash raw_config unconditionally green RED

Each reverts one half and hits only that half's test.

⚠️ The test caught my own wrong model of the code

My first draft asserted that a freshly-loaded model whose raw_config I had nulled
would have config_hash == None. It does notload_safetensors calls
update_model_hashes during the load, so no model reaches a test unhashed.

The surviving assertions are stronger for it: one pins that the real hash is kept
rather than overwritten
, the other names the exact value that would have
replaced it
, so a failure says which defect returned rather than only that
something changed.

Verification

cargo clippy --workspace --all-targets --all-features   exit 0
cargo fmt --all --check                                 exit 0
cargo test -p mlmf --lib                                108 passed
cargo test -p mlmf --doc                                29 passed
cargo test -p mlmf-core                                 132 passed, 18 binaries

🤖 Generated with Claude Code

https://claude.ai/code/session_01MdVuiraXRfDHQ227cjBt51

Summary by Sourcery

Ensure saved model artifacts accurately represent whether the source format provided a configuration file and configuration hash.

Bug Fixes:

  • Stop writing a misleading config.json containing null when the source format has no configuration file.
  • Avoid generating a shared configuration hash for models without source configuration by representing the hash as absent.

Enhancements:

  • Preserve source configuration files and hashes for models that provide them while continuing to include normalized model configuration in metadata.
  • Add regression coverage for configuration-file omission, metadata preservation, and prevention of null-based hash collisions.

Tests:

  • Add focused tests covering both source-config and no-source-config save and hashing behavior.

Closes #72.

`save_with_metadata` -- a PUBLIC API -- wrote `raw_config` to a file called
`config.json` unconditionally. Only the HuggingFace loader has a source config to
copy; GGUF and ONNX set `raw_config` to `Value::Null`. Measured on a real GGUF
checkpoint whose configuration MLMF had read CORRECTLY:

    config.architecture = LLaMA     <- read from the file
    config.vocab_size   = 49152     <- read from the file
    config.json         = null      <- 4 bytes, written to disk

⚠️ A downstream reader gets `null`, not an error: a write path producing a
plausible artifact with no data, with the data in hand on the same struct. That
is the severe half of the class this crate has spent its recent history removing
from the READ side (#37, #43, #50, #58, #59, #62).

## The file is now omitted, not faked

An absent `config.json` says the true thing -- this source format has no config
file -- and `metadata.json` still carries what MLMF read.

⚠️ DELIBERATELY NOT "serialise `ModelConfig` instead". That would put MLMF's
NORMALISED VIEW under a name that, on the HF path, means "the bytes the model
shipped with". One filename, two provenances, and a consumer unable to tell which
it holds -- solving this by manufacturing #48's problem inside the field meant to
fix it.

The omission is announced on stderr rather than silent: a caller gets one file
fewer than the HF path produces, and silence about that is how someone builds a
loader that trips over the missing name later.

## The second consumer: every GGUF and ONNX model shared one config_hash

`serde_json::to_string(&Value::Null)` is the four characters `"null"`, so the
hash was IDENTICAL across two entire formats -- a collision by construction in a
field whose only purpose is telling two models apart.

`update_hashes` already takes `Option<String>` and the field is already `Option`,
so "there is no config to hash" was representable all along; the code declined to
say it.

⚠️ NOT REPLACED WITH A HASH OF `ModelConfig`'S FIELDS, AND I CHECKED BEFORE
DESIGNING ONE. `config_hash` is written here, stored on `ModelMetadata`, and READ
BY NOTHING in this workspace. Inventing a hashing scheme -- and choosing between
a hand-written field list that drifts silently and a `Debug` string that is
unstable across compilers -- would have been picking trade-offs on behalf of
nobody. If a consumer appears it can say what it needs.

## Tests: two arms, ONE VARIABLE

Both tests load the SAME HuggingFace fixture and set `raw_config` to `Null` on
one arm. A GGUF fixture would have varied the format, the loader AND the tensor
set at once, and a difference in the output would not have named its cause.

  no_source_config_means_no_config_json_and_no_config_hash
  an_absent_source_config_yields_no_hash_rather_than_a_shared_one

SABOTAGES -- each reverts one half, and hits ONLY the test for that half:

  write config.json unconditionally   config-json test RED   hash test green
  hash raw_config unconditionally     config-json test green hash test RED

⚠️ AND THE TEST CAUGHT MY OWN WRONG MODEL OF THE CODE. My first draft
asserted that a freshly-loaded model whose `raw_config` I had nulled would have
`config_hash == None`. It does not: `load_safetensors` calls
`update_model_hashes` DURING the load, so no model reaches a test unhashed. The
surviving assertions are stronger for it -- one pins that the real hash is KEPT
rather than overwritten, the other names the exact value that would have replaced
it, so a failure says WHICH defect returned.

VERIFIED:
  cargo clippy --workspace --all-targets --all-features   exit 0
  cargo fmt --all --check                                 exit 0
  cargo test -p mlmf --lib                                108 passed
  cargo test -p mlmf --doc                                29 passed
  cargo test -p mlmf-core                                 132 passed, 18 binaries

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdVuiraXRfDHQ227cjBt51

@sourcery-ai sourcery-ai 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.

Sorry @ciresnave-bot, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 5 days and 7 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Sep 10, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates save_with_metadata and hash generation so formats without a source config no longer emit a misleading config.json or a collision-prone hash of JSON null, while retaining HuggingFace source-config behavior and adding focused regression tests.

Flow diagram for source-config-aware model saving

flowchart TD
    A[save_with_metadata] --> B{raw_config is null?}
    B -->|No| C[Write source config to config.json]
    B -->|Yes| D[Omit config.json]
    D --> E[Write metadata.json and tensor_mappings.json]
    C --> E
Loading

File-Level Changes

Change Details Files
Avoid fabricating source configuration artifacts for formats without a config file.
  • Skip writing config.json when raw_config is null.
  • Emit a stderr warning explaining the omission while continuing to write metadata and tensor mappings.
  • Preserve copying the source config for models that provide one.
src/loader.rs
Represent absent source configuration explicitly in metadata hashing.
  • Produce no new config hash for null raw_config instead of hashing the literal JSON null.
  • Pass the optional hash through the existing metadata update API without inventing a normalized ModelConfig hash.
src/loader.rs
Add regression coverage for both source-config output and hash behavior.
  • Use one HuggingFace fixture with raw_config toggled to isolate the behavior under test.
  • Verify config.json is preserved for source-config models and omitted, while metadata remains present, for null-config models.
  • Verify a null config does not replace an existing hash with the shared hash of "null".
src/loader.rs

Assessment against linked issues

Issue Objective Addressed Explanation
#72 Prevent save_with_metadata from writing a config.json file containing the literal JSON value null when the source format has no config file.
#72 Prevent GGUF and ONNX models from receiving the same config_hash derived from hashing the serialized null value.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity · 2 duplication

Metric Results
Complexity 0
Duplication 2

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@ciresnave
ciresnave merged commit 93e7401 into main Sep 10, 2026
6 checks passed
@ciresnave
ciresnave deleted the a-file-named-config-json-must-hold-a-config branch September 10, 2026 22:10
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.

save_with_metadata writes config.json containing 'null' for every GGUF and ONNX model

2 participants