"""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'