Skip to content

Steering: read GLP (GGUF Layer Projection) vectors - #970

Open
msuiche wants to merge 9 commits into
antirez:mainfrom
msuiche:glp-steering
Open

Steering: read GLP (GGUF Layer Projection) vectors#970
msuiche wants to merge 9 commits into
antirez:mainfrom
msuiche:glp-steering

Conversation

@msuiche

@msuiche msuiche commented Sep 4, 2026

Copy link
Copy Markdown

--dir-steering-file already performs directional ablation:

y = y - scale * v * dot(v, y)

What it lacked was a container. The raw .f32 form is a headerless blob of n_layers * n_embd floats, which is fine on the machine that produced it and unsafe to hand to anyone else, because it cannot state four things that are each silently wrong when they are wrong.

The operation. llama.cpp has shipped control vectors since 2024 with the tensor convention this reuses exactly — direction.<N>, fp32, 1-D — but llama.cpp adds them (h <- h + v) where ds4 projects. A file loads into either runtime with the right names, the right dtype and the right shapes, raises no error, and produces wrong output in one of them: an additive apply of a projective direction pushes every token along the direction instead of removing it.

The hook point. ds4 steers the block writers — ffn_out = moe + shared, or the attention output — before the residual / hyper-connection fold. llama.cpp's build_cvec() steers the post-layer residual. Measured on the same direction, layers and alpha, the writer site left 34.0% refusal against 3.8% post-layer: 9x weaker, and not an error.

The layer map. direction.N applies at layer N, no offset. A one-layer shift does not fail, it degrades — adjacent layers' refusal directions have cosine similarity 0.555–0.979 — so it survives a smoke test. This cost a real exporter bug on our side, caught only by a differential layer probe.

The base checkpoint. A direction is tied to the revision it was derived from, and the shapes still match on a different one.

GLP (GGUF Layer Projection) is standard GGUF v3 with llama.cpp's tensor convention unchanged, plus a glp.* metadata block that states all four. --dir-steering-file now reads it and refuses rather than misapplies: wrong operation, a hook the file was not calibrated for, tensor names disagreeing with glp.layer_ids_zero_based, rank > 1, a non-F32 direction, or a width that is not this model's n_embd. Spec: weightless/spec/GLP.md.

No inference-path change

This replaces the three call sites that read the steering file. The projection, the graphs and the kernels are untouched, and the raw .f32 path is byte-identical — the format is chosen by sniffing the GGUF magic, not by a flag, so every existing vector and build_direction.py output keeps working with no change to any command line.

What's new

ds4_glp.{c,h} Self-contained GGUF v3 reader plus the spec checks. Its own parse rather than ds4.c's, which is static, tied to ds4_model and fatal on malformed input, where a steering path has to be able to report a refusal and continue.
--dir-steering-info FILE Print mode, alpha, coverage, resolved layer ids, per-direction norms and provenance without loading a model. The first thing to reach for when a vector behaves oddly — our own exporter off-by-one surfaced as a dump reporting layers 11–39 for a file derived from 10–38. Deliberately describes files ds4 would refuse to apply.
--dir-steering-allow-hook-mismatch Apply a vector calibrated elsewhere, with the scale re-tuned. Off by default.
glp.alpha_default Becomes the default --dir-steering-ffn when the file's hook matches the site being steered, so a published vector runs at its calibrated strength with no flags. Never adopted across a hook mismatch — projection is quadratic in the norm, so an alpha from elsewhere is not a better default than 1.0, only a more confident wrong one. An explicit flag always wins.
dir-steering/tools/f32_to_glp.py Package a build_direction.py vector as GLP, reading the sidecar JSON so the shape and the hook point cannot disagree with what was measured. Unit-normalises, hashes tensor bytes only (glp.created makes the file non-reproducible), and reads the result back to assert the layer ids landed verbatim. Stdlib only.
tests/test_glp.c 72 checks over real GGUF files written to disk, one per refusal. Wired into make test and test-rocm.

