"""Stepwise text trace renderer consuming ComputationModel. The trace linearizes forward evaluation and reverse-mode autodiff, producing a structured plaintext trace in execution order. """ from html import escape import json import re from math_expression import Expr, adjoint, literal, node, ref from math_renderer import MathRenderer from backward_rules import ScalarLocalRule from backward_explanation import Equation, contribution_symbol, adjoint_equation from teaching_examples import multiplication_example from tensor_formatter import format_tensor_value, DEFAULT_TENSOR_ELEMENT_BUDGET from number_format import format_numeric_literals def _format_val(val, config=None): if val is None: return "—" return format_tensor_value(val, element_budget=DEFAULT_TENSOR_ELEMENT_BUDGET, include_shape_if_truncated=True, config=config) def _adj_name(q, notation, format_config=None): return MathRenderer(notation, format_config=format_config).expression(adjoint(q)) def _partial_sym(source_name, target_name, notation, format_config=None): return MathRenderer(notation, format_config=format_config).expression(node('global_derivative', Expr('ref', key='output', name=source_name), Expr('ref', key='target', name=target_name))) def _op_formula_str(op, notation, format_config=None): return MathRenderer(notation, format_config=format_config).expression(op.formula) def _op_applied_str(op, notation, format_config=None): values = {q.uid: q.primal_value for q in op.operands} return MathRenderer(notation, format_config=format_config).expression(op.formula.bind(values)) def render_trace(model, notation='mathbook', mode='forward_backward', format_config=None): """Linearizes model into deterministic sequential execution steps. notation: 'mathbook' | 'code' mode: 'forward' | 'forward_backward' """ if notation not in {'mathbook', 'code'}: raise ValueError('Unknown notation') forward_only = (mode == 'forward' or model.forward_only) lines = [] # ========================================================================= # FORWARD PASS # ========================================================================= lines.append("FORWARD PASS") lines.append("=" * 12) lines.append("") lines.append("Inputs") lines.append("------") inputs = [q for q in model.quantities if q.is_input and not q.is_constant] for inp in inputs: lines.append(f"{inp.name} = {_format_val(inp.primal_value, config=format_config)}") lines.append("") step_idx = 1 for op in model.operations: res = op.quantity header_text = f"{step_idx}. {res.name} = {_op_formula_str(op, notation, format_config=format_config)} = {_format_val(res.primal_value, config=format_config)}" lines.append(header_text) lines.append("-" * len(header_text)) lines.append("Operands:") for operand in op.operands: if operand.is_constant: lines.append(f"{_format_val(operand.primal_value, config=format_config)}") else: lines.append(f"{operand.name} = {_format_val(operand.primal_value, config=format_config)}") lines.append("Apply:") lines.append(f"{_op_applied_str(op, notation, format_config=format_config)}") lines.append(f"= {_format_val(res.primal_value, config=format_config)}") lines.append("") step_idx += 1 lines.append("OUTPUT") lines.append("------") lines.append(f"{model.output_name} = {_format_val(model.root.primal_value, config=format_config)}") lines.append("") if forward_only or not model.root.differentiable: return "\n".join(lines).strip() # ========================================================================= # BACKWARD PASS # ========================================================================= lines.append("BACKWARD PASS") lines.append("=" * 13) lines.append("") lines.append("Seed") lines.append("----") root_adj = _adj_name(model.root, notation, format_config=format_config) root_deriv = _partial_sym(model.output_name, model.root.name, notation, format_config=format_config) lines.append(f"{root_adj} = {root_deriv} = {_format_val(model.root.adjoint_value, config=format_config)}") lines.append("") steps_by_source = {} for s in model.steps: steps_by_source.setdefault(s.source_quantity.uid, []).append(s) acc_by_dest = {acc.destination_quantity.uid: acc for acc in model.accumulations} contributions_received = {q.uid: [] for q in model.quantities} # Reverse topological execution through operations bwd_idx = step_idx for op in reversed(model.operations): res_q = op.quantity if res_q.uid not in steps_by_source: continue op_steps = steps_by_source[res_q.uid] if not op_steps: continue bwd_header = f"{bwd_idx}. Backpropagate through {res_q.name} = {_op_formula_str(op, notation, format_config=format_config)}" lines.append(bwd_header) lines.append("-" * len(bwd_header)) lines.append("Incoming adjoint:") lines.append(f"{_adj_name(res_q, notation, format_config=format_config)} = {_format_val(op_steps[0].incoming_adjoint, config=format_config)}") lines.append("") for step in op_steps: target = step.destination_quantity occ = step.operand_occurrence occ_header = f"Argument {occ + 1}: {target.name}" if len(op.operands) > 1 and len(set(q.uid for q in op.operands)) == 1 else f"To {target.name}:" lines.append(occ_header) upper, lower = MathRenderer(notation, format_config=format_config).explanation(step.explanation) lines.append("Local derivative:" if isinstance(step.rule, ScalarLocalRule) else "Local backward rule:") lines.append(upper) lines.append("Apply:") lines.append(lower) for note in step.explanation.notes: lines.append(f"Note: {note}") lines.append(f"Contribution to {target.name}:") lines.append(f"{_format_val(step.contribution, config=format_config)}") lines.append("") # Record contribution contributions_received[target.uid].append(step) # Check if all contributions for this target have arrived and accumulation can be emitted if target.uid in acc_by_dest: acc = acc_by_dest[target.uid] if len(contributions_received[target.uid]) == len(acc.contributions): acc_header = f"Accumulate contributions to {target.name}" lines.append(acc_header) lines.append("-" * len(acc_header)) # Use actual order of arrival: contributions_received[target.uid] contrib_vals = [_format_val(s.contribution, config=format_config) for s in contributions_received[target.uid]] contrib_sum_expr = " + ".join(contrib_vals) lines.append(f"{_adj_name(target, notation, format_config=format_config)} = {contrib_sum_expr}") lines.append(f"= {_format_val(acc.resulting_adjoint, config=format_config)}") lines.append("Adjoint:") root_name = model.output_name deriv_name = _partial_sym(root_name, target.name, notation, format_config=format_config) lines.append(f"{_adj_name(target, notation, format_config=format_config)} = {deriv_name} = {_format_val(acc.resulting_adjoint, config=format_config)}") lines.append("") elif len(contributions_received[target.uid]) == 1: # Single contribution establishing adjoint all_steps_for_target = [s for s in model.steps if s.destination_quantity.uid == target.uid] if len(all_steps_for_target) == 1: lines.append("Adjoint:") root_name = model.output_name deriv_name = _partial_sym(root_name, target.name, notation, format_config=format_config) lines.append(f"{_adj_name(target, notation, format_config=format_config)} = {deriv_name} = {_format_val(step.contribution, config=format_config)}") lines.append("") bwd_idx += 1 # Final gradients summary for input quantities diff_inputs = [q for q in model.quantities if q.is_input and q.adjoint_value is not None] if diff_inputs: lines.append("FINAL GRADIENTS") lines.append("=" * 15) root_name = model.output_name for inp in diff_inputs: partial = _partial_sym(root_name, inp.name, notation, format_config=format_config) adj = _adj_name(inp, notation, format_config=format_config) lines.append(f"{partial} = {adj} = {_format_val(inp.adjoint_value, config=format_config)}") return "\n".join(lines).strip() # ============================================================================= # HTML trace view # # Exactly two top-level box types, reusing the diagram's semantic color # contract: a Primal box (green, rounded) summarizes an established forward # value as one equation (quantity = symbolic rule = substituted rule = value); # an Adjoint box (violet, rectangular) summarizes an established total output # sensitivity and contains the complete local derivation needed to explain it. # Contributions, local derivative rules, and accumulation are explicit inside # Adjoint boxes but never become peer top-level boxes. # # Ownership: an operation's card PRODUCES contributions toward its operands # (shown as "Contribution to TARGET" subsections); a quantity's own card # RECEIVES contributions, owns accumulation, and stores the adjoint (the card # header). One quantity, one Adjoint box, regardless of how many operations # consume it. # ============================================================================= _TV_STYLE = """ """ def _esc_val(v, format_config=None): return escape(_format_val(v, config=format_config)) def _adj_symbol_html(q, notation, format_config=None): return MathRenderer(notation, html=True, format_config=format_config).expression(adjoint(q)) def _format_formula_str(formula_str, notation, format_config=None): if not formula_str: return "" s = format_numeric_literals(str(formula_str), config=format_config) if notation == 'mathbook': s = s.replace('**', '^') s = s.replace('*', ' · ') return s def _formula_card_html(model, notation, format_config=None): """Renders the selected formula and intermediate decomposed equations (n-notation).""" formula_disp = _format_formula_str(model.formula, notation, format_config=format_config) target_line = f"{escape(model.output_name)} = {escape(formula_disp)}" if formula_disp else escape(model.output_name) eq_lines = [] for op in model.operations: q_name = escape(op.quantity.name) formula = MathRenderer(notation, html=True, format_config=format_config).expression(op.formula) val = _esc_val(op.quantity.primal_value, format_config=format_config) eq_lines.append(f'
{q_name} = {formula} = {val}
') summary_text = f"Selected Formula: {target_line}" return ( '
' f'{summary_text}' '
' '
Selected Formula
' f'
{target_line}
' '
Decomposed Equations
' f'
{"".join(eq_lines)}
' '
' '
' ) def _primal_header_html(q, op, notation, format_config=None): """quantity = value, or quantity = symbolic rule = substituted rule = value.""" if op is None: return f"{escape(q.name)} = {_esc_val(q.primal_value, format_config=format_config)}" renderer = MathRenderer(notation, html=True, format_config=format_config) applied = op.formula.bind({a.uid: a.primal_value for a in op.operands}) return (f"{escape(q.name)} = {renderer.expression(op.formula)}" f" = {renderer.expression(applied)} = {_esc_val(q.primal_value, format_config=format_config)}") def _repeated_operand_suffix(op, step): """' (operand k)' only when the target occurs more than once among this operation's operands.""" occurrences = [i for i, a in enumerate(op.operands) if a.uid == step.destination_quantity.uid] if len(occurrences) > 1: return f" (operand {step.operand_occurrence + 1})" return "" def _forward_card_html(op, notation, format_config=None): res = op.quantity header = _primal_header_html(res, op, notation, format_config=format_config) renderer = MathRenderer(notation, html=True, format_config=format_config) operand_lines = [] for i, operand in enumerate(op.operands): if operand.is_constant: val_html = renderer.expression(literal(operand.primal_value)) operand_lines.append( f"
Operand {i + 1}: {val_html}
") else: eq_html = f"{renderer.expression(ref(operand))} = {renderer.expression(literal(operand.primal_value))}" operand_lines.append( f"
Operand {i + 1}: {eq_html}
") return (f'
{header}' f'
' f'
{"".join(operand_lines)}
') def _contribution_box_html(op, step, notation, format_config=None): """Views wrap shared mathematical equations; they never rebuild local rules.""" renderer = MathRenderer(notation, html=True, format_config=format_config) rule_line = renderer.backward_rule(step.explanation) result_line = renderer.application_derivation(step.explanation) notes = ''.join(f'
{escape(note)}
' for note in step.explanation.notes) return (f'
{rule_line}
' f'
{result_line}
{notes}') def _nograd_effect_html(op, q, notation, format_config=None): operand = op.operands[0] rule_line, result_line = MathRenderer(notation, html=True, format_config=format_config).explanation(op.stop_explanation) return (f'
Contribution to {escape(operand.name)}
' f'
{rule_line}
' f'
{result_line}
' f'
Effect
' f'

Stop backward propagation through {escape(operand.name)}.

') def _backward_card_html(q, op, notation, model, contributions_received, steps_by_source, format_config=None): is_root = q.uid == model.root.uid is_nograd = op is not None and op.function == 'nograd' received = contributions_received.get(q.uid, []) tags = [] if is_root: tags.append('seed') if is_nograd: tags.append('stop-gradient boundary') tags_html = "".join(tags) header = MathRenderer(notation, html=True, format_config=format_config).equation(adjoint_equation(q, model.output_name, q.adjoint_value)) body_parts = [f'
Primal
' f'{_primal_header_html(q, op, notation, format_config=format_config)}'] if len(received) > 1: body_parts.append('
') rec_lines = [] op_by_uid = {o.quantity.uid: o for o in model.operations} for s in received: producer = s.source_quantity producer_op = op_by_uid.get(producer.uid) suffix = _repeated_operand_suffix(producer_op, s) if producer_op else "" c_sub = MathRenderer(notation, html=True, format_config=format_config).expression(contribution_symbol(s)) rec_lines.append(f"
from {escape(producer.name)}{suffix}: {c_sub} = {_esc_val(s.contribution, format_config=format_config)}
") accumulate = " + ".join(_esc_val(s.contribution, format_config=format_config) for s in received) body_parts.append('
Contributions received
' + "".join(rec_lines) + '
Accumulate
' f'{_adj_symbol_html(q, notation, format_config=format_config)} = {accumulate} = {_esc_val(q.adjoint_value, format_config=format_config)}
') producing_steps = steps_by_source.get(q.uid, []) if is_nograd: body_parts.append('
') body_parts.append(_nograd_effect_html(op, q, notation, format_config=format_config)) elif producing_steps: body_parts.append('
') for i, step in enumerate(producing_steps): target = step.destination_quantity suffix = _repeated_operand_suffix(op, step) margin = ' style="margin-top:8px;"' if i > 0 else '' body_parts.append(f'
Contribution to {escape(target.name)}{suffix}
' f'{_contribution_box_html(op, step, notation, format_config=format_config)}') focus_btn = (f'') summary = f'{header}{tags_html}{focus_btn}' return (f'
{summary}' f'
{"".join(body_parts)}
') _TV_NOTATION_LEGEND = """
How to read the notation
{{primal}}

