From 6674c1d98170e731fdc301915b68bc7f35fc5a21 Mon Sep 17 00:00:00 2001 From: Aditya Suresh <152752311+adi-suresh01@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:06:42 -0700 Subject: [PATCH 1/2] fix: cap exca to restore a working clean install neuralset 0.0.2 references exca.steps.base.NoValue, which was removed in exca 0.5.26 (the symbol moved to exca.steps.identity). neuralset 0.0.2 declares exca>=0.5.20, so a fresh install resolves exca 0.5.26 and fails at import time: AttributeError: module 'exca.steps.base' has no attribute 'NoValue' (neuralset/events/study.py, in StudyLoader.run) tribev2 also imports exca directly (exca.ConfDict, exca.TaskInfra) without declaring it. This adds exca as an explicit dependency, capped to the range that exposes the API neuralset 0.0.2 relies on (0.5.20 through 0.5.25). Verified in a clean Python 3.12 venv: install now resolves exca 0.5.25 and 'from tribev2.demo_utils import TribeModel' imports successfully. Closes #65 --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index da728956..85b4e442 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", From 7cae5135d51e38cda282e8b176f278e6b5846ff2 Mon Sep 17 00:00:00 2001 From: Aditya Suresh <152752311+adi-suresh01@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:15:32 -0700 Subject: [PATCH 2/2] test: add unit test suite and CI workflow Adds the first tests/ for the project and a GitHub Actions workflow. Tests (CPU only, no model weights, datasets, or network): - TemporalSmoothing: depthwise conv shape, Gaussian kernel normalization and symmetry, length preservation, constant-signal invariance. - demo_utils.VALID_SUFFIXES: modality set, non-empty lowercase dot-prefixed suffixes, no cross-modality overlap. - ExtractWordsFromAudio: unsupported-language guard and default language. CI: pytest on Python 3.11 and 3.12, plus black and isort checks scoped to tests/ for now. --- .github/workflows/ci.yml | 53 +++++++++++++++++++++++++ pyproject.toml | 3 ++ tests/__init__.py | 5 +++ tests/test_demo_utils.py | 36 +++++++++++++++++ tests/test_eventstransforms.py | 28 +++++++++++++ tests/test_temporal_smoothing.py | 67 ++++++++++++++++++++++++++++++++ 6 files changed, 192 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/__init__.py create mode 100644 tests/test_demo_utils.py create mode 100644 tests/test_eventstransforms.py create mode 100644 tests/test_temporal_smoothing.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..5e10d7d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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/ diff --git a/pyproject.toml b/pyproject.toml index 85b4e442..40828a3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,5 +66,8 @@ line-length = 88 [tool.isort] profile = "black" +[tool.pytest.ini_options] +testpaths = ["tests"] + [tool.setuptools.packages.find] include = ["tribe*"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..0952fcc3 --- /dev/null +++ b/tests/__init__.py @@ -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. diff --git a/tests/test_demo_utils.py b/tests/test_demo_utils.py new file mode 100644 index 00000000..3a5e939e --- /dev/null +++ b/tests/test_demo_utils.py @@ -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" diff --git a/tests/test_eventstransforms.py b/tests/test_eventstransforms.py new file mode 100644 index 00000000..c71052c9 --- /dev/null +++ b/tests/test_eventstransforms.py @@ -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" diff --git a/tests/test_temporal_smoothing.py b/tests/test_temporal_smoothing.py new file mode 100644 index 00000000..e4768eba --- /dev/null +++ b/tests/test_temporal_smoothing.py @@ -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)