Skip to content

Commit cfdbbb2

Browse files
physicsrobclaude
andcommitted
Address review findings on the PosEncoding redesign
Follow-up to b192865, from an adversarial review of that commit. - attend_most_recent_matching(exclude_self=True): drop the stale len(value)/len(key_vector) <= d_pos asserts. The pre-shift uses attend_to_offset, whose V/O auto-splits across heads, so the only real bound (d_head >= W+1 for the head's Q/K) is already enforced by the compiler. The old asserts rejected valid wide inputs. - get_prev_value: rewrite the numerical-limit docstring. The max_pos bound is exact logit ordering — a recent false key outscores an early true once the runtime position exceeds ~2*max_pos ({0,1} cond) or ~4*max_pos ({-1,+1}) — NOT TF32 precision; the prior "needs fp32" note was wrong. Also document the cond conventions and the all-false window. - attend_argmin/argmax/argmin_where: drop stale "len(value) <= d_pos" docstrings (wide V/O auto-splits). - PosEncoding: reject d_pos < 3 (trig_width 0 divides by zero on use). Tests: - compiled round-trips for attend_argmin/argmax/_where (previously only oracle-tested), incl. a wide-value (40 > d_head) auto-split case. - get_prev_value latch-within-budget and adjacent-true recency. - _current_pos_attn_matrices zeroes the counter column across d_head. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b192865 commit cfdbbb2

6 files changed

Lines changed: 174 additions & 49 deletions

File tree

tests/compile/forward/test_forward_compile.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@
2727
)
2828
from torchwright.ops.logic_ops import cond_gate, cond_add_vector
2929
from torchwright.ops.map_select import select, map_to_table
30+
from torchwright.ops.attention_ops import (
31+
attend_argmin,
32+
attend_argmax,
33+
attend_argmin_where,
34+
attend_argmax_where,
35+
)
3036

3137
D = 256
3238
D_HEAD = 16
@@ -623,6 +629,50 @@ def test_compile_split_vo_large_ratio():
623629
), f"Max diff: {(actual.cpu() - expected).abs().max().item():.6f}"
624630

625631

632+
def test_compile_selection_primitives():
633+
"""Compiled path for the simple selection primitives, whose d_qk shrank
634+
to 1-2 and whose query is now a bare LiteralValue([1.0])."""
635+
pos = create_pos_encoding() # PosEncoding(17), trig_width 16 == D_HEAD
636+
score = create_input("score", 1)
637+
validity = create_input("valid", 1)
638+
value = create_input("value", 3)
639+
640+
n_pos = 6
641+
inputs = {
642+
"score": torch.tensor([[3.0], [1.0], [4.0], [0.0], [2.0], [5.0]]),
643+
# position 0 is always valid, so every causal window has a valid key
644+
"valid": torch.tensor([[1.0], [-1.0], [1.0], [-1.0], [1.0], [-1.0]]),
645+
"value": torch.arange(n_pos * 3, dtype=torch.float32).reshape(n_pos, 3),
646+
}
647+
648+
for out in (
649+
attend_argmin(pos, score, value),
650+
attend_argmax(pos, score, value),
651+
attend_argmin_where(pos, score, validity, value),
652+
attend_argmax_where(pos, score, validity, value),
653+
):
654+
_verify(out, n_pos=n_pos, input_values=inputs, pos_encoding=pos)
655+
656+
657+
def test_compile_selection_wide_value():
658+
"""Selection value wider than d_head must auto-split across heads (the
659+
len(value) <= d_pos cap was removed)."""
660+
pos = create_pos_encoding()
661+
score = create_input("score", 1)
662+
value = create_input("value", 40) # > D_HEAD = 16
663+
n_pos = 5
664+
inputs = {
665+
"score": torch.tensor([[3.0], [1.0], [2.0], [0.0], [4.0]]),
666+
"value": torch.arange(n_pos * 40, dtype=torch.float32).reshape(n_pos, 40),
667+
}
668+
_verify(
669+
attend_argmin(pos, score, value),
670+
n_pos=n_pos,
671+
input_values=inputs,
672+
pos_encoding=pos,
673+
)
674+
675+
626676
def test_compile_rejects_d_head_below_trig_width():
627677
"""forward_compile rejects d_head < pos_encoding.trig_width early, before
628678
the copy heads would silently match on only part of the trig grid."""

tests/compile/forward/test_weight_writer.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
MLPOp,
2525
write_attn_sublayer,
2626
write_mlp_sublayer,
27+
_current_pos_attn_matrices,
2728
)
2829
from torchwright.compiler.groups.transformer_layer import TransformerLayer
2930
from torchwright.graph import Linear, ReLU, Attn, Add, Concatenate
@@ -191,6 +192,20 @@ def test_attn_compute():
191192
assert torch.allclose(result.cpu(), expected, atol=1e-4)
192193