The three CLIs share one --dir-steering-info and one default-scale resolver, so ds4, ds4-server and ds4-agent cannot drift and the help text advertises nothing that is missing from one of them.

Two limitations, stated up front

Layer 0 is not expressible. direction.N applies at layer N and direction.0 is invalid, so a full-coverage raw vector converts with --layers 1-<last> and loses row 0. Vectors derived over a mid-stack range are unaffected, and coverage dominates strength here (6 layers 18%, 16 layers 3.8%, 29 layers 0.0% on our refusal suites, with alpha saturating above about 4), so losing the first of 43 layers is not what decides a vector.

No residual_stream_post_layer hook. Every vector published for llama.cpp and vLLM declares it, so those need --dir-steering-allow-hook-mismatch and a re-tuned scale until ds4 grows that site. The enum and the refusal message already name it; adding the hook is a separate change that touches the trunk in every backend and wants hardware to validate.

Testing

Per CONTRIBUTING: M-series macOS, Metal and CPU backends. None of the new paths need a model.

make && make cpu                       clean, no new warnings
./tests/test_glp                       72 checks, 0 failed

test_layer_pack, test_engine_mgpu_placement, test_gpu_args, test_gpu_args_cli.sh, test_prompt_prefix, test_sampling, test_deepseek4_vision_image, test_q4k_dot, test_mxfp4_dot, ds4_agent_test, ds4-eval --self-test-extractors — all pass.

End to end on a real ds4 vector: f32_to_glp.py on dir-steering/out/verbosity.f32 (43×4096, the bundled verbosity direction) → GLP, read back by all three binaries via --dir-steering-info, and alpha_default picked up as --dir-steering-ffn.

No speed regression is possible: the change is confined to file loading, and ds4-bench measures the graph, which is untouched.

The model-backed ds4_test suites (--logprob-vectors, --long-context, --tool-call-quality, --metal-kernels) were not run — no checkpoint on this machine. They exercise no code this change touches.

Why upstream

The steering feature is the part of ds4 people most want to hand each other, and today they can only hand each other a blob plus a verbal explanation of how to apply it. The op ds4 implements is already the right one; this just lets a vector say so, so that the failure modes above become refusals instead of plausible degraded output.

--dir-steering-file already performs directional ablation:

    y = y - scale * v * dot(v, y)

What it lacked was a container. The raw ".f32" form is a headerless blob
of n_layers * n_embd floats, and it cannot state four things that are each
silently wrong when they are wrong:

The operation. llama.cpp has shipped control vectors since 2024 with the
tensor convention this reuses exactly -- direction.<N>, fp32, 1-D -- but
llama.cpp ADDS them: h <- h + v. ds4 PROJECTS. A file loads into either
runtime with the right names, dtype and shapes, raises no error, and is
wrong in one of them: an additive apply of a projective direction pushes
every token along the direction instead of removing it.

The hook point. ds4 steers the block writers -- ffn_out = moe + shared, or
the attention output -- before the residual / hyper-connection fold.
llama.cpp's build_cvec() steers the post-layer residual. Measured on the
same direction, layers and alpha, the writer site left 34.0% refusal
against 3.8% post-layer: 9x weaker, and not an error.

The layer map. direction.N applies at layer N, no offset. A one-layer
shift does not fail, it degrades -- adjacent layers' refusal directions
have cosine similarity 0.555-0.979 -- so it survives a smoke test. This
cost a real exporter bug, caught only by a differential layer probe.

The base checkpoint. A direction is tied to the revision it was derived
from; the shapes still match on a different one.

GLP is standard GGUF v3 with llama.cpp's tensor convention unchanged plus
a glp.* block that states all four. ds4 now reads it and refuses rather
than misapplies: wrong operation, a hook the file was not calibrated for,
tensor names disagreeing with glp.layer_ids_zero_based, rank > 1, a
non-F32 direction, or a width that is not this model's n_embd. Spec:
github.com/msuiche/weightless/blob/main/spec/GLP.md