Primal — the value computed in the forward pass. Read from left to right: quantity = computation = values used = result.

{{adjoint}}

Adjoint — sensitivity of the final output. n1 tells us how sensitive the final output is to n1, including all downstream paths.

{{rule}}
{{application}}

Contribution — one local part of an adjoint. The first line gives the local derivative. The second combines it with the incoming adjoint to give this operand’s contribution to x. Here a and b name independent operand slots, both filled by x.

{{accumulation}}

When there is more than one contribution. Contributions from every use of a quantity are added to establish its adjoint.

Colors follow meaning: green = primal, violet = adjoint, magenta = contribution, rose = local derivative, gray = constant.
""" def _notation_legend(notation, forward_only=False): model = multiplication_example(forward_only) op = model.operations[0] renderer = MathRenderer(notation, html=True) primal = Equation((ref(op.quantity), op.formula, op.formula.bind({q.uid: q.primal_value for q in op.operands}), literal(op.quantity.primal_value))) if forward_only: return ('
How to read the notation' f'
{renderer.equation(primal)}
' '

Quantity = operation = substituted values = result.

') upper, lower = renderer.explanation(model.steps[0].explanation) acc = model.accumulations[0] accumulation = Equation((adjoint(acc.destination_quantity), node('add', *(contribution_symbol(s) for s in acc.contributions)), node('add', *(literal(s.contribution) for s in acc.contributions)), literal(acc.resulting_adjoint))) values = {'primal': renderer.equation(primal), 'adjoint': renderer.equation(adjoint_equation(op.quantity, model.output_name, op.quantity.adjoint_value)), 'rule': upper, 'application': lower, 'accumulation': renderer.equation(accumulation)} result = _TV_NOTATION_LEGEND for key, value in values.items(): result = result.replace('{{' + key + '}}', value) return result # gr.HTML sets innerHTML on update, and browsers never execute a """ def render_trace_html(model, notation='mathbook', mode='forward_backward', format_config=None): """Renders the same ComputationModel as an HTML trace: exactly two box types (Primal, Adjoint), reusing the diagram's semantic color contract.""" if notation not in {'mathbook', 'code'}: raise ValueError('Unknown notation') forward_only = (mode == 'forward' or model.forward_only) toolbar = ( '
' '' '' '' '' '
' ) if forward_only: toolbar = toolbar.replace("Forward / Backward only", "Forward only") parts = [_TV_STYLE, '
'] parts.append(_notation_legend(notation, forward_only or not model.root.differentiable)) parts.append(toolbar) parts.append(_formula_card_html(model, notation, format_config=format_config)) # Forward pass inputs = [q for q in model.quantities if q.is_input and not q.is_constant] input_chips = "".join( f'{escape(q.name)} = {_esc_val(q.primal_value, format_config=format_config)}' for q in inputs) forward_cards = "".join(_forward_card_html(op, notation, format_config=format_config) for op in model.operations) parts.append( '
Forward pass' '
' f'
Inputs
{input_chips}
' f'{forward_cards}
' ) if forward_only or not model.root.differentiable: parts.append('
') return "".join(parts) # Backward pass: quantity-indexed cards, in reverse construction order # (a valid reverse-topological order, since build_computation_model appends # each quantity only after every quantity it depends on). steps_by_source = {} for s in model.steps: steps_by_source.setdefault(s.source_quantity.uid, []).append(s) contributions_received = {} for s in model.steps: contributions_received.setdefault(s.destination_quantity.uid, []).append(s) op_by_uid = {op.quantity.uid: op for op in model.operations} seen, cards = set(), [] producers = {} for q in reversed(model.quantities): is_root = q.uid == model.root.uid if q.uid in seen or not (is_root or q.uid in contributions_received): continue seen.add(q.uid) cards.append(_backward_card_html(q, op_by_uid.get(q.uid), notation, model, contributions_received, steps_by_source, format_config=format_config)) producers[q.name] = sorted({s.source_quantity.name for s in contributions_received.get(q.uid, [])}) focus_hint = '

Click ⊙ focus on an adjoint below to trace only the quantities that contribute to it; click it again to clear.

' parts.append( '
Backward pass' f'
{focus_hint}{"".join(cards)}
' ) parts.append('') html = "".join(parts) producers_json = escape(json.dumps(producers), quote=True) return html.replace('
', f'
', 1)