Skip to content
Open
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
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml
# Installing the full package also acts as a clean-install smoke test
# for the dependency resolution fixed in #65.
- name: Install package with test extras
run: |
python -m pip install --upgrade pip
pip install -e ".[test]"
- name: Run tests
run: pytest -v tests/

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install linters
run: pip install "black==24.*" "isort==5.*"
# Scoped to tests/ for now; widen to the package in a follow-up once the
# existing sources are confirmed clean against these versions.
# black and isort read their settings from pyproject.toml.
- name: black
run: black --check tests/
- name: isort
run: isort --check-only tests/
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ authors = [{name = "Meta Platforms, Inc."}]
dependencies = [
"neuralset==0.0.2",
"neuraltrain==0.0.2",
# neuralset 0.0.2 references exca.steps.base.NoValue, which exca removed in
# 0.5.26 (moved to exca.steps.identity). Cap exca until neuralset is updated.
"exca>=0.5.20,<0.5.26",
"torch>=2.5.1,<2.7",
"numpy==2.2.6",
"torchvision>=0.20,<0.22",
Expand Down Expand Up @@ -63,5 +66,8 @@ line-length = 88
[tool.isort]
profile = "black"

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.setuptools.packages.find]
include = ["tribe*"]
5 changes: 5 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.
36 changes: 36 additions & 0 deletions tests/test_demo_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.

"""Unit tests for the ``demo_utils`` input-suffix contract."""

from tribev2.demo_utils import VALID_SUFFIXES


def test_expected_input_modalities():
assert set(VALID_SUFFIXES) == {"text_path", "audio_path", "video_path"}


def test_every_modality_has_at_least_one_suffix():
for name, suffixes in VALID_SUFFIXES.items():
assert suffixes, f"{name} has no accepted suffixes"


def test_suffixes_are_lowercase_and_dot_prefixed():
for suffixes in VALID_SUFFIXES.values():
for suffix in suffixes:
assert suffix.startswith("."), f"{suffix!r} is missing a leading dot"
assert suffix == suffix.lower(), f"{suffix!r} is not lowercase"


def test_known_suffixes_are_accepted():
assert ".txt" in VALID_SUFFIXES["text_path"]
assert ".wav" in VALID_SUFFIXES["audio_path"]
assert ".mp4" in VALID_SUFFIXES["video_path"]


def test_suffixes_do_not_overlap_across_modalities():
all_suffixes = [s for suffixes in VALID_SUFFIXES.values() for s in suffixes]
assert len(all_suffixes) == len(set(all_suffixes)), "a suffix maps to >1 modality"
28 changes: 28 additions & 0 deletions tests/test_eventstransforms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.

"""Unit tests for ``ExtractWordsFromAudio`` language validation.

The language guard runs before any transcription subprocess, so these tests
need no audio file, GPU, or the ``uvx``/``whisperx`` toolchain.
"""

from pathlib import Path

import pytest

from tribev2.eventstransforms import ExtractWordsFromAudio


def test_unsupported_language_raises_value_error():
with pytest.raises(ValueError, match="not supported"):
ExtractWordsFromAudio._get_transcript_from_audio(
Path("nonexistent.wav"), "klingon"
)


def test_default_language_is_english():
assert ExtractWordsFromAudio().language == "english"
67 changes: 67 additions & 0 deletions tests/test_temporal_smoothing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the license found in the
# LICENSE file in the root directory of this source tree.

"""Unit tests for ``TemporalSmoothing`` (CPU only, no data or weights needed)."""

import torch
from torch import nn

from tribev2.model import TemporalSmoothing


def test_build_returns_depthwise_conv1d():
conv = TemporalSmoothing(kernel_size=9, sigma=2.0).build(dim=4)

assert isinstance(conv, nn.Conv1d)
# Depthwise: groups == channels, so the weight is (dim, 1, kernel_size).
assert conv.groups == 4
assert conv.weight.shape == (4, 1, 9)
assert conv.bias is None


def test_gaussian_kernel_is_normalized_per_channel():
dim, kernel_size = 4, 9
conv = TemporalSmoothing(kernel_size=kernel_size, sigma=2.0).build(dim=dim)

per_channel_sum = conv.weight.detach().sum(dim=-1).reshape(dim)
assert torch.allclose(per_channel_sum, torch.ones(dim), atol=1e-5)


def test_gaussian_kernel_is_symmetric():
conv = TemporalSmoothing(kernel_size=9, sigma=2.0).build(dim=1)

kernel = conv.weight.detach()[0, 0]
assert torch.allclose(kernel, torch.flip(kernel, dims=[0]), atol=1e-6)


def test_output_length_is_preserved():
conv = TemporalSmoothing(kernel_size=9, sigma=2.0).build(dim=4)

x = torch.randn(2, 4, 50)
y = conv(x)
assert y.shape == (2, 4, 50)


def test_constant_signal_is_unchanged_in_interior():
# A normalized smoothing kernel must leave a constant signal unchanged,
# away from the zero-padded borders.
conv = TemporalSmoothing(kernel_size=9, sigma=2.0).build(dim=3)

x = torch.ones(1, 3, 50)
y = conv(x).detach()
# kernel_size=9, padding=4 means only indices 0-3 and 46-49 are
# contaminated by zero-padding; 10:40 is safely interior.
interior = y[:, :, 10:40]
assert torch.allclose(interior, torch.ones_like(interior), atol=1e-5)


def test_default_sigma_is_none():
# With no sigma the conv keeps its randomly initialized, trainable weights
# (the Gaussian branch is skipped).
conv = TemporalSmoothing(kernel_size=5).build(dim=2)

assert isinstance(conv, nn.Conv1d)
assert conv.weight.shape == (2, 1, 5)