From b3b18857f0d9e9c6b50f73d3afe84f5bac185e13 Mon Sep 17 00:00:00 2001 From: saymrwulf Date: Thu, 16 Apr 2026 12:05:32 +0200 Subject: [PATCH] Harden notebook animation rendering --- ntt_learning/visuals.py | 121 ++++++++++++++++++++++++++++++++++------ tests/test_visuals.py | 65 +++++++++++++++++++-- 2 files changed, 164 insertions(+), 22 deletions(-) diff --git a/ntt_learning/visuals.py b/ntt_learning/visuals.py index 102024d..76f3edb 100644 --- a/ntt_learning/visuals.py +++ b/ntt_learning/visuals.py @@ -105,7 +105,7 @@ def _player_widget( def render(index: int) -> None: frame_html.value = f""" -
+
{frames[index]}
""" @@ -153,6 +153,55 @@ def _svg_text(x: float, y: float, text: str, *, size: int = 14, weight: str = "4 return f'{escape(text)}' +def _svg_multiline_text( + x: float, + y: float, + text: str, + *, + max_chars: int = 42, + line_height: int = 16, + size: int = 14, + weight: str = "400", + fill: str = SVG_INK, + anchor: str = "start", +) -> str: + words = text.split() + if not words: + return "" + + lines: list[str] = [] + current = words[0] + for word in words[1:]: + candidate = f"{current} {word}" + if len(candidate) <= max_chars: + current = candidate + else: + lines.append(current) + current = word + lines.append(current) + + return "".join( + _svg_text( + x, + y + index * line_height, + line, + size=size, + weight=weight, + fill=fill, + anchor=anchor, + ) + for index, line in enumerate(lines) + ) + + +def _svg_canvas_open(width: int, height: int) -> str: + return ( + f'' + ) + + def _html_token(label: str, value: str, *, fill: str, border: str, text: str = SVG_INK) -> str: return f"""
', + _svg_canvas_open(width, height), f'', _svg_text(34, 48, "Wraparound Comparison Player", size=22, weight="800"), - _svg_text(34, 68, f"Current source term: x^{current_index} with coefficient {current_value}", size=13, fill="#486581"), - _svg_text(34, top_y - 14, "Raw convolution tail", size=14, weight="700"), - _svg_text(34, cyclic_y - 14, "Cyclic fold into x^n - 1", size=14, weight="700", fill=SVG_BLUE), - _svg_text(34, neg_y - 14, "Negacyclic fold into x^n + 1", size=14, weight="700", fill=SVG_ACCENT), + _svg_text(34, 74, f"Current source term: x^{current_index} with coefficient {current_value}", size=13, fill="#486581"), + _svg_text(34, top_y - 18, "Raw convolution tail", size=14, weight="700"), + _svg_text(34, cyclic_y - 18, "Cyclic fold into x^n - 1", size=14, weight="700", fill=SVG_BLUE), + _svg_text(34, neg_y - 18, "Negacyclic fold into x^n + 1", size=14, weight="700", fill=SVG_ACCENT), ] for index, coefficient in enumerate(coefficients): @@ -493,7 +542,7 @@ def _direct_ntt_frame_svg(values: Sequence[int], modulus: int, psi: int, output_ final = sum(product for _, _, product in contributions) % modulus parts = [ - f'', + _svg_canvas_open(width, height), f'', _svg_text(34, 48, "Direct NTTψ Contribution Player", size=22, weight="800"), _svg_text(34, 68, f"Building output slot j={output_index}, currently consuming input i={input_index}", size=13, fill="#486581"), @@ -537,8 +586,27 @@ def _direct_ntt_frame_svg(values: Sequence[int], modulus: int, psi: int, output_ parts.append(_svg_box(520 + index * 90, boxes_y, 74, 54, f"a{index}·w", str(product), fill=fill, stroke=stroke)) parts.append(_svg_text(556 + index * 90, boxes_y - 10, f"factor {factor}", size=11, anchor="middle")) - parts.append(_svg_text(34, height - 60, f"Current partial sum for j={output_index}: {partial} mod {modulus}", size=15, weight="700")) - parts.append(_svg_text(34, height - 34, f"Completed output slot y{output_index}: {final} mod {modulus}", size=15, weight="700", fill=SVG_ACCENT)) + parts.append( + _svg_multiline_text( + 34, + height - 68, + f"Current partial sum for j={output_index}: {partial} mod {modulus}", + max_chars=52, + size=15, + weight="700", + ) + ) + parts.append( + _svg_multiline_text( + 34, + height - 36, + f"Completed output slot y{output_index}: {final} mod {modulus}", + max_chars=52, + size=15, + weight="700", + fill=SVG_ACCENT, + ) + ) parts.append("") return "".join(parts) @@ -575,7 +643,7 @@ def _butterfly_story_frame_svg(trace: TransformTrace, stage_index: int, pair_ind spacing = 92 start_x = 60 parts = [ - f'', + _svg_canvas_open(width, height), f'', _svg_text(34, 48, f"{trace.algorithm.upper()} Butterfly Player", size=22, weight="800"), _svg_text(34, 68, f"Stage {stage.stage_index}, active pair ({left}, {right}), zeta={zeta}", size=13, fill="#486581"), @@ -603,8 +671,29 @@ def _butterfly_story_frame_svg(trace: TransformTrace, stage_index: int, pair_ind x_right = start_x + right * spacing + 32 parts.append(f'') parts.append(_svg_text((x_left + x_right) / 2, 196, f"pair ({left}, {right})", size=12, weight="700", fill=SVG_ACCENT, anchor="middle")) - parts.append(_svg_text((x_left + x_right) / 2, 214, f"inputs -> outputs = ({stage.input_values[left]}, {stage.input_values[right]}) -> ({stage.output_values[left]}, {stage.output_values[right]})", size=11, anchor="middle")) - parts.append(_svg_text((x_left + x_right) / 2, 232, stage.note, size=11, anchor="middle", fill="#486581")) + parts.append( + _svg_multiline_text( + (x_left + x_right) / 2, + 214, + f"inputs -> outputs = ({stage.input_values[left]}, {stage.input_values[right]}) -> ({stage.output_values[left]}, {stage.output_values[right]})", + max_chars=34, + line_height=14, + size=11, + anchor="middle", + ) + ) + parts.append( + _svg_multiline_text( + (x_left + x_right) / 2, + 242, + stage.note, + max_chars=36, + line_height=14, + size=11, + anchor="middle", + fill="#486581", + ) + ) parts.append("") return "".join(parts) diff --git a/tests/test_visuals.py b/tests/test_visuals.py index 9f7e1a2..6a7299f 100644 --- a/tests/test_visuals.py +++ b/tests/test_visuals.py @@ -1,10 +1,33 @@ from __future__ import annotations +import os import unittest import ipywidgets as widgets -from ntt_learning.visuals import _convolution_frame_html, schoolbook_diagonal_player +from ntt_learning.course import REPO_ROOT + +MPLCONFIGDIR = REPO_ROOT / ".cache" / "matplotlib" +MPLCONFIGDIR.mkdir(parents=True, exist_ok=True) +os.environ.setdefault("MPLCONFIGDIR", str(MPLCONFIGDIR)) + +from ntt_learning.toy_ntt import fast_ntt_psi_ct_trace, find_psi +from ntt_learning.visuals import ( + _convolution_frame_html, + butterfly_story_player, + direct_ntt_player, + schoolbook_diagonal_player, + wraparound_comparison_player, +) + + +def player_parts(player: widgets.Widget) -> tuple[widgets.IntSlider, widgets.HTML, widgets.HTML]: + content = player.children[1] + controls = content.children[0] + slider = controls.children[1] + frame_html = content.children[1] + caption_html = content.children[2] + return slider, frame_html, caption_html class VisualUxTests(unittest.TestCase): @@ -30,11 +53,7 @@ class VisualUxTests(unittest.TestCase): self.assertIsInstance(player, widgets.VBox) self.assertEqual(player.layout.width, "100%") - content = player.children[1] - controls = content.children[0] - slider = controls.children[1] - frame_html = content.children[1] - caption_html = content.children[2] + slider, frame_html, caption_html = player_parts(player) self.assertIsInstance(slider, widgets.IntSlider) self.assertEqual(slider.layout.width, "100%") @@ -45,6 +64,40 @@ class VisualUxTests(unittest.TestCase): self.assertIn("Active diagonal: y3", frame_html.value) self.assertIn("Frame 4 of", caption_html.value) + def test_svg_players_render_in_scrollable_non_shrinking_frames(self) -> None: + psi = find_psi(order=4, modulus=17) + trace = fast_ntt_psi_ct_trace([1, 2, 3, 4], modulus=17, psi=psi) + players = [ + wraparound_comparison_player([3, 0, 2, 1, 5, 4, 6], n=4), + direct_ntt_player([1, 2, 3, 4], modulus=17, psi=psi), + butterfly_story_player(trace), + ] + + for player in players: + slider, frame_html, _ = player_parts(player) + self.assertIsInstance(slider, widgets.IntSlider) + self.assertIn("overflow-x:auto", frame_html.value) + self.assertIn(" None: + psi = find_psi(order=4, modulus=17) + trace = fast_ntt_psi_ct_trace([1, 2, 3, 4], modulus=17, psi=psi) + players = [ + wraparound_comparison_player([3, 0, 2, 1, 5, 4, 6], n=4), + direct_ntt_player([1, 2, 3, 4], modulus=17, psi=psi), + butterfly_story_player(trace), + ] + + for player in players: + slider, frame_html, caption_html = player_parts(player) + before = frame_html.value + slider.value = 1 + self.assertNotEqual(before, frame_html.value) + self.assertIn("Frame 2 of", caption_html.value) + if __name__ == "__main__": unittest.main()