No inference-path change. This only replaces the three call sites that
read the steering file; the projection, the graphs and the kernels are
untouched, and the raw .f32 path is byte-identical -- the format is chosen
by sniffing the GGUF magic, not by a flag, so every existing vector and
build_direction.py output keeps working.

New:
  ds4_glp.{c,h}  self-contained GGUF v3 reader and spec checks. Its own
                 parse rather than ds4.c's, which is static, tied to
                 ds4_model and fatal on malformed input, where a steering
                 path must report a refusal.
  --dir-steering-info FILE
                 print mode, alpha, coverage, resolved layer ids, norms
                 and provenance without loading a model. The first thing
                 to reach for when a vector behaves oddly -- our exporter
                 off-by-one surfaced as a dump reporting layers 11-39 for
                 a file derived from 10-38. Also describes files ds4 would
                 refuse to apply.
  --dir-steering-allow-hook-mismatch
                 apply a vector calibrated elsewhere, with the scale
                 re-tuned. Off by default.
  glp.alpha_default
                 becomes the default --dir-steering-ffn when the file's
                 hook matches the site being steered, so a published
                 vector runs at its calibrated strength with no flags.
                 Never adopted across a hook mismatch: projection is
                 quadratic in the norm, so an alpha from elsewhere is not
                 a better default than 1.0, only a more confident wrong
                 one. An explicit flag always wins.
  dir-steering/tools/f32_to_glp.py
                 package a build_direction.py vector as GLP, reading the
                 sidecar JSON so shape and hook cannot disagree with what
                 was measured. Unit-normalises, hashes tensor bytes only
                 (glp.created makes the file non-reproducible), and reads
                 the result back to assert the layer ids landed verbatim.
                 Stdlib only.
  tests/test_glp.c
                 72 checks over real GGUF files on disk, one per refusal:
                 missing mode, mode=add, hook mismatch and its override,
                 unknown hook, layer-id disagreement, direction.0, non-F32,
                 rank>1, width and layer-count mismatch, off-unit and zero
                 directions, the pre-GLP dspark.* namespace, truncated
                 headers, GGUF v2. Wired into make test and test-rocm.

Layer 0 is not expressible: direction.N applies at layer N and
direction.0 is invalid, so a full-coverage raw vector converts with
--layers 1-<last> and loses row 0. Vectors over a mid-stack range are
unaffected, and coverage dominates strength here (6 layers 18%, 16 layers
3.8%, 29 layers 0.0% on our refusal suites, alpha saturating above ~4).

Not implemented: a residual_stream_post_layer hook. Vectors published for
llama.cpp and vLLM declare it, so they need
--dir-steering-allow-hook-mismatch and a re-tuned scale until ds4 grows
that site. The enum and the refusal already name it.

Tested on M-series macOS, Metal and CPU backends, no model required by any
of the new paths:
  make && make cpu                            clean, no new warnings
  ./tests/test_glp                            72 checks, 0 failed
  test_layer_pack, test_engine_mgpu_placement, test_gpu_args,
  test_gpu_args_cli.sh, test_prompt_prefix, test_sampling,
  test_deepseek4_vision_image, test_q4k_dot, test_mxfp4_dot,
  ds4_agent_test, ds4-eval --self-test-extractors     all pass
  f32_to_glp.py on dir-steering/out/verbosity.f32 (43x4096) -> GLP,
  read back by ./ds4, ./ds4-server and ./ds4-agent --dir-steering-info,
  and alpha_default picked up as --dir-steering-ffn
The model-backed ds4_test suites (--logprob-vectors, --long-context,
--tool-call-quality, --metal-kernels) were not run: no checkpoint on this
machine. They exercise no code this change touches.
Parse glp.derived_at (where the direction was captured, vs hook_point
where it is applied). A divergence marks a transferred vector: warn on
load, mark TRANSFERRED in --dir-steering-info, never refuse — the reader
still applies at hook_point and alpha_default belongs to the apply site.
test_glp: +11 checks (native vs transferred fixtures, info markers).
The hook-point refusal cited "9x weaker (34.0% vs 3.8% refusal at identical
direction, layers and alpha)" as though it measured ds4's hook. It does not,
and the gap matters more than the number does.

What weightless REFUSAL.md 3k (2026-08-13) measured, at equal coverage
(16 layers) and equal alpha, was the ATTENTION write against the folded
residual: 3.8% vs 34.0% on cyber100. That is a real application-point result
— 3k states it as one — and the weight-edit form it was run in is
numerically the activation projection of that matmul's output, verified in
3x to 1.4e-06, float32 roundoff. So the comparison stands.

The problem is which contributor it measured. ds4's default hook is the
FFN/MoE write, a THIRD site that has never been measured — in this project
or in the published record. The write-vs-explain contrast these directions
come from is a content decision, and content is largely MoE-mediated on this
architecture, so the FFN write plausibly carries far more of each layer's
refusal contribution than the attention write does. Quoting an
attention-writer number as if it bounded the FFN writer is the kind of
transfer this whole format exists to stop.

Second caveat, from the source: the 34.0% came from a retired binary scorer
that did not store completions, so it cannot be re-scored.

So the refusal now argues from the mechanism, which needs no contested
figure: a writer holds only what that block computed this layer, while the
folded residual holds the accumulated sum, so projecting it also removes
what every upstream layer contributed. Steering a writer prevents refusal
being added; steering the residual deletes refusal already there. Different
interventions, and neither dose transfers. The figure stays in ds4_glp.h with
both caveats attached, where a reviewer can weigh it.

Also: the refusal message now leads with the remedy and follows with the
rationale. It is written into a caller-sized buffer, and a truncation should
cost the explanation, not the fix — the previous ordering pushed
--dir-steering-allow-hook-mismatch past the end of a 512-byte buffer, which
the tests caught only after a forced relink. The tests now use the 768-byte
buffer ds4.c actually passes, so that class of truncation is exercised
rather than assumed.

No behaviour change: same refusals, same overrides, same shapes. Comments,
messages and docs only.

make && make cpu clean, no new warnings. tests/test_glp 83 checks 0 failed;
test_layer_pack, test_engine_mgpu_placement, test_gpu_args,
test_gpu_args_cli.sh, test_prompt_prefix, test_sampling,
test_deepseek4_vision_image, test_q4k_dot, test_mxfp4_dot, ds4_agent_test,
ds4-eval --self-test-extractors all pass. Re-verified against the two
published GLP-29 vectors: both still refused by default with the new
message, both still load under the override at 29 directions, L10-38.
The header explained the untested FFN site by asserting that the
write-vs-explain contrast is a content decision and that content is
largely MoE-mediated on this architecture. Nothing measured either
half of that. It was my reasoning for why the FFN writer might behave
unlike the attention writer, and it does not belong in a comment that
a reader will take as a finding.

The honest statement is narrower and is what remains: the measured
comparison covered the attention contributor, ds4's default hook is the
FFN/MoE write, that site has never been measured, and so the figure
bounds it in neither direction. The mechanism argument stands on its
own and needs no conjecture.

tests/test_glp 83 checks 0 failed; make clean.
ds4's steering projected only the block writers -- ffn_out = moe + shared,
or the attention output -- before the hyper-connection fold. GLP vectors
published for llama.cpp and vLLM declare residual_stream_post_layer, the
post-layer residual, so they loaded only behind
--dir-steering-allow-hook-mismatch with a scale that had to be re-tuned.

This adds the third site: after the FFN fold, out_hc is the accumulated
residual, and each of the n_hc hyper-connection streams is projected
independently against the layer's direction --

    y = y - scale * v * dot(v, y)     per stream

