Skip to content

Commit 71a7f03

Browse files
Merge pull request #1 from Shashank-Tripathi-07/mcdc-01-core-tensor-autograd
test(tinytorch): regression coverage for core tensor/layers/autograd/optimizer gaps
2 parents f391fd5 + 9eea971 commit 71a7f03

4 files changed

Lines changed: 217 additions & 2 deletions

File tree

tinytorch/tests/01_tensor/test_tensor_core.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,42 @@ def test_tensor_from_tensor_list(self):
568568
assert np.array_equal(stacked.data[0], t1.data)
569569
assert np.array_equal(stacked.data[1], t2.data)
570570

571+
def test_tensor_from_empty_list(self):
572+
"""Tensor([]) constructs successfully with shape (0,)."""
573+
t = Tensor([])
574+
assert t.shape == (0,)
575+
576+
def test_matmul_1d_self_2d_other(self):
577+
"""matmul with a 1D self tensor and a 2D other tensor works."""
578+
t1 = Tensor([1, 2, 3])
579+
t2 = Tensor(rng.standard_normal((3, 4)))
580+
result = t1.matmul(t2)
581+
assert result.shape == (4,)
582+
583+
def test_reshape_scalar_arg(self):
584+
"""reshape accepts a single scalar argument, not just a tuple."""
585+
t = Tensor(np.arange(6))
586+
reshaped = t.reshape(6)
587+
assert reshaped.shape == (6,)
588+
589+
def test_transpose_only_dim0_raises(self):
590+
"""transpose() with only dim0 specified (dim1 omitted) raises ValueError."""
591+
t = Tensor(np.arange(6).reshape(2, 3))
592+
with pytest.raises(ValueError):
593+
t.transpose(dim0=0)
594+
595+
def test_reshape_multiple_negative_one_raises(self):
596+
"""reshape() with more than one -1 dimension raises ValueError."""
597+
t = Tensor(np.arange(6).reshape(2, 3))
598+
with pytest.raises(ValueError):
599+
t.reshape(-1, -1)
600+
601+
def test_reshape_indivisible_negative_one_raises(self):
602+
"""reshape() raises ValueError when size isn't divisible for -1 inference."""
603+
t = Tensor(np.arange(7))
604+
with pytest.raises(ValueError):
605+
t.reshape(2, -1)
606+
571607

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

tinytorch/tests/03_layers/test_layers_core.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

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

31-
from tinytorch.core.layers import Layer
31+
from tinytorch.core.layers import Layer, Sequential, Linear, ReLU, Dropout
3232
from tinytorch.core.tensor import Tensor
3333

3434

@@ -488,5 +488,74 @@ def output_shape(self, input_shape):
488488
)
489489

490490

491+
class TestSequentialRealImplementation:
492+
"""
493+
Test the real tinytorch.core.layers.Sequential container.
494+
495+
CONCEPT: Sequential accepts layers either as separate positional
496+
arguments (Sequential(l1, l2)) or as a single list (Sequential([l1, l2])).
497+
Both forms must produce a working, correctly shaped forward pass.
498+
"""
499+
500+
def test_sequential_positional_args_construction_and_shape(self):
501+
"""
502+
WHAT: Sequential(Linear(2, 2), ReLU()) built from positional args.
503+
504+
WHY: Students commonly write Sequential(layer1, layer2, ...) the
505+
same way they would in PyTorch, without wrapping layers in a list.
506+
507+
STUDENT LEARNING: The real Sequential supports both call styles.
508+
"""
509+
model = Sequential(Linear(2, 2), ReLU())
510+
assert len(model.layers) == 2
511+
512+
x = Tensor(np.array([[1.0, -1.0]]))
513+
output = model(x)
514+
assert output.shape == (1, 2), (
515+
f"Sequential(Linear(2,2), ReLU()) forward shape wrong.\n"
516+
f" Expected: (1, 2)\n"
517+
f" Got: {output.shape}"
518+
)
519+
520+
521+
class TestDropoutLayer:
522+
"""
523+
Test the Dropout layer's validation and training/inference behavior.
524+
525+
CONCEPT: Dropout only zeros elements during training and only when
526+
p > 0. It must also reject invalid probabilities at construction.
527+
"""
528+
529+
def test_dropout_valid_construction(self):
530+
"""Dropout(0.5) constructs successfully and stores p."""
531+
dropout = Dropout(0.5)
532+
assert dropout.p == 0.5
533+
534+
def test_dropout_negative_p_raises(self):
535+
"""Dropout(-0.1) raises ValueError."""
536+
with pytest.raises(ValueError):
537+
Dropout(-0.1)
538+
539+
def test_dropout_p_above_one_raises(self):
540+
"""Dropout(1.1) raises ValueError."""
541+
with pytest.raises(ValueError):
542+
Dropout(1.1)
543+
544+
def test_should_apply_dropout_training_and_p_positive(self):
545+
"""_should_apply_dropout is True when training=True and p > 0."""
546+
dropout = Dropout(0.5)
547+
assert dropout._should_apply_dropout(training=True) is True
548+
549+
def test_should_apply_dropout_false_when_not_training(self):
550+
"""_should_apply_dropout is False when training=False."""
551+
dropout = Dropout(0.5)
552+
assert dropout._should_apply_dropout(training=False) is False
553+
554+
def test_should_apply_dropout_false_when_p_zero(self):
555+
"""_should_apply_dropout is False when p=0, even during training."""
556+
dropout = Dropout(0.0)
557+
assert dropout._should_apply_dropout(training=True) is False
558+
559+
491560
if __name__ == "__main__":
492561
pytest.main([__file__, "-v"])

