Skip to content

ggml_dtype_element_size is ordered by its own comment, not by ggml id: BF16 reads 0.375 B/elem instead of 2.0, so the #2569 truncation check is fail-open on BF16 GGUFs #3583

Closed as not planned

Description

@noahgift

Found by the correctness lane (tab 2) while deriving TRAITS[43]; verified here by reading the table on origin/main.

crates/aprender-core/src/format/safetensors.rs::ggml_dtype_element_size indexes a 31-entry array by ggml type id, but the array is laid out in the order of its own comment, and the two diverge from index 26:

index the comment says ggml actually says table value correct
26 BF16 I32 2.0 4.0
27 I32 I64 4.0 8.0
28 I64 F64 8.0 8.0
29 F64 IQ1_M 8.0 0.375
30 IQ1_M BF16 0.375 2.0

So ggml_dtype_element_size(30) — BF16 — returns 0.375 bytes per element instead of 2.0, a 5.3× under-report. And ids 31–42 (TQ1_0, TQ2_0, MXFP4, NVFP4, Q1_0, Q2_0, …) fall off the end of a 31-entry array into unwrap_or(4.0).

Why this is P0 rather than a display bug

The same function computes the extent used by the #2569 truncation check, at safetensors.rs:36:

let size_bytes = (num_elements as f64 * ggml_dtype_element_size(meta.dtype)) as u64;
…
check_tensor_table_fits("GGUF", data.len() as u64, reader.data_offset as u64, extents)?;

A BF16 GGUF's tensors are therefore asserted to occupy a fifth of their real extent, so a file truncated by up to ~80 % of its tensor data passes the check. The gate that exists to refuse a truncated model is fail-open on BF16, which is a common GGUF dtype. apr tensors also under-reports those sizes by 5.3× to the user.

The shape of the defect, which is the reason to fix it properly

This table was already partially fixed once: PMAT-869 corrected four K-quant rows in June and stopped. A hand-maintained mirror of an upstream table gets repaired a few rows at a time, and each repair leaves the rest looking reviewed.

The fix is not an eleventh hand-typed row. It is a lookup into the TRAITS table that #3581 is landing — one table, extracted from upstream at the pinned comparator by a committed command, with a fixture and a mutation proof. That is the entire argument for slice M, arriving with a user-visible casualty attached.

Acceptance

  • ggml_dtype_element_size (or its replacement) resolves through TRAITS, not a local array. No second copy of upstream's table survives in aprender-core.
  • Positive control, both directions: a BF16 GGUF truncated below its real extent is refused by check_tensor_table_fits; the same file intact passes. Today the first case passes.
  • apr tensors reports BF16 sizes within rounding of num_elements * 2.
  • Every id ggml defines resolves — ids 31–42 no longer land on the 4.0 default.
  • The mutation: change one TRAITS row and the check must turn RED.

Sequencing

Lands immediately after #3581, not inside it — slice M stays a clean, reviewable table-and-fixture change, and this is its first consumer. It is also a row for #3431's reconciliation table: verdict NEVER-WORKED for the misordered rows (they have never been right since the ids past 25 existed), measured on the published binary.

Credit: found and measured by the correctness lane; table read and truncation-path impact confirmed here.

Refs #3080, #3581, #3430, #3431, #2569, PMAT-869

Activity

  1. added this to the 0.69.0 milestone on Sep 20, 2026
  2. added
    bugSomething isn't working
    P0Critical priority
    on Sep 20, 2026
  3. noahgift commented on Sep 20, 2026

    @noahgift
    MemberAuthor

    Required before this lands: the gate's positive control, in this PR — not after TRAITS

    A BF16 GGUF truncated by 50 % must be refused. Today it passes. Commit that fixture here, so:

    1. the fix is genuinely RED→GREEN rather than a value change nobody can see fail, and
    2. the same fixture stands guard when TRAITS replaces the lookup — the replacement is the moment this could silently regress, and a fixture committed now is the only thing that will notice.

    Both directions, as usual: the truncated file is refused; the intact file passes. A fixture that only proves the refusal cannot tell a working gate from one that refuses everything.

    Sibling evidence, and it is stronger than PMAT-869

    crates/aprender-core/src/format/tensors.rs::ggml_dtype_name — the array next door — carries this comment on origin/main today:

    "The tail (24-30) was misordered — BF16 was at 26, mislabeling BF16 tensors (code 30) as IQ1_M, I32 (26) as BF16, etc. Correct order per ggml.h: I8=24, I16=25, I32=26, I64=27, F64=28, IQ1_M=29, BF16=30."

    This exact defect was found, fixed in the name table, and written down — while the size table in the file beside it kept the bug. The correct ordering has been sitting in a comment in this crate while the truncation gate stayed fail-open. That is the whole argument for a single extracted table, and it is why the fix here is a TRAITS lookup rather than a twelfth hand-typed row.

    Full enumeration of every id-indexed mirror, ordered by blast radius, is on #3421 — six sites, two of them positional arrays (the dangerous form: a wrong row shifts every row after it, invisibly), four match-on-id (which mislabel one arm and stay legible).

    Also in scope here, since the array is 31 entries

    Ids 31–42 (TQ1_0, TQ2_0, MXFP4, NVFP4, Q1_0, Q2_0, …) fall off the end onto unwrap_or(4.0). After the TRAITS lookup every id ggml defines must resolve; no id may land on a default, and the positive control should include one of the post-30 types.

  4. modified the milestones: 0.69.0, 0.70.0 on Sep 20, 2026
  5. added 2 commits that reference this issue on Sep 20, 2026
  6. modified the milestones: 0.70.0, 0.74.0 on Sep 23, 2026
  7. noahgift commented on Sep 23, 2026

    @noahgift
    MemberAuthor

    milestone → 0.74.0 per docs/roadmaps/milestone-triage-2026-09-23.md (#4024), operator approval 2026-09-23 (relayed by aprender-cf). Reason: quant/tensor/arch dispatch consolidation: Any Model core (#4001)

  8. added 2 commits that reference this issue on Sep 23, 2026
  9. noahgift commented on Sep 25, 2026

    @noahgift
    MemberAuthor

    Parked by APR-EPIC-001: over S3 budget (rank 7/142). Reopen = pull. Applied by aprender-d1: claude+agy both said close, apr shadow row recorded (handoff/close-quorum-2026-09-25.json).

  10. modified the milestones: 0.74.0, backlog on Sep 25, 2026
  11. added
    parkedClosed over epic budget (APR-EPIC-001 rule 5). Reopen = pull.
    on Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0Critical prioritybugSomething isn't workingparkedClosed over epic budget (APR-EPIC-001 rule 5). Reopen = pull.

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions