Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions tinytorch/tests/01_tensor/test_tensor_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,42 @@ def test_tensor_from_tensor_list(self):
assert np.array_equal(stacked.data[0], t1.data)
assert np.array_equal(stacked.data[1], t2.data)

def test_tensor_from_empty_list(self):
"""Tensor([]) constructs successfully with shape (0,)."""
t = Tensor([])
assert t.shape == (0,)

def test_matmul_1d_self_2d_other(self):
"""matmul with a 1D self tensor and a 2D other tensor works."""
t1 = Tensor([1, 2, 3])
t2 = Tensor(rng.standard_normal((3, 4)))
result = t1.matmul(t2)
assert result.shape == (4,)

def test_reshape_scalar_arg(self):
"""reshape accepts a single scalar argument, not just a tuple."""
t = Tensor(np.arange(6))
reshaped = t.reshape(6)
assert reshaped.shape == (6,)

def test_transpose_only_dim0_raises(self):
"""transpose() with only dim0 specified (dim1 omitted) raises ValueError."""
t = Tensor(np.arange(6).reshape(2, 3))
with pytest.raises(ValueError):
t.transpose(dim0=0)

def test_reshape_multiple_negative_one_raises(self):
"""reshape() with more than one -1 dimension raises ValueError."""
t = Tensor(np.arange(6).reshape(2, 3))
with pytest.raises(ValueError):
t.reshape(-1, -1)

def test_reshape_indivisible_negative_one_raises(self):
"""reshape() raises ValueError when size isn't divisible for -1 inference."""
t = Tensor(np.arange(7))
with pytest.raises(ValueError):
t.reshape(2, -1)


if __name__ == "__main__":
pytest.main([__file__, "-v"])
71 changes: 70 additions & 1 deletion tinytorch/tests/03_layers/test_layers_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

sys.path.insert(0, str(Path(__file__).parent.parent.parent))

from tinytorch.core.layers import Layer
from tinytorch.core.layers import Layer, Sequential, Linear, ReLU, Dropout
from tinytorch.core.tensor import Tensor


Expand Down Expand Up @@ -488,5 +488,74 @@ def output_shape(self, input_shape):
)


class TestSequentialRealImplementation:
"""
Test the real tinytorch.core.layers.Sequential container.

CONCEPT: Sequential accepts layers either as separate positional
arguments (Sequential(l1, l2)) or as a single list (Sequential([l1, l2])).
Both forms must produce a working, correctly shaped forward pass.
"""

def test_sequential_positional_args_construction_and_shape(self):
"""
WHAT: Sequential(Linear(2, 2), ReLU()) built from positional args.

WHY: Students commonly write Sequential(layer1, layer2, ...) the
same way they would in PyTorch, without wrapping layers in a list.

STUDENT LEARNING: The real Sequential supports both call styles.
"""
model = Sequential(Linear(2, 2), ReLU())
assert len(model.layers) == 2

x = Tensor(np.array([[1.0, -1.0]]))
output = model(x)
assert output.shape == (1, 2), (
f"Sequential(Linear(2,2), ReLU()) forward shape wrong.\n"
f" Expected: (1, 2)\n"
f" Got: {output.shape}"
)


class TestDropoutLayer:
"""
Test the Dropout layer's validation and training/inference behavior.

CONCEPT: Dropout only zeros elements during training and only when
p > 0. It must also reject invalid probabilities at construction.
"""

def test_dropout_valid_construction(self):
"""Dropout(0.5) constructs successfully and stores p."""
dropout = Dropout(0.5)
assert dropout.p == 0.5

def test_dropout_negative_p_raises(self):
"""Dropout(-0.1) raises ValueError."""
with pytest.raises(ValueError):
Dropout(-0.1)

def test_dropout_p_above_one_raises(self):
"""Dropout(1.1) raises ValueError."""
with pytest.raises(ValueError):
Dropout(1.1)

def test_should_apply_dropout_training_and_p_positive(self):
"""_should_apply_dropout is True when training=True and p > 0."""
dropout = Dropout(0.5)
assert dropout._should_apply_dropout(training=True) is True

def test_should_apply_dropout_false_when_not_training(self):
"""_should_apply_dropout is False when training=False."""
dropout = Dropout(0.5)
assert dropout._should_apply_dropout(training=False) is False

def test_should_apply_dropout_false_when_p_zero(self):
"""_should_apply_dropout is False when p=0, even during training."""
dropout = Dropout(0.0)
assert dropout._should_apply_dropout(training=True) is False


if __name__ == "__main__":
pytest.main([__file__, "-v"])
59 changes: 58 additions & 1 deletion tinytorch/tests/06_autograd/test_autograd_gradient_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
sys.path.insert(0, str(Path(__file__).parent.parent.parent))