193194

195+
def test_current_pos_matrices_exclude_counter():
196+
"""Same-position copy heads must match on the trig block only — the raw
197+
counter column is zeroed regardless of how d_head compares to d_pos."""
198+
pe = PosEncoding(17) # counter_col == 16
199+
counter_col = pe.counter_col
200+
for d_head in (8, 16, 17, 20):
201+
q, k = _current_pos_attn_matrices(pe, d_head)
202+
assert torch.all(q[counter_col] == 0.0), f"q counter not zero at d_head={d_head}"
203+
assert torch.all(k[counter_col] == 0.0), f"k counter not zero at d_head={d_head}"
204+
# A trig row IS matched (non-degenerate diagonal).
205+
q16, k16 = _current_pos_attn_matrices(pe, 16)
206+
assert q16[0, 0] != 0.0 and k16[0, 0] != 0.0
207+
208+
194209
def test_attn_compute_small_d_head():
195210
"""Attn node with d_head smaller than layer d_head — needs padding."""
196211
pos = _make_pos_encoding()

tests/graph/test_pos_encoding.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,46 @@ def test_get_prev_value():
4444
assert torch.allclose(output, expected_prev_values)
4545

4646

47+
def test_get_prev_value_latch_within_budget():
48+
"""A value latched at an early trigger is held across a long-ish sequence
49+
as long as the runtime length stays within the max_pos budget (< 2*max_pos
50+
for a {0,1} cond)."""
51+
n_pos = 400 # < 2 * default max_pos (512)
52+
value_input = InputNode("value", 1, value_range=(-1000.0, 1000.0))
53+
cond_input = InputNode("cond", 1, value_range=(-100.0, 100.0))
54+
pos_encoding = PosEncoding(17)
55+
out = pos_encoding.get_prev_value(value_input, cond_input)
56+
57+
cond = torch.zeros(n_pos, 1)
58+
cond[3, 0] = 1.0 # single true at position 3
59+
value = torch.arange(n_pos, dtype=torch.float32).reshape(n_pos, 1)
60+
value[3, 0] = 42.0
61+
res = out.compute(n_pos=n_pos, input_values={"value": value, "cond": cond})
62+
# Every query at or after the trigger returns the latched value.
63+
assert torch.allclose(res[3:], torch.full((n_pos - 3, 1), 42.0))
64+
65+
66+
def test_get_prev_value_adjacent_trues_pick_most_recent():
67+
"""Among adjacent true positions, the most recent (largest counter) wins."""
68+
n_pos = 10
69+
value_input = InputNode("value", 1, value_range=(-100.0, 100.0))
70+
cond_input = InputNode("cond", 1, value_range=(-100.0, 100.0))
71+
pos_encoding = PosEncoding(17)
72+
out = pos_encoding.get_prev_value(value_input, cond_input)
73+
74+
cond = torch.zeros(n_pos, 1)
75+
cond[5, 0] = 1.0
76+
cond[6, 0] = 1.0 # adjacent trues at 5 and 6
77+
value = torch.zeros(n_pos, 1)
78+
value[5, 0] = 50.0
79+
value[6, 0] = 60.0
80+
res = out.compute(n_pos=n_pos, input_values={"value": value, "cond": cond})
81+
# Most recent true (pos 6 -> 60) dominates; ~exp(-recency_gap) contamination
82+
# from the adjacent true (50) keeps it just under 60.
83+
assert torch.allclose(res[6], torch.tensor([60.0]), atol=0.05)
84+
assert torch.allclose(res[9], torch.tensor([60.0]), atol=0.05)
85+
86+
4787
def test_attend_to_offset():
4888
input_values = torch.tensor([[1.0], [2.0], [3.0], [4.0], [5.0]])
4989
value_input = InputNode("value", 1, value_range=(-100.0, 100.0))

tests/ops/test_attention_ops.py

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,31 +1250,24 @@ def test_most_recent_matching_exclude_self():
12501250
assert abs(result_excl[3].item() - 30.0) < 1e-2, result_excl[3]
12511251

12521252

