Skip to content

Commit 4b6596f

Browse files
committed
fix(mlx): enforce graph-routed indexer loss contract
1 parent fa2f4d9 commit 4b6596f

6 files changed

Lines changed: 441 additions & 73 deletions

File tree

cppmega_mlx/models/dense_cpp_lm.py

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@
4949
apply_rotary_emb,
5050
rotary_inv_freq,
5151
)
52-
from cppmega_mlx.nn.code_graph_routes import build_token_graph_biases
52+
from cppmega_mlx.nn.code_graph_routes import (
53+
build_token_graph_biases,
54+
remove_graph_route_prior,
55+
)
5356
from cppmega_mlx.nn.domain_embedding import CppMegaDomainEmbedding
5457
from cppmega_mlx.nn.moe import FeedForwardExpert
5558
from cppmega_mlx.nn.ngram_hash import NgramHashEmbedding
@@ -274,8 +277,8 @@ class GraphIndexedAttention(nn.Module):
274277
275278
Reuses the same q/k/v projections as :class:`CausalSelfAttention` but, instead
276279
of dense SDPA, scores each (query, key) pair with a cheap lightning indexer
277-
(per-head ReLU dot + learned graph-prior bias ``beta * S_blk``), selects the
278-
top-k (+ local window + sinks), and runs dense MLA over the gathered KV via
280+
(per-head ReLU dot + fixed graph-prior coefficient ``beta * S_blk``), selects
281+
the top-k (+ local window + sinks), and runs dense MLA over the gathered KV via
279282
:func:`graph_indexed_attention_reference`. The indexer scores are exposed for
280283
the indexer losses (KL warm-up / BCE / coverage / contrastive) as an
281284
explicit return value. Forward execution never writes activation tensors
@@ -299,12 +302,14 @@ def __init__(self, config: DenseCppLMConfig):
299302
self.index_q_proj = nn.Linear(d_model, hi * di, bias=False)
300303
self.index_k_proj = nn.Linear(d_model, hi * di, bias=False)
301304
self.index_head_weights = mx.ones((hi,), dtype=mx.float32)
302-
# Keep the checkpoint-visible scalar while matching the production
303-
# CPPMEGA_DSA_GRAPH_BIAS_BETA default (1.0). Beta=0 remains an explicit
304-
# config/checkpoint ablation.
305+
# Keep the checkpoint-visible route coefficient while matching the
306+
# production CPPMEGA_DSA_GRAPH_BIAS_BETA default (1.0). It is fixed:
307+
# graph supervision removes this prior and discrete top-k cannot train
308+
# it, so leaving it in AdamW would only move it through weight decay.
305309
self.index_beta = mx.full(
306310
(1,), config.graph_attention_bias_beta, dtype=mx.float32
307311
)
312+
self.freeze(recurse=False, keys="index_beta", strict=True)
308313
self.rope_inv_freq = rotary_inv_freq(acfg) if acfg.use_rope else None
309314

310315
def _rotary_tables(self, seq_len: int) -> tuple[mx.array, mx.array]:
@@ -384,16 +389,11 @@ def graph_supervision_scores(
384389
) -> mx.array:
385390
"""Remove the fixed route prior while preserving invalid score entries."""
386391

387-
if tuple(final_scores.shape) != tuple(route_bias.shape):
388-
raise ValueError(
389-
"graph supervision route bias must match final indexer scores: "
390-
f"{tuple(route_bias.shape)} != {tuple(final_scores.shape)}"
391-
)
392-
scores = final_scores.astype(mx.float32)
393-
bias = route_bias.astype(mx.float32)
394-
valid = scores > mx.array(-5.0e8, dtype=mx.float32)
395-
neural_scores = scores - self.index_beta[0].astype(mx.float32) * bias
396-
return mx.where(valid, neural_scores, scores)
392+
return remove_graph_route_prior(
393+
final_scores,
394+
route_bias,
395+
beta=self.index_beta[0],
396+
)
397397

398398

