Skip to content

Commit 9a6d2a7

Browse files
physicsrobclaude
andcommitted
attend_most_recent_matching: add exclude_self option
Pre-shifts key_vector and value by delta_pos=-1 via attend_to_offset so the head selects the most recent matching position strictly before self. Adds the d_pos width constraint on key/value (the shift's per-head width limit) and is gated on the new flag; default behaviour is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4ff1025 commit 9a6d2a7

2 files changed

Lines changed: 115 additions & 1 deletion

File tree

tests/ops/test_attention_ops.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,3 +1096,86 @@ def test_most_recent_matching_value_wider_than_d_pos():
10961096
assert torch.allclose(result[0], value_in[0], atol=1e-2)
10971097
assert torch.allclose(result[1], value_in[0], atol=1e-2)
10981098
assert torch.allclose(result[2], value_in[2], atol=1e-2)
1099+
1100+
1101+
def test_most_recent_matching_exclude_self():
1102+
"""``exclude_self=True`` skips the current query position even when
1103+
its own key matches — picking the most recent matching position
1104+
strictly before self.
1105+
1106+
Setup: every position queries type 0; positions 0, 2, 3 are
1107+
type-0 keys (matches) and position 1 is type 1 (no match).
1108+
1109+
Default (exclude_self=False) behaviour:
1110+
pos 1 → match at pos 0 → value 10
1111+
pos 2 → match at pos 2 → value 30 (self matches)
1112+
pos 3 → match at pos 3 → value 40 (self matches)
1113+
1114+
With exclude_self=True the head shifts key/value back by one, so
1115+
self can no longer contribute and the predecessor's value is
1116+
returned at the rightmost slot:
1117+
pos 1 → predecessor of pos 1 is pos 0 (match) → value 10
1118+
pos 2 → predecessor of pos 2 is pos 1 (no match); next
1119+
most-recent match below self is pos 0 → value 10
1120+
pos 3 → predecessor of pos 3 is pos 2 (match) → value 30
1121+
Position 0 is degenerate under the shift (no prior position exists)
1122+
and is not asserted.
1123+
"""
1124+
pe = _pe()
1125+
query = InputNode("query", 4, value_range=(-1.0, 1.0))
1126+
key = InputNode("key", 4, value_range=(-1.0, 1.0))
1127+
value = InputNode("value", 1, value_range=(-100.0, 100.0))
1128+
1129+
out_default = attend_most_recent_matching(pe, query, key, value)
1130+
out_excl = attend_most_recent_matching(pe, query, key, value, exclude_self=True)
1131+
1132+
n_pos = 4
1133+
# types per position: 0, 1, 0, 0 — positions 0, 2, 3 match type 0.
1134+
key_in = torch.eye(4)[torch.tensor([0, 1, 0, 0])]
1135+
# All queries look for type 0.
1136+
query_in = torch.eye(4)[torch.tensor([0, 0, 0, 0])]
1137+
value_in = torch.tensor([[10.0], [20.0], [30.0], [40.0]])
1138+
1139+
result_default = _run(out_default, n_pos, query=query_in, key=key_in, value=value_in)
1140+
result_excl = _run(out_excl, n_pos, query=query_in, key=key_in, value=value_in)
1141+
1142+
# Sanity: default mode picks self when self matches.
1143+
assert abs(result_default[1].item() - 10.0) < 1e-2, result_default[1]
1144+
assert abs(result_default[2].item() - 30.0) < 1e-2, result_default[2]
1145+
assert abs(result_default[3].item() - 40.0) < 1e-2, result_default[3]
1146+
1147+
# exclude_self mode: self's match doesn't count; we get the most
1148+
# recent strict-prior match instead.
1149+
assert abs(result_excl[1].item() - 10.0) < 1e-2, result_excl[1]
1150+
assert abs(result_excl[2].item() - 10.0) < 1e-2, result_excl[2]
1151+
assert abs(result_excl[3].item() - 30.0) < 1e-2, result_excl[3]
1152+
1153+
1154+
def test_most_recent_matching_exclude_self_width_constraint():
1155+
"""``exclude_self=True`` requires ``len(value) <= d_pos`` and
1156+
``len(key_vector) <= d_pos`` because each pre-shift compiles to a
1157+
single attention head whose width is bounded by ``d_pos``. The
1158+
default mode has no such limit.
1159+
"""
1160+
import pytest
1161+
1162+
pe = _pe() # d_pos = 16
1163+
query = InputNode("query", 4, value_range=(-1.0, 1.0))
1164+
key = InputNode("key", 4, value_range=(-1.0, 1.0))
1165+
wide_value = InputNode("value", 24, value_range=(-100.0, 100.0))
1166+
1167+
# Default mode accepts width 24 > d_pos = 16.
1168+
attend_most_recent_matching(pe, query, key, wide_value)
1169+
1170+
# exclude_self mode rejects it.
1171+
with pytest.raises(AssertionError, match="value width"):
1172+
attend_most_recent_matching(pe, query, key, wide_value, exclude_self=True)
1173+
1174+
# Symmetric check on key width.
1175+
wide_query = InputNode("query", 24, value_range=(-1.0, 1.0))
1176+
wide_key = InputNode("key", 24, value_range=(-1.0, 1.0))
1177+
narrow_value = InputNode("value", 1, value_range=(-100.0, 100.0))
1178+
with pytest.raises(AssertionError, match="key_vector width"):
1179+
attend_most_recent_matching(
1180+
pe, wide_query, wide_key, narrow_value, exclude_self=True
1181+
)

torchwright/ops/attention_ops.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,7 @@ def attend_most_recent_matching(
10111011
value: Node,
10121012
*,
10131013
match_gain: float = 200.0,
1014+
exclude_self: bool = False,
10141015
assert_hardness_gt: Optional[float] = None,
10151016
) -> Node:
10161017
"""Attend to the **most recent** key position whose ``key_vector``
@@ -1104,6 +1105,18 @@ def attend_most_recent_matching(
11041105
physical heads.
11051106
match_gain: Coefficient on the dot-product term. See the
11061107
invariant above.
1108+
exclude_self: If ``True``, the current query position is
1109+
excluded from selection — the head returns ``value`` at the
1110+
most recent matching position **strictly before** self.
1111+
Implemented by pre-shifting ``key_vector`` and ``value`` by
1112+
one position via :meth:`PosEncoding.attend_to_offset`, so
1113+
that at slot ``i`` the head sees the predecessor's key and
1114+
value. Costs two extra attention heads (one for the key
1115+
shift, one for the value shift) and adds the constraint
1116+
``len(key_vector) <= d_pos`` and ``len(value) <= d_pos``
1117+
(the shift's per-head width limit). At query position 0
1118+
the result is degenerate (no prior position exists) — the
1119+
caller must not consume it there.
11071120
assert_hardness_gt: If set, wraps the output in a softmax
11081121
hardness assertion verifying the max attention weight per
11091122
query exceeds this threshold during ``debug=True`` forward
@@ -1124,9 +1137,27 @@ def attend_most_recent_matching(
11241137
"query_vector and key_vector must have the same width "
11251138
f"(got {len(query_vector)} and {len(key_vector)})"
11261139
)
1140+
d_pos = pos_encoding.d_pos
1141+
if exclude_self:
1142+
assert len(key_vector) <= d_pos, (
1143+
f"attend_most_recent_matching(exclude_self=True): "
1144+
f"key_vector width ({len(key_vector)}) must be <= d_pos ({d_pos}); "
1145+
"the position-shift step uses one attention head per shift."
1146+
)
1147+
assert len(value) <= d_pos, (
1148+
f"attend_most_recent_matching(exclude_self=True): "
1149+
f"value width ({len(value)}) must be <= d_pos ({d_pos}); "
1150+
"the position-shift step uses one attention head per shift."
1151+
)
1152+
# Shift key and value back by one position so that at slot i the
1153+
# head sees key_vector[i-1] / value[i-1]. The causal mask still
1154+
# admits i in [0, j], but the rightmost slot i = j now carries
1155+
# the predecessor's data — self can no longer contribute its own
1156+
# match.
1157+
key_vector = pos_encoding.attend_to_offset(key_vector, delta_pos=-1)
1158+
value = pos_encoding.attend_to_offset(value, delta_pos=-1)
11271159
W = len(query_vector)
11281160
d_v = len(value)
1129-
d_pos = pos_encoding.d_pos
11301161

11311162
# d_qk layout:
11321163
# cols 0..W-1 content match: Q = match_gain · query_vector[c],

0 commit comments

Comments
 (0)