tinytorch/tests/06_autograd/test_autograd_gradient_flow.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
1515

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

150150

151+
def test_sum_axis0_backward():
152+
"""Test that sum(axis=0).backward(gradient) propagates the gradient to every row."""
153+
print("Testing sum(axis=0) backward pass...")
154+
155+
x = Tensor(np.random.randn(3, 4), requires_grad=True)
156+
y = x.sum(axis=0)
157+
assert y.shape == (4,)
158+
159+
y.backward(np.ones(4))
160+
161+
assert x.grad.shape == (3, 4), "Gradient shape should match input shape"
162+
assert np.allclose(x.grad, 1.0), "Every element's gradient should be 1.0"
163+
164+
print("✅ sum(axis=0) backward pass correct")
165+
166+
167+
def test_sum_axis1_backward():
168+
"""Test that sum(axis=1).backward(gradient) propagates the gradient to every column."""
169+
print("Testing sum(axis=1) backward pass...")
170+
171+
x = Tensor(np.random.randn(3, 4), requires_grad=True)
172+
y = x.sum(axis=1)
173+
assert y.shape == (3,)
174+
175+
y.backward(np.ones(3))
176+
177+
assert x.grad.shape == (3, 4), "Gradient shape should match input shape"
178+
assert np.allclose(x.grad, 1.0), "Every element's gradient should be 1.0"
179+
180+
print("✅ sum(axis=1) backward pass correct")
181+
182+
183+
def test_function_apply_not_implemented():
184+
"""Test that the base Function.apply() raises NotImplementedError."""
185+
print("Testing bare Function.apply()...")
186+
187+
x = Tensor(np.array([1.0, 2.0]))
188+
fn = Function(x)
189+
190+
with pytest.raises(NotImplementedError):
191+
fn.apply(1.0)
192+
193+
print("✅ Function.apply() raises NotImplementedError as expected")
194+
195+
196+
def test_backward_without_gradient_on_nonscalar_raises():
197+
"""Test that backward() with no gradient argument on a non-scalar tensor raises ValueError."""
198+
print("Testing backward() without gradient on non-scalar tensor...")
199+
200+
x = Tensor(np.random.randn(3, 4), requires_grad=True)
201+
202+
with pytest.raises(ValueError):
203+
x.backward()
204+
205+
print("✅ backward() without gradient on non-scalar tensor raises ValueError")
206+
207+
151208
if __name__ == "__main__":
152209
print("\n" + "="*70)
153210
print("GRADIENT FLOW TEST SUITE")

tinytorch/tests/07_optimizers/test_optimizer_core.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,5 +283,58 @@ def test_optimizer_updates_all_parameters(self):
283283
)
284284

285285

286+
class TestSGDMomentumState:
287+
"""
288+
Test SGD momentum state introspection and checkpointing.
289+
290+
CONCEPT: has_momentum(), get_momentum_state(), and set_momentum_state()
291+
give checkpointing code (Module 08) a safe API to save and restore
292+
momentum buffers without using hasattr() checks.
293+
"""
294+
295+
def test_has_momentum_false_without_momentum(self):
296+
"""has_momentum() returns False when momentum=0.0."""
297+
param = Tensor([1.0, 2.0, 3.0], requires_grad=True)
298+
optimizer = SGD([param], lr=0.1, momentum=0.0)
299+
assert optimizer.has_momentum() is False
300+
301+
def test_has_momentum_true_with_momentum(self):
302+
"""has_momentum() returns True when momentum > 0."""
303+
param = Tensor([1.0, 2.0, 3.0], requires_grad=True)
304+
optimizer = SGD([param], lr=0.1, momentum=0.9)
305+
assert optimizer.has_momentum() is True
306+
307+
def test_momentum_state_round_trip(self):
308+
"""
309+
WHAT: Save momentum state from one SGD optimizer and restore it
310+
into a fresh SGD optimizer built on equivalent parameters.
311+
312+
WHY: Training checkpoints must be able to resume momentum exactly,
313+
otherwise resumed training diverges from an uninterrupted run.
314+
"""
315+
param = Tensor([1.0, 2.0, 3.0], requires_grad=True)
316+
optimizer = SGD([param], lr=0.1, momentum=0.9)
317+
318+
param.grad = np.array([1.0, 1.0, 1.0])
319+
optimizer.step()
320+
321+
saved_state = optimizer.get_momentum_state()
322+
323+
# Fresh optimizer with the same params structure (new tensors, same shape)
324+
fresh_param = Tensor([1.0, 2.0, 3.0], requires_grad=True)
325+
fresh_optimizer = SGD([fresh_param], lr=0.1, momentum=0.9)
326+
fresh_optimizer.set_momentum_state(saved_state)
327+
328+
restored_state = fresh_optimizer.get_momentum_state()
329+
330+
assert len(restored_state) == len(saved_state)
331+
for original_buf, restored_buf in zip(saved_state, restored_state):
332+
assert np.array_equal(original_buf, restored_buf), (
333+
f"Restored momentum buffer does not match saved state.\n"
334+
f" Saved: {original_buf}\n"
335+
f" Restored: {restored_buf}"
336+
)
337+
338+
286339
if __name__ == "__main__":
287340
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)