Skip to content

Commit dcf4be4

Browse files
thomasahleclaude
andcommitted
correspond.py: step-to-step atom matching for animation (plan M3)
match(before, after) classifies every drawable atom occurrence in two consecutive derivation steps as moved / copy / merge / birth / death -- the input the animation compiler (plan component 5) consumes to emit Transform / TransformFromCopy / FadeOut / FadeIn. Pass 1 exploits immutability + structural sharing: rewrites rebuild only the spine, so untouched subtrees are the SAME Python objects in both steps; shared regions match exactly, surplus after-occurrences are copies (distribution), surplus before-occurrences merge. Pass 2 matches the rewrite site by (kind, label) signature deterministically; leftovers are births/deaths. audit() guarantees every atom on both sides is classified exactly once (the wire-conservation idea applied to correspondence). Confirmed empirically: A(B+C) -> AB+AC reuses the same A leaf object, matching as one moved + one TransformFromCopy. 5 tests, mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018PzG3QbNtaFmABBHBG39wp
1 parent c8eae7f commit dcf4be4

2 files changed

Lines changed: 336 additions & 0 deletions

File tree

tensorgrad/extras/correspond.py

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
"""Step-to-step object correspondence for animating derivations.
2+
3+
``match(before, after)`` classifies every drawable atom occurrence in two
4+
consecutive derivation steps as moved / copy / merge / birth / death --
5+
the input an animation compiler needs to emit Transform /
6+
TransformFromCopy / FadeOut / FadeIn per the SKILL.md grammar
7+
(see paper/animations/MANIM_BACKEND_PLAN.md, component 3).
8+
9+
Two passes:
10+
11+
1. **Identity.** Tensors are immutable and rewrite rules rebuild only the
12+
spine of the rewrite, so every untouched subtree in ``after`` is the
13+
SAME Python object as in ``before``. Shared subtrees match exactly,
14+
atom by atom. A subtree appearing more times after than before means
15+
the rule copied it (distribution): the surplus occurrences are
16+
``copies`` of the first before-occurrence. More before than after
17+
means a merge (factoring).
18+
2. **Signature.** Atoms not covered (the rewrite site itself) are matched
19+
greedily by (kind, label) in deterministic path order. Leftovers are
20+
births (grow in) and deaths (fade out).
21+
22+
Every atom on both sides ends up in exactly one bucket -- ``audit()``
23+
checks this, mirroring the wire-conservation audit in book_layout.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
from dataclasses import dataclass, field
29+
from typing import Any
30+
31+
from tensorgrad.structure import structure_of
32+
from tensorgrad.tensor import Delta, Derivative, Function, Variable, Zero
33+
from tensorgrad.extras.expectation import Expectation
34+
35+
Path = tuple[int, ...]
36+
37+
38+
@dataclass(frozen=True)
39+
class Occ:
40+
"""One drawable atom occurrence, addressed by its path from the root
41+
(child indices), so two occurrences of the same Variable are distinct."""
42+
43+
path: Path
44+
kind: str
45+
label: str
46+
47+
48+
@dataclass
49+
class Matching:
50+
# (before, after) pairs matched one-to-one -- animate as Transform/move
51+
moved: list[tuple[Occ, Occ]] = field(default_factory=list)
52+
# (source-before, new-after): the rule duplicated this atom's subtree
53+
# -- animate as TransformFromCopy from the source
54+
copies: list[tuple[Occ, Occ]] = field(default_factory=list)
55+
# (dying-before, target-after): several before-occurrences collapsed
56+
# into one -- animate as many-to-one Transform
57+
merges: list[tuple[Occ, Occ]] = field(default_factory=list)
58+
births: list[Occ] = field(default_factory=list) # FadeIn / grow
59+
deaths: list[Occ] = field(default_factory=list) # FadeOut
60+
61+
def audit(self, n_before: int, n_after: int) -> None:
62+
"""Every atom on both sides is classified exactly once."""
63+
b = len(self.moved) + len(self.merges) + len(self.deaths)
64+
a = len(self.moved) + len(self.copies) + len(self.merges) + len(self.births)
65+
if b != n_before or a != n_after:
66+
raise AssertionError(
67+
f"correspondence dropped atoms: classified {b}/{n_before}"
68+
f" before, {a}/{n_after} after"
69+
)
70+
71+
72+
def _kids(t: Any) -> tuple:
73+
# the wrt Variable of a Derivative (and the Expectation's variable) are
74+
# not drawn as atoms -- they appear as the ellipse's dX tag / the E box
75+
if isinstance(t, Derivative):
76+
return (t.tensor,)
77+
if isinstance(t, Expectation):
78+
return (t.tensor,)
79+
try:
80+
return tuple(structure_of(t).children)
81+
except NotImplementedError:
82+
return ()
83+
84+
85+
def _is_atom(t: Any) -> bool:
86+
if isinstance(t, (Variable, Zero, Function)):
87+
return True
88+
# an order-2 Delta is a plain wire, not a glyph; other orders draw as
89+
# copy-dots / scalar rings
90+
if isinstance(t, Delta):
91+
return t.order != 2
92+
return False
93+
94+
95+
def _label(t: Any) -> str:
96+
if isinstance(t, Variable):
97+
return t.name
98+
if isinstance(t, Function):
99+
return t.signature.name
100+
if isinstance(t, Delta):
101+
return "copydot"
102+
if isinstance(t, Zero):
103+
return "0"
104+
return type(t).__name__ # pragma: no cover
105+
106+
107+
def _kind(t: Any) -> str:
108+
if isinstance(t, Variable):
109+
return "var"
110+
if isinstance(t, Function):
111+
return "func"
112+
if isinstance(t, Delta):
113+
return "copydot"
114+
if isinstance(t, Zero):
115+
return "zero"
116+
return "other" # pragma: no cover
117+
118+
119+
def _atoms_under(t: Any, path: Path = ()) -> list[Occ]:
120+
out: list[Occ] = []
121+
if _is_atom(t):
122+
out.append(Occ(path, _kind(t), _label(t)))
123+
for i, c in enumerate(_kids(t)):
124+
out.extend(_atoms_under(c, path + (i,)))
125+
return out
126+
127+
128+
def _subtree_index(t: Any) -> dict[int, list[tuple[Path, Any]]]:
129+
"""id(subtree) -> occurrences (pre-order). Structural sharing means one
130+
object can occur at several paths."""
131+
idx: dict[int, list[tuple[Path, Any]]] = {}
132+
133+
def rec(n: Any, path: Path) -> None:
134+
idx.setdefault(id(n), []).append((path, n))
135+
for i, c in enumerate(_kids(n)):
136+
rec(c, path + (i,))
137+
138+
rec(t, ())
139+
return idx
140+
141+
142+
def match(before: Any, after: Any) -> Matching:
143+
"""Classify every atom occurrence of ``before`` and ``after``."""
144+
m = Matching()
145+
before_idx = _subtree_index(before)
146+
147+
# ---- pass 1: shared-object regions (top-down, maximal) ----
148+
# pool[obj_id] = unconsumed before-paths for that object
149+
pool: dict[int, list[Path]] = {
150+
k: [p for p, _ in v] for k, v in before_idx.items()
151+
}
152+
first_before: dict[int, Path] = {
153+
k: v[0][0] for k, v in before_idx.items()
154+
}
155+
consumed_before: list[tuple[Path, Any]] = [] # regions matched in pass 1
156+
matched_after_regions: list[tuple[Path, Path, Any, str]] = []
157+
# (before_path, after_path, obj, tag)
158+
159+
def walk_after(n: Any, path: Path) -> None:
160+
entries = pool.get(id(n))
161+
if entries is not None and _shares_ref(n):
162+
if entries:
163+
bp = entries.pop(0)
164+
consumed_before.append((bp, n))
165+
matched_after_regions.append((bp, path, n, "moved"))
166+
else:
167+
matched_after_regions.append(
168+
(first_before[id(n)], path, n, "copy"))
169+
return # a maximal shared region covers all its atoms
170+
for i, c in enumerate(_kids(n)):
171+
walk_after(c, path + (i,))
172+
173+
def _shares_ref(n: Any) -> bool:
174+
# scalars / trivial leaves are interned by Python (small ints etc.)
175+
# -- only trust identity for actual Tensor nodes
176+
return _is_atom(n) or bool(_kids(n))
177+
178+
walk_after(after, ())
179+
180+
covered: set[Path] = set() # consumed region ROOT paths in before
181+
for bp, ap, obj, tag in matched_after_regions:
182+
rel = _atoms_under(obj)
183+
for occ in rel:
184+
b_occ = Occ(bp + occ.path, occ.kind, occ.label)
185+
a_occ = Occ(ap + occ.path, occ.kind, occ.label)
186+
if tag == "moved":
187+
m.moved.append((b_occ, a_occ))
188+
else:
189+
m.copies.append((b_occ, a_occ))
190+
if tag == "moved":
191+
covered.add(bp)
192+
193+
# before-regions whose object also appears in after but was consumed
194+
# fewer times: their atoms MERGE into the first after-occurrence
195+
after_first: dict[int, Path] = {}
196+
197+
def walk_after_first(n: Any, path: Path) -> None:
198+
after_first.setdefault(id(n), path)
199+
for i, c in enumerate(_kids(n)):
200+
walk_after_first(c, path + (i,))
201+
202+
walk_after_first(after, ())
203+
204+
leftover_before: list[Occ] = []
205+
206+
def walk_before(n: Any, path: Path) -> None:
207+
if path in covered:
208+
return # region already matched one-to-one
209+
if id(n) in after_first and _shares_ref(n) and pool.get(id(n)) is not None:
210+
still = pool[id(n)]
211+
if path in still:
212+
# object exists in after but this occurrence was surplus
213+
still.remove(path)
214+
ap = after_first[id(n)]
215+
for occ in _atoms_under(n):
216+
m.merges.append((
217+
Occ(path + occ.path, occ.kind, occ.label),
218+
Occ(ap + occ.path, occ.kind, occ.label),
219+
))
220+
return
221+
if _is_atom(n):
222+
leftover_before.append(Occ(path, _kind(n), _label(n)))
223+
for i, c in enumerate(_kids(n)):
224+
walk_before(c, path + (i,))
225+
226+
walk_before(before, ())
227+
228+
# ---- pass 2: signature matching at the rewrite site ----
229+
covered_after = {a.path for _, a in m.moved}
230+
covered_after |= {a.path for _, a in m.copies}
231+
all_after = _atoms_under(after)
232+
leftover_after = [o for o in all_after if o.path not in covered_after
233+
and o.path not in {a.path for _, a in m.merges}]
234+
235+
by_sig_after: dict[tuple[str, str], list[Occ]] = {}
236+
for o in sorted(leftover_after, key=lambda o: o.path):
237+
by_sig_after.setdefault((o.kind, o.label), []).append(o)
238+
for o in sorted(leftover_before, key=lambda o: o.path):
239+
bucket = by_sig_after.get((o.kind, o.label))
240+
if bucket:
241+
m.moved.append((o, bucket.pop(0)))
242+
else:
243+
m.deaths.append(o)
244+
m.births.extend(o for bucket in by_sig_after.values() for o in bucket)
245+
246+
m.audit(len(_atoms_under(before)), len(all_after))
247+
return m

tests/extras/test_correspond.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Correspondence matcher tests on real simplify-step pairs.
2+
3+
Each test hand-checks the classification the animation compiler will
4+
consume: moved (Transform), copies (TransformFromCopy), merges,
5+
births/deaths. match() itself audits total classification, so every test
6+
also proves no atom was silently dropped.
7+
"""
8+
9+
from sympy import symbols
10+
11+
from tensorgrad import Variable
12+
from tensorgrad.tensor import Product, Sum
13+
from tensorgrad.extras.correspond import match
14+
15+
n = symbols("n")
16+
17+
18+
def test_identical_steps_all_moved():
19+
A = Variable("A", i=n, j=n)
20+
B = Variable("B", j=n, k=n)
21+
t = A @ B
22+
m = match(t, t)
23+
assert len(m.moved) == 2
24+
assert not m.copies and not m.merges and not m.births and not m.deaths
25+
# exact identity: paths map 1:1
26+
assert all(b.path == a.path for b, a in m.moved)
27+
28+
29+
def test_distribution_creates_copies():
30+
# A (B + C) -> A B + A C : the rule reuses the SAME A leaf object in
31+
# both terms, so one occurrence is moved and the surplus is a copy.
32+
A = Variable("A", i=n, j=n)
33+
B = Variable("B", j=n, k=n)
34+
C = Variable("C", j=n, k=n)
35+
before = Product([A, Sum([B, C])])
36+
after = before.simplify({"expand": True})
37+
assert isinstance(after, Sum) and len(after.terms) == 2
38+
39+
m = match(before, after)
40+
labels_moved = sorted(b.label for b, _ in m.moved)
41+
# B and C move; A moves once
42+
assert labels_moved == ["A", "B", "C"]
43+
# ...and appears once more as a copy
44+
assert [b.label for b, _ in m.copies] == ["A"]
45+
assert not m.births and not m.deaths and not m.merges
46+
47+
48+
def test_gradient_step_classifies_everything():
49+
# d(x^T A x)/dx -> A x + A^T x (shapes vary by simplification), with
50+
# A and x duplicated across terms. We assert full classification and
51+
# that no Variable dies: differentiation only rearranges/copies them.
52+
i = symbols("i")
53+
x = Variable("x", i=i)
54+
A = Variable("A", i=i, j=i)
55+
q = x.rename(i="j") @ A @ x
56+
before = q.grad(x, {"i": "p"})
57+
after = before.simplify()
58+
59+
m = match(before, after)
60+
# the quadratic's atoms (x, A, x) all survive into the two terms
61+
dead_vars = [d for d in m.deaths if d.kind == "var"]
62+
assert not dead_vars
63+
# something was duplicated by the product rule
64+
assert m.copies or m.merges
65+
66+
67+
def test_factor_out_of_product_keeps_identity():
68+
# (A B) C -> associativity/merge inside simplify: atoms all survive.
69+
A = Variable("A", i=n, j=n)
70+
B = Variable("B", j=n, k=n)
71+
C = Variable("C", k=n, l=n)
72+
before = Product([Product([A, B]), C])
73+
after = before.simplify()
74+
m = match(before, after)
75+
assert sorted(b.label for b, _ in m.moved) == ["A", "B", "C"]
76+
assert not m.births and not m.deaths
77+
78+
79+
def test_audit_counts_are_totals():
80+
A = Variable("A", i=n, j=n)
81+
B = Variable("B", j=n, k=n)
82+
C = Variable("C", j=n, k=n)
83+
before = Product([A, Sum([B, C])])
84+
after = before.simplify({"expand": True})
85+
m = match(before, after)
86+
# 3 atoms before; 4 after (A twice)
87+
assert len(m.moved) + len(m.merges) + len(m.deaths) == 3
88+
assert (len(m.moved) + len(m.copies) + len(m.merges)
89+
+ len(m.births)) == 4

0 commit comments

Comments
 (0)