1253-
def test_most_recent_matching_exclude_self_width_constraint():
1254-
"""``exclude_self=True`` requires ``len(value) <= d_pos`` and
1255-
``len(key_vector) <= d_pos`` because each pre-shift compiles to a
1256-
single attention head whose width is bounded by ``d_pos``. The
1257-
default mode has no such limit.
1253+
def test_most_recent_matching_exclude_self_accepts_wide_width():
1254+
"""``exclude_self=True`` no longer caps key/value width: the pre-shift
1255+
uses ``attend_to_offset``, whose V/O auto-splits across heads. Both modes
1256+
build for widths wider than the encoding (regression for the removed caps).
12581257
"""
1259-
import pytest
1260-
1261-
pe = _pe() # d_pos = 16
1258+
pe = _pe() # PosEncoding(17), trig_width 16
12621259
query = InputNode("query", 4, value_range=(-1.0, 1.0))
12631260
key = InputNode("key", 4, value_range=(-1.0, 1.0))
12641261
wide_value = InputNode("value", 24, value_range=(-100.0, 100.0))
12651262

1266-
# Default mode accepts width 24 > d_pos = 16.
1263+
# Wide value (24 > trig_width 16) builds in both modes.
12671264
attend_most_recent_matching(pe, query, key, wide_value)
1265+
attend_most_recent_matching(pe, query, key, wide_value, exclude_self=True)
12681266

1269-
# exclude_self mode rejects it.
1270-
with pytest.raises(AssertionError, match="value width"):
1271-
attend_most_recent_matching(pe, query, key, wide_value, exclude_self=True)
1272-
1273-
# Symmetric check on key width.
1267+
# Wide key_vector likewise.
12741268
wide_query = InputNode("query", 24, value_range=(-1.0, 1.0))
12751269
wide_key = InputNode("key", 24, value_range=(-1.0, 1.0))
12761270
narrow_value = InputNode("value", 1, value_range=(-100.0, 100.0))
1277-
with pytest.raises(AssertionError, match="key_vector width"):
1278-
attend_most_recent_matching(
1279-
pe, wide_query, wide_key, narrow_value, exclude_self=True
1280-
)
1271+
attend_most_recent_matching(
1272+
pe, wide_query, wide_key, narrow_value, exclude_self=True
1273+
)

torchwright/graph/pos_encoding.py

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ def __init__(self, d_pos: int):
3131
f"even d_pos would leave an incomplete sin/cos pair. Use "
3232
f"{d_pos + 1} or {d_pos - 1}."
3333
)
34+
if d_pos < 3:
35+
raise ValueError(
36+
f"PosEncoding requires d_pos >= 3 (got {d_pos}): at least one "
37+
f"complete sin/cos pair (trig_width >= 2) plus the counter "
38+
f"column. trig_width = 0 has no position discrimination and "
39+
f"divides by zero when building the frequency grid."
40+
)
3441
self.d_pos = d_pos
3542
super().__init__(d_pos, [])
3643

@@ -130,18 +137,42 @@ def get_prev_value(
130137
"""Most-recent previous ``value`` at a position where ``cond`` is true.
131138
132139
For each query position, selects ``value`` at the most recent causal
133-
position where ``cond == 1.0`` (false positions must be ``<= 0``).
134-
Recency is ranked by the raw position counter: among true positions
135-
the largest counter (most recent) wins by ``recency_logit_gap`` of
136-
logit per position. A condition gate that dominates the full recency
137-
span makes any true key beat any false key.
138-
139-
Numerical limit. The gate forces peak logits near
140-
``3 * recency_logit_gap * max_pos``. On A100 TF32 (~1e-3 relative
141-
precision) the per-position recency gap stays resolvable only while
142-
``max_pos`` is a few hundred; the default 256 is safe, ~512 is the
143-
ceiling. Larger reach needs an fp32 matmul for this head. Runtime
144-
positions must be ``<= max_pos``.
140+
position where ``cond`` is true. ``cond`` may be either torchwright
141+
boolean convention: ``{0, 1}`` (false = 0) or ``{-1, +1}`` (false = -1);
142+
true must be ``+1`` and false must be ``<= 0``. Recency is ranked by
143+
the raw position counter: among true positions the largest counter
144+
(most recent) wins by ``recency_logit_gap`` of logit per position. A
145+
condition gate (``2 * recency_logit_gap * max_pos``) makes a true key
146+
outscore a false key.
147+
148+
**Selection contract — this is exact arithmetic, not precision.** The
149+
gate is frozen at graph-build time from ``max_pos``, but the raw
150+
counter grows with the *actual* runtime sequence length. A true key at
151+
counter ``t`` beats a false key at counter ``f`` only while
152+
``gate > recency_logit_gap * (f - t)``, i.e. while the runtime position
153+
stays below ``2 * max_pos`` (false = 0) or ``4 * max_pos``
154+
(false = -1). **Past that bound a recent false key silently outscores
155+
an early true** (e.g. a value latched at an early trigger and held
156+
across a long rollout) — no error, no tie, just the wrong value. So
157+
``max_pos`` must be set to at least the longest sequence this head will
158+
ever see (the default 256 covers sequences up to ~512 / ~1024). This
159+
mirrors the caller-supplied ``match_gain`` invariant documented on
160+
:func:`~torchwright.ops.attention_ops.attend_most_recent_matching`.
161+
(fp32 does **not** move this bound — it is logit ordering, not mantissa
162+
width.)
163+
164+
**Tiebreak precision — TF32, soft.** Among *multiple* true positions,
165+
resolving the most recent needs the ``recency_logit_gap`` (8) to exceed
166+
the TF32 noise floor at the peak logit (~``3 * recency_logit_gap *
167+
max_pos``); this holds for a few hundred positions and then degrades
168+
gracefully to "approximately the most recent true" (a blend of the last
169+
few trues), not a wrong false. The common single-true latch never hits
170+
this.
171+
172+
**All-false window.** If no causal position has ``cond`` true, the
173+
gate term is uniform and the head degrades to pure recency, returning
174+
the most recent position's value. Callers must ensure at least one
175+
true key exists in every consumed window, or gate the result.
145176
"""
146177
assert len(cond) == 1, "get_prev_value expects a 1-D boolean cond"
147178
d_v = len(value)

torchwright/ops/attention_ops.py

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,8 @@ def attend_argmin(
295295
pos_encoding: The graph's positional encoding node.
296296
score: 1D scalar node (``len(score) == 1``).
297297
value: Node whose value to read at the winning position.
298-
Must satisfy ``len(value) <= pos_encoding.d_pos``.
298+
No width constraint — wide V/O is auto-split across physical
299+
heads by the compiler.
299300
300301
Returns:
301302
A new ``Attn`` node of width ``len(value)`` equal to ``value`` at
@@ -328,7 +329,8 @@ def attend_argmax(
328329
Args:
329330
pos_encoding: The graph's positional encoding node.
330331
score: 1D scalar node.
331-
value: Node whose value to read. ``len(value) <= pos_encoding.d_pos``.
332+
value: Node whose value to read. No width constraint (wide V/O
333+
auto-splits across heads).
332334
333335
Returns:
334336
Attn node of width ``len(value)`` equal to ``value`` at the
@@ -384,7 +386,8 @@ def attend_argmin_where(
384386
pos_encoding: The graph's positional encoding node.
385387
score: 1D scalar node.
386388
validity: 1D boolean node (+1 valid, −1 invalid).
387-
value: Node to read. ``len(value) <= pos_encoding.d_pos``.
389+
value: Node to read. No width constraint (wide V/O auto-splits
390+
across heads).
388391
389392
Returns:
390393
Attn node of width ``len(value)``.
@@ -1279,12 +1282,11 @@ def attend_most_recent_matching(
12791282
Implemented by pre-shifting ``key_vector`` and ``value`` by
12801283
one position via :meth:`PosEncoding.attend_to_offset`, so
12811284
that at slot ``i`` the head sees the predecessor's key and
1282-
value. Costs two extra attention heads (one for the key
1283-
shift, one for the value shift) and adds the constraint
1284-
``len(key_vector) <= d_pos`` and ``len(value) <= d_pos``
1285-
(the shift's per-head width limit). At query position 0
1286-
the result is degenerate (no prior position exists) — the
1287-
caller must not consume it there.
1285+
value. Costs two extra attention heads for the key and
1286+
value shifts (wide key/value auto-split across more heads;
1287+
no width cap). At query position 0 the result is degenerate
1288+
(no prior position exists) — the caller must not consume it
1289+
there.
12881290
assert_hardness_gt: If set, wraps the output in a softmax
12891291
hardness assertion verifying the max attention weight per
12901292
query exceeds this threshold during ``debug=True`` forward
@@ -1307,16 +1309,10 @@ def attend_most_recent_matching(
13071309
)
13081310
d_pos = pos_encoding.d_pos
13091311
if exclude_self:
1310-
assert len(key_vector) <= d_pos, (
1311-
f"attend_most_recent_matching(exclude_self=True): "
1312-
f"key_vector width ({len(key_vector)}) must be <= d_pos ({d_pos}); "
1313-
"the position-shift step uses one attention head per shift."
1314-
)
1315-
assert len(value) <= d_pos, (
1316-
f"attend_most_recent_matching(exclude_self=True): "
1317-
f"value width ({len(value)}) must be <= d_pos ({d_pos}); "
1318-
"the position-shift step uses one attention head per shift."
1319-
)
1312+
# The shift uses attend_to_offset, whose Q/K width is trig_width and
1313+
# whose V/O auto-splits across heads, so key_vector / value may be any
1314+
# width (the only binding constraint, d_head >= W + 1 for this head's
1315+
# Q/K, is enforced by the compiler).
13201316
# Shift key and value back by one position so that at slot i the
13211317
# head sees key_vector[i-1] / value[i-1]. The causal mask still
13221318
# admits i in [0, j], but the rightmost slot i = j now carries

0 commit comments

Comments
 (0)