"""Contract, numerical, and snapshot tests for the shared computation store.""" import json import numpy as np import pytest import sympy as sp from autodiff_visualizer import ComputationGraph, safe_parse_expr from backward_rules import ScalarLocalRule, TensorBackwardRule from diagram_renderer import DIVIDER, render_diagram, step_label from math_renderer import MathRenderer from tensor_engine import TensorComputationGraph from trace_renderer import _contribution_box_html, render_trace, render_trace_html def scalar(expr, feed): graph = ComputationGraph().build(safe_parse_expr(expr)) graph.evaluate({sp.Symbol(k): v for k, v in feed.items()}) return graph.to_computation_model() def tensor_graph(expr, feed, forward_only=False): graph = TensorComputationGraph().build_from_ast(expr) graph.evaluate(feed, forward_only=forward_only) return graph CASES = [ ('scalar', '(x*x)*y+(y+2)', {'x': 3., 'y': 4.}), ('scalar', 'sin(x) + x**y', {'x': 2., 'y': 3.}), ('tensor', 'sum(x/y+b)', {'x': np.array([2., 4.]), 'y': np.array([1., 2.]), 'b': 2.}), ('tensor', 'sum(W @ x)', {'W': np.array([[1., 2.], [3., 4.]]), 'x': np.array([2., 3.])}), ('tensor', 'sum(x @ W)', {'W': np.array([[1., 2., 3.], [3., 4., 5.]]), 'x': np.array([2., 3.])}), ('tensor', 'softmax(x)[-1]', {'x': np.array([1., 2., 3.])}), ('tensor', 'sum(x*x)', {'x': np.array([1., 2., 3.])}), ('tensor', 'sum(x*x)', {'x': 3.}), ] @pytest.mark.parametrize('notation', ['code', 'mathbook']) @pytest.mark.parametrize('engine,formula,feed', CASES) def test_every_view_uses_identical_local_equations(notation, engine, formula, feed): model = scalar(formula, feed) if engine == 'scalar' else tensor_graph(formula, feed).to_computation_model() text, html, diagram = render_trace(model, notation), render_trace_html(model, notation), render_diagram(model, notation) for step in model.steps: renderer = MathRenderer(notation, html=True) upper = renderer.backward_rule(step.explanation) lower = renderer.application_derivation(step.explanation, layout="auto") plain_upper, plain_lower = MathRenderer(notation).explanation(step.explanation) assert step_label(step, notation).split(DIVIDER)[0] == f"
{upper}
" assert f'>{lower}' in step_label(step, notation) box = _contribution_box_html(step.operation, step, notation) assert f'class="tv-deriv-rule">{upper}' in box assert f'class="tv-deriv-result">{lower}' in box assert box in html assert upper in diagram and lower in diagram assert plain_upper in text and plain_lower in text assert 'diff(out,' not in upper and '∂out/' not in upper # Semantic identity: the lower compartment never re-states the symbolic rule. assert step.rule.application not in step.explanation.application_derivation.stages assert step.explanation.application_derivation.lhs == step.explanation.contribution # Expressions are data, independent of both display notation and containers. assert '')[1] assert '∂' not in body and 'diff(' not in body assert 'tv-adjoint-step' not in body and 'tv-deriv-box' not in body def test_repeated_base_and_exponent_keep_different_rules(): model = scalar('x**x', {'x': 2.}) first, second = model.steps assert first.rule.operand.key != second.rule.operand.key assert first.local_value == 4. assert np.isclose(second.local_value, 4 * np.log(2)) assert 'log(' in MathRenderer('code').explanation(second.explanation)[0] assert np.isclose(model.accumulations[0].resulting_adjoint, 4 + 4 * np.log(2)) def test_numerical_outer_product_is_not_rendered_as_row_times_column(): model = tensor_graph('sum(W @ x)', {'W': np.ones((2, 2)), 'x': np.array([2., 3.])}).to_computation_model() upper, lower = MathRenderer().explanation(model.steps[0].explanation) assert upper == 'c_n1→W = n̄1 xᵀ' assert lower == 'c_n1→W = outer([1, 1], [2, 3])\n = [[2, 3], [2, 3]]' @pytest.mark.parametrize('value', [2., np.array([0., 2.])]) def test_constant_zero_power_does_not_multiply_infinite_adjoint(value): count = np.size(value) graph = tensor_graph(f'log(sum(x**0)-{count})', {'x': value}) np.testing.assert_array_equal(graph.name_to_input['x'].grad, np.zeros_like(value)) model = graph.to_computation_model() step = next(s for s in model.steps if s.operation.kind == 'pow') assert not step.rule.application.has_adjoint() assert 'nan' not in MathRenderer().explanation(step.explanation)[1] @pytest.mark.parametrize('engine', ['scalar', 'tensor']) @pytest.mark.parametrize('formula, expected', [ ('nograd(x*x)', None), ('nograd(x*x)+x', 1.), ('x*x', 0.), ]) def test_unreached_adjoints_are_absent_but_active_zeros_remain(engine, formula, expected): from diagram_renderer import quantity_label from tikz_renderer import render_tikz feed = {'x': 0. if formula == 'x*x' else 3.} model = (scalar(formula, feed) if engine == 'scalar' else tensor_graph(formula, feed).to_computation_model()) x = next(q for q in model.quantities if q.name == 'x') assert x.adjoint_value == expected operations = {op.quantity.uid: op for op in model.operations} if 'nograd' in formula: stopped_operand = next(op.operands[0] for op in model.operations if op.function == 'nograd') assert stopped_operand.adjoint_value is None stop_result = next(op.quantity for op in model.operations if op.function == 'nograd') assert stop_result.adjoint_value == 1 assert 'adjoint-state' not in quantity_label(stopped_operand, operations[stopped_operand.uid], 'code') label = quantity_label(x, None, 'code') assert ('adjoint-state' in label) == (expected is not None) tikz = render_tikz(model) assert (r'\overline{x}=' in tikz) == (expected is not None) text = render_trace(model, 'code') assert ('diff(out, x) = x_bar =' in text) == (expected is not None) html = render_trace_html(model, 'code') assert 'None' not in html def test_semantic_derivation_stages_model_contract(): """Lower compartment = (concrete application, result); never the symbolic rule.""" def check(step, concrete_kind): d = step.explanation.application_derivation assert d.lhs == step.explanation.contribution assert len(d.stages) == 2 assert d.stages[0].kind == concrete_kind assert d.stages[1].kind == 'literal' # Identity, not substring: the symbolic rule lives only in local_rule. assert step.rule.application not in d.stages assert not set(step.explanation.local_rule.terms[1:]) & set(d.stages) return d.stages[1].value # 1. Scalar multiplication: c_out→x = 1 · 3 = 3 assert check(scalar('x*y', {'x': 2., 'y': 3.}).steps[0], 'mul') == 3.0 # 2. Repeated scalar operand: x*x for s in scalar('x*x', {'x': 3.}).steps: assert check(s, 'mul') == 3.0 # 3. softmax(z)[1]: softmax VJP, then index selection tg_sm = tensor_graph('softmax(z)[1]', {'z': np.array([1., 2., 3.])}).to_computation_model() np.testing.assert_allclose(check(tg_sm.steps[0], 'elementwise'), [-0.02203304, 0.18483645, -0.1628034], atol=1e-5) np.testing.assert_allclose(check(tg_sm.steps[1], 'scale'), [0., 1., 0.]) # 4. sum(W @ x): outer product to W, transpose-matmul to x tg_mv = tensor_graph('sum(W @ x)', {'W': np.ones((2, 3)), 'x': np.ones(3)}).to_computation_model() np.testing.assert_array_equal(check(tg_mv.steps[0], 'outer'), np.ones((2, 3))) np.testing.assert_array_equal(check(tg_mv.steps[1], 'matmul'), [2., 2., 2.]) # 5. Broadcasting: sum(x/y + b) tg_bc = tensor_graph('sum(x/y + b)', {'x': np.array([2., 4.]), 'y': np.array([1., 2.]), 'b': 2.}).to_computation_model() assert check(next(s for s in tg_bc.steps if s.destination_quantity.name == 'b'), 'sum') == 2.0 def test_derivation_stages_forbid_embedded_equality(): """Negative invariant: No derivation stage may itself be an Equation or encode internal equality.""" from backward_explanation import Equation for engine, formula, feed in CASES: model = scalar(formula, feed) if engine == 'scalar' else tensor_graph(formula, feed).to_computation_model() for step in model.steps: d = step.explanation.application_derivation # lhs must be an Expr, not Equation assert not isinstance(d.lhs, Equation) assert d.lhs.kind != 'equation' for stage in d.stages: # stages must be Expr, never Equation assert not isinstance(stage, Equation) assert stage.kind != 'equation'