No new kernel anywhere. The CPU path reuses
cpu_directional_steering_project_rows with rows = n_hc (out_hc is
[n_tok][n_hc][n_embd] contiguous); the graph path reuses
ds4_gpu_directional_steering_project_tensor, which already takes rows, so
Metal, CUDA and ROCm share the one host-side change. A residual-calibrated
GLP now loads with no flags: glp.alpha_default becomes the default
--dir-steering-resid when the file's hook matches, and only then. A hook
mismatch still refuses, and the message names the flag for the site the
file declares; an unknown hook name still refuses, as does mode=add.

Sites:
  CPU      layer_ffn_one and layer_ffn_one_decode_scratch (decode),
           layer_ffn_batch and layer_ffn_shared_batch (prefill),
           layer_ffn_tokens_parallel
  graph    the three metal_graph_encode_decode_layer_phase fold exits,
           metal_graph_encode_layer_ffn_batch (prefill), and the glm53
           folds (decode tail, both batch prefills, native session batch)
  CUDA/ROCm  same host code; their kernels already take rows

Two opt-outs, both matching what the writer hooks already do. The
deferred HC expand (M5 TP) writes after_ffn_hc only when flushed after
the layer returns -- too late for the projection -- so resid steering
takes the eager fold. The CUDA decode-graph capture of the GLM FFN tail
is skipped, because a replay bakes the scale; ffn steering already does
the same. The multi-session batch folds (native, pipeline, mixed) decline
resid-steered sessions, which take the per-session path, as they do for
ffn steering.

Capture ordering note: DSpark's target-hidden taps read cur_hc at the top
of a layer, the previous layer's post-fold stack, so under resid steering
they see steered state -- the same ordering the writer hooks already
produce, where steered ffn_out is folded into the stack the taps read.
Preserving pre-steering taps would be a separate change.

tests/test_glp.c: +11 checks -- a residual-hooked file loads at the resid
target with no override, its alpha_default is adopted by the resid default
and not the ffn one (and vice versa), and the mismatch refusal names
--dir-steering-resid. tests/test_dir_steering.c: model-free numeric check
of the projection at the new site through the DS4_TEST_HOOKS wrapper -- a
zero direction row is an exact no-op (layers a file does not cover stay
unsteered), a nonzero direction removes scale * dot(v, x) * v from each
stream independently, matching the hand computation, and a second scale-1
application is a no-op. Both wired into make test and test-rocm.

Tested on M-series macOS, no model required by the new tests:
  make && make cpu                    clean, no new warnings
  ./tests/test_glp                    94 checks, 0 failed
  ./tests/test_dir_steering           OK
  test_layer_pack, test_engine_mgpu_placement, test_gpu_args,
  test_gpu_args_cli.sh, test_prompt_prefix, test_sampling,
  test_deepseek4_vision_image, ds4_agent_test,
  ds4-eval --self-test-extractors     all pass
  ./ds4 --dir-steering-info on a residual-hooked fixture parses, and the
  CLI adopts its alpha_default as --dir-steering-resid with no scale flag
The model-backed ds4_test suites were not run (no checkpoint on this
machine). CUDA and ROCm were not compiled here (no toolkit on macOS); the
change to their path is host-side and shared with Metal, but both should
be built on a real target before this goes upstream.
Six findings from review of the GLP reader and the resid-hook commit:

- Non-finite values were accepted. A NaN or Inf direction, or a NaN
  glp.alpha_default, now refuse at load. The checks run on the serialized
  bytes as integers: this file builds with -ffast-math, and clang folds
  even memcpy/bit-test NaN checks on float-typed values away under
  -ffinite-math-only (verified with a minimal repro), so the value must
  never get a float type before the test.
