"""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'
Stop backward propagation through {escape(operand.name)}.
Primal — the value computed in the forward pass. Read from left to right: quantity = computation = values used = result.
Adjoint — sensitivity of the final output. n1 tells us how sensitive the final output is to n1, including all downstream paths.
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.
When there is more than one contribution. Contributions from every use of a quantity are added to establish its adjoint.
Quantity = operation = substituted values = result.
Click ⊙ focus on an adjoint below to trace only the quantities that contribute to it; click it again to clear.
' parts.append( '