File size: 8,035 Bytes
8565ab7
 
 
 
 
 
 
 
 
 
 
 
 
 
5e066a0
8565ab7
 
 
 
5e066a0
8565ab7
 
 
 
 
 
 
 
5e066a0
 
 
 
 
8565ab7
 
 
5e066a0
8565ab7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e066a0
 
 
63abd61
 
 
 
 
 
 
 
5e066a0
63abd61
 
5e066a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
"""Notation and output formatting for the shared mathematical representation.

Views own layout; this module owns all mathematical spelling and escaping.
"""
from html import escape
import re

import numpy as np

from math_expression import Expr
from tensor_formatter import format_tensor_value


class MathRenderer:
    def __init__(self, notation='mathbook', html=False, format_config=None):
        if notation not in {'mathbook', 'code'}:
            raise ValueError('Unknown notation')
        self.notation = notation
        self.html = html
        self.format_config = format_config

    def text(self, value):
        return escape(str(value)) if self.html else str(value)

    def equation(self, equation):
        return ' = '.join(self.expression(term) for term in equation.terms)

    def expression(self, expr):
        """Spell one Expr as one indivisible equation stage.

        Never emits line breaks or block-level markup: layout belongs to
        render_derivation(), which may only break *between* stages.
        """
        kind, args = expr.kind, expr.args
        mb = self.notation == 'mathbook'
        if kind == 'literal':
            return self.text(format_tensor_value(expr.value, config=self.format_config))
        if kind in {'ref', 'slot'}:
            return self.text(expr.name)
        if kind == 'adjoint':
            name = self.text(expr.name)
            if not mb:
                return name + '_bar'
            if self.html:
                return f"<span style='text-decoration:overline'>{name}</span>"
            match = re.match(r'^([a-zA-Z]+)(.*)$', name)
            return f'{match[1]}̄{match[2]}' if match else name + '̄'
        if kind == 'contribution':
            source, target = (self.expression(a) for a in args)
            suffix = f', {expr.value}' if expr.value is not None else ''
            if not mb:
                return f'c_{source}_to_{target}' + (f'_{expr.value}' if expr.value is not None else '')
            sub = f'{source}→{target}{suffix}'
            return f'<i>c</i><sub>{sub}</sub>' if self.html else f'c_{sub}'
        if kind in {'partial', 'global_derivative'}:
            first, target = (self.expression(a) for a in args)
            if not mb:
                return f'diff({first}, {target})'
            return f'∂({first})/∂{target}' if kind == 'partial' else f'∂{first}/∂{target}'
        if kind == 'neg':
            return ('−' if mb else '-') + self.group(args[0])
        if kind in {'add', 'sub', 'mul', 'elementwise', 'scale', 'div', 'matmul'}:
            joins = {'add': ' + ', 'sub': ' − ' if mb else ' - ',
                     'mul': ' · ' if mb else ' * ', 'scale': ' · ' if mb else ' * ',
                     'elementwise': ' ⊙ ' if mb else ' * ', 'div': ' / ', 'matmul': ' @ '}
            # Explicit grouping makes the same expression unambiguous in every view.
            rendered = [self.expression(a) if kind == 'add' and a.kind != 'neg' else self.group(a) for a in args]
            return joins[kind].join(rendered)
        if kind == 'pow':
            base = self.group(args[0])
            exponent = self.expression(args[1])
            if mb and self.html:
                return f'{base}<sup>{exponent}</sup>'
            return base + ('^' if mb else '**') + self.group(args[1])
        if kind == 'transpose':
            return self.group(args[0]) + ('<sup>T</sup>' if mb and self.html else ('ᵀ' if mb else '.T'))
        if kind == 'outer' and mb and not any(a.kind == 'literal' for a in args):
            # Named vectors conventionally denote columns. Array previews are
            # horizontal lists, so numerical applications keep explicit outer().
            return self.group(args[0]) + ' ' + self.group(args[1]) + ('<sup>T</sup>' if self.html else 'ᵀ')
        if kind == 'pick':
            return self.expression(args[0]) + '[' + self.expression(args[1]) + ']'
        if kind == 'positive':
            return f'mask({self.expression(args[0])} {self.text("> ")}0)'
        if kind == 'equal':
            return f'mask({self.expression(args[0])} == {self.expression(args[1])})'
        name = '∑' if kind == 'sum' and mb else kind
        return self.text(name) + '(' + ', '.join(self.expression(a) for a in args) + ')'

    def group(self, expr):
        text = self.expression(expr)
        if expr.kind in {'add', 'sub', 'mul', 'elementwise', 'scale', 'div', 'matmul', 'neg'}:
            return '(' + text + ')'
        if expr.kind == 'literal' and np.ndim(expr.value) == 0 and float(expr.value) < 0:
            return '(' + text + ')'
        return text

    def backward_rule(self, explanation):
        """Upper trapezoid compartment. Bindings and rule are separate equations,
        so the break between them is semantic; each equation stays atomic."""
        lines = [self.equation(b) for b in explanation.bindings]
        mapping = ', '.join(lines)
        eq_lines = []
        if mapping:
            eq_lines.append(mapping)
        if getattr(explanation, 'local_derivative_rule', None) is not None:
            eq_lines.append(self.equation(explanation.local_derivative_rule))
        eq_lines.append(self.equation(explanation.local_rule))
        if self.html:
            return '<br>'.join(self.atomic(line) for line in eq_lines)
        return '\n'.join(eq_lines)

    def atomic(self, text):
        """Mark one complete equation stage (or line of stages) as unbreakable.

        This is the authoritative no-internal-wrap rule. It is inline because the
        diagram is rendered by mermaid.ink, which never sees the app's CSS.
        """
        return f'<span class="math-stage" style="{NOWRAP}">{text}</span>' if self.html else text

    # Decision B only: *whether* to use the permitted stage-boundary breaks.
    # Decision A (where breaks may occur) is fixed: only between stages.
    INLINE_CHAIN_BUDGET = 45

    def should_stack_derivation(self, derivation, rendered_lhs, rendered_stages):
        if len(rendered_stages) <= 1:
            return False
        if len(rendered_stages) >= 3:
            return True
        plain = [re.sub(r'<[^>]+>', '', s) for s in (rendered_lhs, *rendered_stages)]
        return sum(len(s) for s in plain) + 3 * len(rendered_stages) > self.INLINE_CHAIN_BUDGET

    def render_derivation(self, derivation, layout="auto"):
        """Render a derivation inline (`A = B = C`) or stacked (`A = B` / `= C`).

        Each stage comes from expression() as one atomic unit; a line break is
        only ever placed between equality stages. layout: auto | inline | stacked.
        """
        lhs_str = self.expression(derivation.lhs)
        stages_strs = [self.expression(st) for st in derivation.stages]
        if not stages_strs:
            return self.atomic(lhs_str)

        stack = layout == "stacked" or (
            layout == "auto" and self.should_stack_derivation(derivation, lhs_str, stages_strs))

        if not stack:
            return self.atomic(f"{lhs_str} = " + " = ".join(stages_strs))

        if not self.html:
            indent = ' ' * len(lhs_str)
            return "\n".join([f"{lhs_str} = {stages_strs[0]}"] + [f"{indent} = {st}" for st in stages_strs[1:]])

        lines = [f'<div class="derivation-line" style="{NOWRAP}">{lhs_str} = {stages_strs[0]}</div>']
        lines += [f'<div class="derivation-line derivation-continuation" style="{NOWRAP}">= {st}</div>'
                  for st in stages_strs[1:]]
        return f'<div class="derivation">{"".join(lines)}</div>'

    def application_derivation(self, explanation, layout="auto"):
        """Lower trapezoid compartment: concrete application and result."""
        return self.render_derivation(explanation.application_derivation, layout=layout)

    def explanation(self, explanation):
        """Return the shared upper/lower compartments; containers belong to each view."""
        return self.backward_rule(explanation), self.application_derivation(explanation)


NOWRAP = 'white-space:nowrap'