- The layer-map cross-check compared only count/min/max. It now sorts and
  compares the exact layer-id sets, so a duplicate direction.<N> (which
  would load twice, the second silently overwriting the first row of the
  dense buffer) and a declared list differing only in the interior both
  refuse.
- alpha_default=0 was indistinguishable from an absent alpha_default in
  the CLI default path and fell through to the FFN default of 1. A file
  that declares 0 means "no steering by default"; the default-scale
  helpers now report adoption separately from the value, and an adopted 0
  is honored as the zero-scale no-op it is.
- Direction tensor offsets were bounds-checked but not float-aligned
  before direct mapped float reads; a misaligned offset now refuses.
- Header counts were bounded at one byte per entry, so a small file could
  still amplify itself into multi-GB callocs. Counts must now fit the
  minimum serialized entry sizes (13 bytes per KV, 32 per tensor
  directory entry) and refuse otherwise, before allocating.
- g_steering_provenance_logged raced when ds4-server creates sessions
  from worker threads. It is now an atomic_bool with explicit ordering,
  matching how ds4_tp.c handles shared flags.

tests/test_glp.c: 94 -> 118 checks: NaN/Inf direction, NaN alpha_default,
interior layer-list mismatch, duplicate direction.<N>, misaligned tensor
offset, amplified header counts, alpha_default=0 adopted as a value,
absent alpha_default adopts nothing. tests/test_dir_steering.c: a zero
scale does not even read the direction (NaN row, bit-identical output) --
that is what makes honoring alpha_default=0 safe.

Tested: make && make cpu clean, no new warnings; test_glp 118/0;
test_dir_steering, test_layer_pack, test_engine_mgpu_placement,
test_gpu_args, test_gpu_args_cli.sh, test_prompt_prefix, test_sampling,
test_deepseek4_vision_image, ds4_agent_test, ds4-eval
--self-test-extractors all pass. CLI check: a residual-hooked file with
alpha_default=0 logs "--dir-steering-resid defaulted to 0" and does not
fall through to the FFN default.
New data, measured today on DeepSeek-V4-Flash-0731 (NVFP4, vLLM overlay,
greedy, refusal32 = comply/32 at 400 and 1400 max tokens): the FFN writer
at alpha 6.0 reached 26/32 (alpha 4.0: 19/32, benign clean), the
post-layer residual 11/32 at alpha 2.0 (alpha 4.0 garbled), the attention
writer 5/32 at alpha 4.0 -- with a direction derived at the folded
post-layer residual and transferred to the FFN site. So on this
architecture the writer site is not a degraded second choice; it is where
the calibrated window lives. The residual hook added in 8f3439e exists for
vectors calibrated at the residual, not because the residual ranks first.

Restate the three places that implied otherwise. dir-steering/README.md's
hook-point paragraph now gives the measured numbers and says site efficacy
is architecture- and dose-dependent. The ds4_glp.c hook-mismatch message
keeps the refusal and the remedy-first wording but argues only what the
check can defend: alpha was calibrated at one site and does not transfer
silently. The ds4.c loader comment and the ds4_glp.h hook-point note (the
9x attention-writer figure, whose caveat claimed the FFN site had never
been measured) say the same and cite the measurement.

No behavior change. make && make cpu clean, no new warnings; test_glp
118/0 and test_dir_steering still pass.
The hardening pass (2ef5fdc) reads ids[i] as a glp_fail argument after
free(ids). Capture the duplicate index first. Found by gcc 13's
-Wuse-after-free on the CUDA build (clang on macOS did not flag it).
g_tp_block_ctx was declared Apple-only but is used on every GPU build
(verify-block window); widen the guard to !DS4_NO_GPU.
ds4_gpu_add_tensor_tp_flag was defined only in ds4_metal.m but called
from shared host code; give the CUDA build the fallback the header
documents (plain ds4_gpu_add_tensor) until a flag-publishing CUDA kernel
exists. Found building glp-steering on GB10 (CUDA 13.0, sm_121a).
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