|
| 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 |
0 commit comments