399399
class DenseCppBlock(nn.Module):
@@ -834,11 +834,18 @@ def graph_supervision_scores(
834834
indexer_scores: tuple[mx.array, ...],
835835
*,
836836
input_ids: mx.array,
837-
document_ids: mx.array,
838-
block_bias: mx.array,
839-
edge_kind_bias: mx.array,
837+
document_ids: mx.array | None = None,
838+
graph_batch: GraphBatch | None = None,
839+
block_bias: mx.array | None = None,
840+
edge_kind_bias: mx.array | None = None,
840841
) -> tuple[mx.array, ...]:
841-
"""Return learned DSA logits with the fixed graph route prior removed."""
842+
"""Return neural DSA logits with the fixed graph route prior removed.
843+
844+
The route source must match the forward pass: provide a typed
845+
``graph_batch`` or the precomputed relation and edge-kind biases, never
846+
both. This keeps graph-supervised losses from training on ``beta*S_graph``
847+
a second time.
848+
"""
842849

843850
if self.config.attention_mode != "dsa":
844851
raise ValueError("graph supervision scores require attention_mode='dsa'")
@@ -849,7 +856,7 @@ def graph_supervision_scores(
849856
)
850857
route_bias = self._resolve_graph_bias(
851858
input_ids,
852-
graph_batch=None,
859+
graph_batch=graph_batch,
853860
block_bias=block_bias,
854861
edge_kind_bias=edge_kind_bias,
855862
document_ids=document_ids,

cppmega_mlx/nn/code_graph_routes.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,97 @@
7171
}
7272
_NORMALIZE_MODES = ("binary", "count")
7373
_KNOWN_EDGE_KIND_IDS = frozenset(int(kind) for kind in DomainEdgeKind)
74+
_MASKED_SCORE_THRESHOLD = -5.0e8
75+
76+
77+
def _broadcast_route_bias(
78+
scores: mx.array,
79+
graph_bias: mx.array,
80+
*,
81+
where: str,
82+
) -> mx.array:
83+
if scores.ndim != 3:
84+
raise ValueError(
85+
f"{where}: scores must be shaped (B,Tq,S), got {tuple(scores.shape)}"
86+
)
87+
batch, queries, keys = (int(value) for value in scores.shape)
88+
if graph_bias.ndim == 2:
89+
if tuple(graph_bias.shape) != (queries, keys):
90+
raise ValueError(
91+
f"{where}: 2-D graph_bias must be ({queries},{keys}), got "
92+
f"{tuple(graph_bias.shape)}"
93+
)
94+
return mx.broadcast_to(graph_bias[None, :, :], (batch, queries, keys))
95+
if graph_bias.ndim != 3:
96+
raise ValueError(
97+
f"{where}: graph_bias must be 2-D or 3-D, got "
98+
f"{tuple(graph_bias.shape)}"
99+
)
100+
bias_batch, bias_queries, bias_keys = (
101+
int(value) for value in graph_bias.shape
102+
)
103+
if (bias_queries, bias_keys) != (queries, keys):
104+
raise ValueError(
105+
f"{where}: graph_bias trailing shape must be ({queries},{keys}), got "
106+
f"{tuple(graph_bias.shape)}"
107+
)
108+
if bias_batch == batch:
109+
return graph_bias
110+
if bias_batch == 1:
111+
return mx.broadcast_to(graph_bias, (batch, queries, keys))
112+
raise ValueError(
113+
f"{where}: graph_bias batch must be 1 or {batch}, got {bias_batch}"
114+
)
115+
116+
117+
def _route_beta(beta: mx.array | float, *, where: str) -> mx.array:
118+
beta_array = (
119+
beta
120+
if isinstance(beta, mx.array)
121+
else mx.array(float(beta), dtype=mx.float32)
122+
)
123+
if beta_array.ndim != 0:
124+
raise ValueError(
125+
f"{where}: beta must be a scalar, got {tuple(beta_array.shape)}"
126+
)
127+
return beta_array.astype(mx.float32)
128+
129+
130+
def apply_graph_route_prior(
131+
neural_scores: mx.array,
132+
graph_bias: mx.array,
133+
*,
134+
beta: mx.array | float = 1.0,
135+
) -> mx.array:
136+
"""Return ``I_neural + beta * S_graph`` without reviving masked pairs."""
137+
138+
where = "apply_graph_route_prior"
139+
bias = _broadcast_route_bias(neural_scores, graph_bias, where=where)
140+
scores = neural_scores.astype(mx.float32)
141+
valid = scores > mx.array(_MASKED_SCORE_THRESHOLD, dtype=mx.float32)
142+
final_scores = scores + _route_beta(beta, where=where) * bias.astype(mx.float32)
143+
return mx.where(valid, final_scores, scores)
144+
145+
146+
def remove_graph_route_prior(
147+
final_scores: mx.array,
148+
graph_bias: mx.array,
149+
*,
150+
beta: mx.array | float = 1.0,
151+
) -> mx.array:
152+
"""Recover neural indexer scores from ``I_neural + beta * S_graph``.
153+
154+
Graph-supervised losses must consume this neural component. Training on the
155+
final routed score would reward the fixed graph prior itself and would apply
156+
``beta * S_graph`` twice when the loss path also adds the prior.
157+
"""
158+
159+
where = "remove_graph_route_prior"
160+
bias = _broadcast_route_bias(final_scores, graph_bias, where=where)
161+
scores = final_scores.astype(mx.float32)
162+
valid = scores > mx.array(_MASKED_SCORE_THRESHOLD, dtype=mx.float32)
163+
neural_scores = scores - _route_beta(beta, where=where) * bias.astype(mx.float32)
164+
return mx.where(valid, neural_scores, scores)
74165

