Spaces:
Running
Running
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'
|