from tinytorch.core.tensor import Tensor
from tinytorch.core.autograd import enable_autograd
from tinytorch.core.autograd import enable_autograd, Function
from tinytorch.core.activations import GELU
# Try to import transformer for mean/sqrt monkey-patches (Module 13)
# This is optional - tests will skip if not available
Expand Down Expand Up @@ -148,6 +148,63 @@ def test_reshape_gradient_flow():
print("✅ Reshape gradient flow works correctly")


def test_sum_axis0_backward():
"""Test that sum(axis=0).backward(gradient) propagates the gradient to every row."""
print("Testing sum(axis=0) backward pass...")

x = Tensor(np.random.randn(3, 4), requires_grad=True)
y = x.sum(axis=0)
assert y.shape == (4,)

y.backward(np.ones(4))

assert x.grad.shape == (3, 4), "Gradient shape should match input shape"
assert np.allclose(x.grad, 1.0), "Every element's gradient should be 1.0"

print("✅ sum(axis=0) backward pass correct")


def test_sum_axis1_backward():
"""Test that sum(axis=1).backward(gradient) propagates the gradient to every column."""
print("Testing sum(axis=1) backward pass...")

x = Tensor(np.random.randn(3, 4), requires_grad=True)
y = x.sum(axis=1)
assert y.shape == (3,)

y.backward(np.ones(3))

assert x.grad.shape == (3, 4), "Gradient shape should match input shape"
assert np.allclose(x.grad, 1.0), "Every element's gradient should be 1.0"

print("✅ sum(axis=1) backward pass correct")


def test_function_apply_not_implemented():
"""Test that the base Function.apply() raises NotImplementedError."""
print("Testing bare Function.apply()...")

x = Tensor(np.array([1.0, 2.0]))
fn = Function(x)

with pytest.raises(NotImplementedError):
fn.apply(1.0)

print("✅ Function.apply() raises NotImplementedError as expected")


def test_backward_without_gradient_on_nonscalar_raises():
"""Test that backward() with no gradient argument on a non-scalar tensor raises ValueError."""
print("Testing backward() without gradient on non-scalar tensor...")

x = Tensor(np.random.randn(3, 4), requires_grad=True)

with pytest.raises(ValueError):
x.backward()

print("✅ backward() without gradient on non-scalar tensor raises ValueError")


if __name__ == "__main__":
print("\n" + "="*70)
print("GRADIENT FLOW TEST SUITE")
Expand Down
53 changes: 53 additions & 0 deletions tinytorch/tests/07_optimizers/test_optimizer_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,5 +283,58 @@ def test_optimizer_updates_all_parameters(self):
)


class TestSGDMomentumState:
"""
Test SGD momentum state introspection and checkpointing.

CONCEPT: has_momentum(), get_momentum_state(), and set_momentum_state()
give checkpointing code (Module 08) a safe API to save and restore
momentum buffers without using hasattr() checks.
"""

def test_has_momentum_false_without_momentum(self):
"""has_momentum() returns False when momentum=0.0."""
param = Tensor([1.0, 2.0, 3.0], requires_grad=True)
optimizer = SGD([param], lr=0.1, momentum=0.0)
assert optimizer.has_momentum() is False

def test_has_momentum_true_with_momentum(self):
"""has_momentum() returns True when momentum > 0."""
param = Tensor([1.0, 2.0, 3.0], requires_grad=True)
optimizer = SGD([param], lr=0.1, momentum=0.9)
assert optimizer.has_momentum() is True

def test_momentum_state_round_trip(self):
"""
WHAT: Save momentum state from one SGD optimizer and restore it
into a fresh SGD optimizer built on equivalent parameters.

WHY: Training checkpoints must be able to resume momentum exactly,
otherwise resumed training diverges from an uninterrupted run.
"""
param = Tensor([1.0, 2.0, 3.0], requires_grad=True)
optimizer = SGD([param], lr=0.1, momentum=0.9)

param.grad = np.array([1.0, 1.0, 1.0])
optimizer.step()

saved_state = optimizer.get_momentum_state()

# Fresh optimizer with the same params structure (new tensors, same shape)
fresh_param = Tensor([1.0, 2.0, 3.0], requires_grad=True)
fresh_optimizer = SGD([fresh_param], lr=0.1, momentum=0.9)
fresh_optimizer.set_momentum_state(saved_state)

restored_state = fresh_optimizer.get_momentum_state()

assert len(restored_state) == len(saved_state)
for original_buf, restored_buf in zip(saved_state, restored_state):
assert np.array_equal(original_buf, restored_buf), (
f"Restored momentum buffer does not match saved state.\n"
f" Saved: {original_buf}\n"
f" Restored: {restored_buf}"
)


if __name__ == "__main__":
pytest.main([__file__, "-v"])
Loading