Skip to content

Commit c71676f

Browse files
physicsrobclaude
andcommitted
Add 3D constant table lookup primitive
table_lookup_3d is a table_lookup_2d whose row index is a flattened, pre-rounded (A, B) index: q = B*idx_a + idx_b into table.reshape(A*B, C), with the C axis handled by the existing 2D column gate. The only new construction is a scalar centered integer-index staircase. Internal axis order: A = outer_axis (default smallest, asserted integer since its off-grid transition is a degenerate q-scan), C = larger remaining axis (the contiguous vector axis), B = smaller remaining. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b5618f4 commit c71676f

3 files changed

Lines changed: 477 additions & 4 deletions

File tree

tests/ops/test_resampling_primitives.py

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
linear_bin_index,
2727
reciprocal,
2828
table_lookup_2d,
29+
table_lookup_3d,
2930
)
3031
from torchwright.ops.arithmetic_ops import clamp, subtract
3132

@@ -223,6 +224,327 @@ def test_table_lookup_2d_accepts_transition_band_inputs():
223224
)
224225
assert report.first_divergent is None, report.format_short()
225226

227+
228+
# ---------------------------------------------------------------------------
229+
# table_lookup_3d
230+
# ---------------------------------------------------------------------------
231+
232+
233+
def _table_lookup_3d_axis_order(shape, outer_axis=None):
234+
"""Replicates the implementation's internal axis-order heuristic:
235+
A = outer_axis (default smallest), C = larger remaining, B = smaller."""
236+
sizes = list(shape)
237+
if outer_axis is None:
238+
a = int(min(range(3), key=lambda ax: sizes[ax]))
239+
else:
240+
a = int(outer_axis)
241+
remaining = sorted((ax for ax in range(3) if ax != a), key=lambda ax: sizes[ax])
242+
return a, remaining[0], remaining[1]
243+
244+
245+
def _staircase_value(x: float, n: int, eps: float) -> float:
246+
"""Continuous form of the centered integer-index staircase."""
247+
idx = max(0, min(n - 1, int(math.floor(x + 0.5))))
248+
if n <= 1:
249+
return float(idx)
250+
k = int(math.floor(x))
251+
lo = float(k) + 0.5 - eps / 2.0
252+
hi = float(k) + 0.5 + eps / 2.0
253+
if 0 <= k <= n - 2 and lo <= x <= hi:
254+
return float(k) + max(0.0, min(1.0, (x - lo) / eps))
255+
return float(idx)
256+
257+
258+
def _table_lookup_3d_reference(
259+
table: torch.Tensor,
260+
i: torch.Tensor,
261+
j: torch.Tensor,
262+
k: torch.Tensor,
263+
index_scale=1.0,
264+
sharpness: float = 100.0,
265+
outer_axis=None,
266+
) -> torch.Tensor:
267+
"""Independent reference: round the two flattened axes to integer
268+
indices, flatten as ``q = B*idx_a + idx_b``, and reduce to the
269+
validated 2D reference over ``table.reshape(A*B, C)``."""
270+
if isinstance(index_scale, (int, float)):
271+
scales = [float(index_scale)] * 3
272+
else:
273+
scales = [float(s) for s in index_scale]
274+
a_ax, b_ax, c_ax = _table_lookup_3d_axis_order(table.shape, outer_axis)
275+
a_size, b_size, c_size = table.shape[a_ax], table.shape[b_ax], table.shape[c_ax]
276+
table_perm = (
277+
table.permute(a_ax, b_ax, c_ax).contiguous().reshape(a_size * b_size, c_size)
278+
)
279+
eps = 1.0 / sharpness
280+
inp = [i, j, k]
281+
n_pos = i.shape[0]
282+
q = torch.empty(n_pos, 1, dtype=table.dtype)
283+
c_scaled = torch.empty(n_pos, 1, dtype=table.dtype)
284+
for p in range(n_pos):
285+
sv_a = _staircase_value(float(inp[a_ax][p, 0]) * scales[a_ax], a_size, eps)
286+
sv_b = _staircase_value(float(inp[b_ax][p, 0]) * scales[b_ax], b_size, eps)
287+
q[p, 0] = b_size * sv_a + sv_b
288+
c_scaled[p, 0] = float(inp[c_ax][p, 0]) * scales[c_ax]
289+
return _table_lookup_2d_reference(table_perm, q, c_scaled, sharpness=sharpness)
290+
291+
292+
def _build_table_lookup_3d_graph(
293+
table, index_scale=1.0, sharpness=100.0, outer_axis=None
294+
):
295+
i = create_input("i", 1)
296+
j = create_input("j", 1)
297+
k = create_input("k", 1)
298+
return table_lookup_3d(
299+
i,
300+
j,
301+
k,
302+
table,
303+
index_scale=index_scale,
304+
sharpness=sharpness,
305+
outer_axis=outer_axis,
306+
)
307+
308+
309+
def test_table_lookup_3d_axis_order_for_target_shape():
310+
# 16 x 128 x 128 should resolve to A=16 (outer), B=128, C=128.
311+
assert _table_lookup_3d_axis_order((16, 128, 128)) == (0, 1, 2)
312+
313+
314+
def test_table_lookup_3d_every_integer_cell():
315+
table = torch.arange(24, dtype=torch.float32).reshape(2, 3, 4) * 2.0 - 5.0
316+
out_node = _build_table_lookup_3d_graph(table)
317+
318+
coords = [
319+
(a, b, c)
320+
for a in range(table.shape[0])
321+
for b in range(table.shape[1])
322+
for c in range(table.shape[2])
323+
]
324+
i_val = torch.tensor([[float(a)] for a, _b, _c in coords], dtype=torch.float32)
325+
j_val = torch.tensor([[float(b)] for _a, b, _c in coords], dtype=torch.float32)
326+
k_val = torch.tensor([[float(c)] for _a, _b, c in coords], dtype=torch.float32)
327+
inputs = {"i": i_val, "j": j_val, "k": k_val}
328+
n_pos = len(coords)
329+
330+
# Fully independent check: the cell value itself.
331+
direct = torch.tensor(
332+
[[float(table[a, b, c])] for a, b, c in coords], dtype=torch.float32
333+
)
334+
expected = _table_lookup_3d_reference(table, i_val, j_val, k_val)
335+
cache = reference_eval(out_node, inputs, n_pos)
336+
assert torch.allclose(cache[out_node], direct, atol=5e-3)
337+
assert torch.allclose(cache[out_node], expected, atol=5e-3)
338+
339+
report = probe_graph(
340+
out_node,
341+
pos_encoding=None,
342+
input_values=inputs,
343+
n_pos=n_pos,
344+
d=2048,
345+
d_head=16,
346+
verbose=False,
347+
atol=5e-3,
348+
)
349+
assert report.first_divergent is None, report.format_short()
350+
351+
352+
def test_table_lookup_3d_scaled_unit_coordinates():
353+
table = torch.arange(24, dtype=torch.float32).reshape(2, 3, 4) * 1.5 + 1.0
354+
index_scale = table.shape
355+
out_node = _build_table_lookup_3d_graph(table, index_scale=index_scale)
356+
357+
coords = [(0, 1, 2), (1, 0, 3), (1, 2, 0), (0, 2, 3)]
358+
# +0.2 inside each scaled cell stays in the centered integer bin. The
359+
# outer axis (axis 0) must scale to an exact integer (it is asserted
360+
# integer), so it gets no offset.
361+
i_val = torch.tensor(
362+
[[float(a) / table.shape[0]] for a, _b, _c in coords],
363+
dtype=torch.float32,
364+
)
365+
j_val = torch.tensor(
366+
[[(float(b) + 0.2) / table.shape[1]] for _a, b, _c in coords],
367+
dtype=torch.float32,
368+
)
369+
k_val = torch.tensor(
370+
[[(float(c) + 0.2) / table.shape[2]] for _a, _b, c in coords],
371+
dtype=torch.float32,
372+
)
373+
inputs = {"i": i_val, "j": j_val, "k": k_val}
374+
direct = torch.tensor(
375+
[[float(table[a, b, c])] for a, b, c in coords], dtype=torch.float32
376+
)
377+
378+
cache = reference_eval(out_node, inputs, len(coords))
379+
assert torch.allclose(cache[out_node], direct, atol=5e-3)
380+
381+
382+
# Flattening 16x128 into 2048 row breakpoints makes the row PWL reconstruct
383+
# each value as a sum over ~q active ReLU terms. Both the per-term slope
384+
# magnitude and the ReLU-argument magnitude scale with `sharpness`, so the
385+
# float32 accumulation error at integer cells scales with it too: ~0.08 at
386+
# sharpness=100, ~0.008 at sharpness=10 on unit-magnitude values. Integer-
387+
# lookup fidelity at the target shape therefore improves with lower sharpness.
388+
@pytest.mark.parametrize("sharpness,atol", [(100.0, 0.2), (10.0, 0.02)])
389+
def test_table_lookup_3d_target_size_integer_reference(sharpness, atol):
390+
generator = torch.Generator().manual_seed(0)
391+
table = (
392+
torch.rand((16, 128, 128), generator=generator, dtype=torch.float32) * 2.0 - 1.0
393+
)
394+
out_node = _build_table_lookup_3d_graph(table, sharpness=sharpness)
395+
396+
coords = [(0, 0, 0), (3, 17, 99), (8, 64, 64), (15, 127, 1), (15, 0, 127)]
397+
i_val = torch.tensor([[float(a)] for a, _b, _c in coords], dtype=torch.float32)
398+
j_val = torch.tensor([[float(b)] for _a, b, _c in coords], dtype=torch.float32)
399+
k_val = torch.tensor([[float(c)] for _a, _b, c in coords], dtype=torch.float32)
400+
inputs = {"i": i_val, "j": j_val, "k": k_val}
401+
direct = torch.tensor(
402+
[[float(table[a, b, c])] for a, b, c in coords], dtype=torch.float32
403+
)
404+
405+
cache = reference_eval(out_node, inputs, len(coords))
406+
assert torch.allclose(cache[out_node], direct, atol=atol)
407+
408+
409+
def test_table_lookup_3d_inner_axis_amplification():
410+
# b_size = 16, so idx_a feeds q with a ×16 weight: q = 16*idx_a + idx_b.
411+
# High-b integer cells (b=15) stress that the staircase lands exactly.
412+
table = torch.arange(2 * 16 * 16, dtype=torch.float32).reshape(2, 16, 16) * 0.5
413+
out_node = _build_table_lookup_3d_graph(table)
414+
assert _table_lookup_3d_axis_order(table.shape) == (0, 1, 2)
415+
416+
coords = [(0, 0, 0), (0, 15, 0), (1, 15, 15), (1, 0, 7), (0, 9, 11), (1, 8, 3)]
417+
i_val = torch.tensor([[float(a)] for a, _b, _c in coords], dtype=torch.float32)
418+
j_val = torch.tensor([[float(b)] for _a, b, _c in coords], dtype=torch.float32)
419+
k_val = torch.tensor([[float(c)] for _a, _b, c in coords], dtype=torch.float32)
420+
inputs = {"i": i_val, "j": j_val, "k": k_val}
421+
direct = torch.tensor(
422+
[[float(table[a, b, c])] for a, b, c in coords], dtype=torch.float32
423+
)
424+
425+
cache = reference_eval(out_node, inputs, len(coords))
426+
assert torch.allclose(cache[out_node], direct, atol=5e-3)
427+
428+
report = probe_graph(
429+
out_node,
430+
pos_encoding=None,
431+
input_values=inputs,
432+
n_pos=len(coords),
433+
d=2048,
434+
d_head=16,
435+
verbose=False,
436+
atol=5e-3,
437+
)
438+
assert report.first_divergent is None, report.format_short()
439+
440+
441+
def test_table_lookup_3d_near_integer_inputs_remain_in_bin():
442+
# sharpness=10 -> eps=0.1, transition half-width 0.05. +/-0.4 on the
443+
# inner (B) and vector (C) axes stays in the stable bin. The outer (A)
444+
# axis is held exactly integer (it is asserted integer).
445+
table = torch.arange(2 * 3 * 3, dtype=torch.float32).reshape(2, 3, 3) * 4.0
446+
out_node = _build_table_lookup_3d_graph(table, sharpness=10.0)
447+
448+
coords = [(0, 0.4, 0.4), (1, 1.4, 1.6), (0, 1.6, 0.4), (1, 0.4, 1.6)]
449+
i_val = torch.tensor([[a] for a, _b, _c in coords], dtype=torch.float32)
450+
j_val = torch.tensor([[b] for _a, b, _c in coords], dtype=torch.float32)
451+
k_val = torch.tensor([[c] for _a, _b, c in coords], dtype=torch.float32)
452+
inputs = {"i": i_val, "j": j_val, "k": k_val}
453+
expected = _table_lookup_3d_reference(
454+
table, i_val, j_val, k_val, sharpness=10.0
455+
)
456+
457+
cache = reference_eval(out_node, inputs, len(coords))
458+
assert torch.allclose(cache[out_node], expected, atol=5e-3)
459+
460+
461+
def test_table_lookup_3d_inner_and_vector_transition_match_reference():
462+
# Half-integer transition on B (axis 1) and C (axis 2), A held integer.
463+
table = torch.tensor(
464+
[
465+
[[0.0, 10.0], [20.0, 30.0]],
466+
[[40.0, 50.0], [60.0, 70.0]],
467+
],
468+
dtype=torch.float32,
469+
)
470+
sharpness = 10.0
471+
out_node = _build_table_lookup_3d_graph(table, sharpness=sharpness)
472+
assert _table_lookup_3d_axis_order(table.shape) == (0, 1, 2)
473+
474+
inputs = {
475+
"i": torch.tensor([[0.0], [1.0], [0.0]], dtype=torch.float32),
476+
"j": torch.tensor([[0.5], [0.0], [0.5]], dtype=torch.float32), # B transition
477+
"k": torch.tensor([[0.0], [0.5], [0.5]], dtype=torch.float32), # C transition
478+
}
479+
expected = _table_lookup_3d_reference(
480+
table, inputs["i"], inputs["j"], inputs["k"], sharpness=sharpness
481+
)
482+
483+
cache = reference_eval(out_node, inputs, 3)
484+
assert torch.allclose(cache[out_node], expected, atol=5e-3)
485+
486+
report = probe_graph(
487+
out_node,
488+
pos_encoding=None,
489+
input_values=inputs,
490+
n_pos=3,
491+
d=512,
492+
d_head=16,
493+
verbose=False,
494+
atol=5e-3,
495+
)
496+
assert report.first_divergent is None, report.format_short()
497+
498+
499+
def test_table_lookup_3d_outer_axis_must_be_integer():
500+
table = torch.arange(2 * 3 * 4, dtype=torch.float32).reshape(2, 3, 4)
501+
out_node = _build_table_lookup_3d_graph(table)
502+
# A = axis 0; a clearly non-integer value there must fail the assert.
503+
inputs = {
504+
"i": torch.tensor([[0.5]], dtype=torch.float32),
505+
"j": torch.tensor([[1.0]], dtype=torch.float32),
506+
"k": torch.tensor([[2.0]], dtype=torch.float32),
507+
}
508+
with pytest.raises(AssertionError):
509+
reference_eval(out_node, inputs, 1)
510+
511+
512+
def test_table_lookup_3d_outer_axis_override():
513+
table = torch.arange(8 * 4 * 2, dtype=torch.float32).reshape(8, 4, 2) + 0.5
514+
# Default outer axis would be axis 2 (size 2); override to axis 0.
515+
assert _table_lookup_3d_axis_order(table.shape) == (2, 1, 0)
516+
assert _table_lookup_3d_axis_order(table.shape, outer_axis=0) == (0, 2, 1)
517+
out_node = _build_table_lookup_3d_graph(table, outer_axis=0)
518+
519+
coords = [(0, 0, 0), (7, 3, 1), (4, 1, 0), (2, 2, 1)]
520+
i_val = torch.tensor([[float(a)] for a, _b, _c in coords], dtype=torch.float32)
521+
j_val = torch.tensor([[float(b)] for _a, b, _c in coords], dtype=torch.float32)
522+
k_val = torch.tensor([[float(c)] for _a, _b, c in coords], dtype=torch.float32)
523+
inputs = {"i": i_val, "j": j_val, "k": k_val}
524+
direct = torch.tensor(
525+
[[float(table[a, b, c])] for a, b, c in coords], dtype=torch.float32
526+
)
527+
528+
cache = reference_eval(out_node, inputs, len(coords))
529+
assert torch.allclose(cache[out_node], direct, atol=5e-3)
530+
531+
532+
def test_table_lookup_3d_rejects_bad_shapes_and_scales():
533+
i = create_input("i", 1)
534+
j = create_input("j", 1)
535+
k = create_input("k", 1)
536+
with pytest.raises(ValueError): # 2D table
537+
table_lookup_3d(i, j, k, torch.zeros(3, 4))
538+
with pytest.raises(ValueError): # empty axis
539+
table_lookup_3d(i, j, k, torch.zeros(3, 0, 4))
540+
with pytest.raises(ValueError): # wrong-length index_scale
541+
table_lookup_3d(i, j, k, torch.zeros(3, 4, 5), index_scale=(1.0, 2.0))
542+
with pytest.raises(ValueError): # sharpness must be > 1
543+
table_lookup_3d(i, j, k, torch.zeros(3, 4, 5), sharpness=1.0)
544+
with pytest.raises(ValueError): # bad outer_axis
545+
table_lookup_3d(i, j, k, torch.zeros(3, 4, 5), outer_axis=3)
546+
547+
226548
# ---------------------------------------------------------------------------
227549
# dynamic_extract
228550
# ---------------------------------------------------------------------------

torchwright/ops/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
select,
5555
switch,
5656
table_lookup_2d,
57+
table_lookup_3d,
5758
)
5859

5960
# I/O nodes

0 commit comments

Comments
 (0)