75166

76167
def _resolve_num_nodes(packet: GraphPacket, relation: str) -> int:
@@ -733,8 +824,10 @@ def _validate_graph_document_boundaries(
733824
__all__ = [
734825
"GraphRouteConfig",
735826
"CodeGraphRouter",
827+
"apply_graph_route_prior",
736828
"build_attention_bias",
737829
"build_block_candidates",
738830
"build_token_attention_bias",
739831
"build_token_graph_biases",
832+
"remove_graph_route_prior",
740833
]

cppmega_mlx/training/indexer_losses.py

Lines changed: 18 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
import numpy as np
3333

3434
from cppmega_mlx.data.batch import batch_values_are_prevalidated
35+
from cppmega_mlx.nn.code_graph_routes import (
36+
apply_graph_route_prior,
37+
remove_graph_route_prior,
38+
)
3539

3640
_EPS = 1e-9
3741
_NEG_INF = -1e9
@@ -275,52 +279,20 @@ def apply_graph_indexer_bias(
275279
beta: scalar learnable/fixed graph-prior weight.
276280
"""
277281

278-
B, Tq, Sblk = _check_block_scores(
279-
indexer_scores, where="apply_graph_indexer_bias"
280-
)
281-
if graph_bias.ndim == 2:
282-
if tuple(graph_bias.shape) != (Tq, Sblk):
283-
raise ValueError(
284-
"apply_graph_indexer_bias: 2-D graph_bias must be "
285-
f"({Tq},{Sblk}), got {tuple(graph_bias.shape)}"
286-
)
287-
bias = mx.broadcast_to(graph_bias[None, :, :], (B, Tq, Sblk))
288-
elif graph_bias.ndim == 3:
289-
gB, gTq, gSblk = (int(x) for x in graph_bias.shape)
290-
if (gTq, gSblk) != (Tq, Sblk):
291-
raise ValueError(
292-
"apply_graph_indexer_bias: graph_bias trailing shape must be "
293-
f"({Tq},{Sblk}), got {tuple(graph_bias.shape)}"
294-
)
295-
if gB == B:
296-
bias = graph_bias
297-
elif gB == 1:
298-
bias = mx.broadcast_to(graph_bias, (B, Tq, Sblk))
299-
else:
300-
raise ValueError(
301-
"apply_graph_indexer_bias: graph_bias batch must be 1 or "
302-
f"{B}, got {gB}"
303-
)
304-
else:
305-
raise ValueError(
306-
"apply_graph_indexer_bias: graph_bias must be 2-D or 3-D, got "
307-
f"{tuple(graph_bias.shape)}"
308-
)
282+
_check_block_scores(indexer_scores, where="apply_graph_indexer_bias")
283+
return apply_graph_route_prior(indexer_scores, graph_bias, beta=beta)
309284

310-
beta_arr = (
311-
beta
312-
if isinstance(beta, mx.array)
313-
else mx.array(float(beta), dtype=mx.float32)
314-
)
315-
if beta_arr.ndim != 0:
316-
raise ValueError(
317-
"apply_graph_indexer_bias: beta must be a scalar, got "
318-
f"{tuple(beta_arr.shape)}"
319-
)
320-
logits = indexer_scores.astype(mx.float32)
321-
valid = logits > (_NEG_INF / 2.0)
322-
biased = logits + beta_arr.astype(mx.float32) * bias.astype(mx.float32)
323-
return mx.where(valid, biased, mx.array(_NEG_INF, dtype=mx.float32))
285+
286+
def remove_graph_indexer_bias(
287+
final_scores: mx.array,
288+
graph_bias: mx.array,
289+
*,
290+
beta: mx.array | float = 1.0,
291+
) -> mx.array:
292+
"""Return neural indexer logits by removing the fixed graph route prior."""
293+
294+
_check_block_scores(final_scores, where="remove_graph_indexer_bias")
295+
return remove_graph_route_prior(final_scores, graph_bias, beta=beta)
324296

325297

326298
def select_graph_biased_topk(
@@ -531,6 +503,7 @@ def edge_targets_from_candidates(
531503
"indexer_edge_bce_loss",
532504
"indexer_coverage_hinge_loss",
533505
"apply_graph_indexer_bias",
506+
"remove_graph_indexer_bias",
534507
"select_graph_biased_topk",
535508
"recall_at_k",
536509
"dense_attn_topk_overlap",

docs/graph_supervised_dsa_indexer.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,25 +31,37 @@ top-k selection, not as an after-the-fact annotation.
3131

3232
## Training Objective
3333

34-
The indexer is trained with both neural and compiler-grade teachers:
34+
The routed score is used for inference-time top-k only. Graph supervision must
35+
train the neural score after removing the fixed route prior:
36+
37+
```text
38+
I_neural = I_final - beta * S_graph
39+
```
40+
41+
The indexer objective is therefore:
3542

3643
```text
3744
L_indexer =
38-
KL(dense_attention_blocks || softmax(I_final))
39-
+ lambda_bce * BCE(I_final, graph_edge_targets)
40-
+ lambda_cov * coverage_hinge(I_final, graph_edge_targets, topk)
45+
KL(dense_attention_blocks || softmax(I_neural))
46+
+ lambda_bce * BCE(I_neural, graph_edge_targets)
47+
+ lambda_cov * coverage_hinge(I_neural, graph_edge_targets, topk)
4148
+ lambda_ctr * block_contrastive(...)
4249
```
4350

4451
`graph_edge_targets` is built from the same graph candidates that produce
45-
`S_graph`. This keeps the training target aligned with the inference-time
46-
selection rule.
52+
`S_graph`, but the fixed prior itself is not rewarded by the auxiliary loss.
53+
This prevents the shortcut where increasing `beta` lowers graph loss without
54+
improving the neural indexer. `DenseCppLM` keeps `beta` checkpoint-visible but
55+
frozen so optimizer weight decay cannot move a coefficient that receives no
56+
useful differentiable training signal.
4757

4858
## Implemented MLX Surfaces
4959

5060
- `cppmega_mlx.nn.code_graph_routes`
5161
- `GraphRouteConfig`
5262
- `CodeGraphRouter`
63+
- `apply_graph_route_prior`
64+
- `remove_graph_route_prior`
5365
- `build_attention_bias`
5466
- `build_block_candidates`
5567
- `cppmega_mlx.nn.sparse_mla`
@@ -58,6 +70,7 @@ selection rule.
5870
- `graph_indexed_attention_reference`
5971
- `cppmega_mlx.training.indexer_losses`
6072
- `apply_graph_indexer_bias`
73+
- `remove_graph_indexer_bias`
6174
- `select_graph_biased_topk`
6275
- `total_indexer_loss`
6376
- `recall_at_k`

0 commit comments

Comments
 (0)