Repository: lhotse-speech/lhotse

Issue:
Randomized testing of *Cut classes invariants
Motivation:

`MixedCut` has grown into a more complex class than it seems since it can be composed of many underlying cuts that have either `Cut` or `PaddingCut` type. Recently I fixed some off-by-one `num_frames` errors between the meta-data and the actual feature matrices due to rounding. While things seem to be working okay now, I'd like to be sure we are free of this sort of errors, but the space of possible cut combinations is too large to cover with standard unit test cases.

Goal:

Test that `MixedCut` created in various ways always has consistent `num_samples` and `num_frames` metadata with the actual data shapes when samples/features are loaded into memory.

We should create the `MixedCut` by initializing fake Recordings + Cuts with random sampling rates and durations, extracting features for them, and then performing a number of randomly selected operations: `pad`, `mix`, and `append`. We can use a randomized testing library like [hypothesis](https://pypi.org/project/hypothesis/) if it is useful.


Discussion:

Resolving patch:
diff --git a/lhotse/cut.py b/lhotse/cut.py
--- a/lhotse/cut.py
+++ b/lhotse/cut.py
@@ -841,7 +841,21 @@ def load_audio(self, mixed: bool = True) -> Optional[np.ndarray]:
                 snr=track.snr,
                 offset=track.offset,
             )
-        return mixer.mixed_audio if mixed else mixer.unmixed_audio
+        if mixed:
+            # Off-by-one errors can happen during mixing due to imperfect float arithmetic and rounding;
+            # we will fix them on-the-fly so that the manifest does not lie about the num_samples.
+            audio = mixer.mixed_audio
+            if audio.shape[1] - self.num_samples == 1:
+                audio = audio[:, :self.num_samples]
+            if audio.shape[1] - self.num_samples == -1:
+                audio = np.concatenate((audio, audio[:, -1:]), axis=1)
+            assert audio.shape[1] == self.num_samples, "Inconsistent number of samples in a MixedCut: please report " \
+                                                       "this issue at https://github.com/lhotse-speech/lhotse/issues " \
+                                                       "showing the output of print(cut) or str(cut) on which" \
+                                                       "load_audio() was called."
+        else:
+            audio = mixer.unmixed_audio
+        return audio
 
     def plot_tracks_features(self):
         """
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -8,7 +8,7 @@
 
 install_requires = (project_root / 'requirements.txt').read_text().splitlines()
 docs_require = (project_root / 'docs' / 'requirements.txt').read_text().splitlines()
-tests_require = ['pytest==5.4.3', 'flake8==3.8.3', 'coverage==5.1']
+tests_require = ['pytest==5.4.3', 'flake8==3.8.3', 'coverage==5.1', 'hypothesis==5.41.2']
 dev_requires = docs_require + tests_require + ['jupyterlab', 'matplotlib', 'isort']
 
 if os.environ.get('READTHEDOCS', False):


Test patch:
diff --git a/lhotse/testing/__init__.py b/lhotse/testing/__init__.py
new file mode 100644
diff --git a/lhotse/test_utils.py b/lhotse/testing/dummies.py
similarity index 100%
rename from lhotse/test_utils.py
rename to lhotse/testing/dummies.py
diff --git a/lhotse/testing/fixtures.py b/lhotse/testing/fixtures.py
new file mode 100644
--- /dev/null
+++ b/lhotse/testing/fixtures.py
@@ -0,0 +1,63 @@
+from tempfile import NamedTemporaryFile, TemporaryDirectory
+
+import numpy as np
+import soundfile
+
+from lhotse import AudioSource, Cut, Fbank, LilcomFilesWriter, Recording
+from lhotse.utils import uuid4
+
+
+class RandomCutTestCase:
+    def setup_method(self, method):
+        self.files = []
+        self.dirs = []
+
+    def teardown_method(self, method):
+        self.cleanup()
+
+    def cleanup(self):
+        for f in self.files:
+            f.close()
+        self.files = []
+        for d in self.dirs:
+            d.cleanup()
+        self.dirs = []
+
+    def with_recording(self, sampling_rate: int, num_samples: int) -> Recording:
+        f = NamedTemporaryFile('wb', suffix='.wav')
+        self.files.append(f)
+        duration = num_samples / sampling_rate
+        samples = np.random.rand(num_samples)
+        soundfile.write(f.name, samples, samplerate=sampling_rate)
+        return Recording(
+            id=str(uuid4()),
+            sources=[
+                AudioSource(
+                    type='file',
+                    channels=[0],
+                    source=f.name
+                )
+            ],
+            sampling_rate=sampling_rate,
+            num_samples=num_samples,
+            duration=duration
+        )
+
+    def with_cut(self, sampling_rate: int, num_samples: int, features: bool = True) -> Cut:
+        duration = num_samples / sampling_rate
+        cut = Cut(
+            id=str(uuid4()),
+            start=0,
+            duration=duration,
+            channel=0,
+            recording=self.with_recording(sampling_rate=sampling_rate, num_samples=num_samples)
+        )
+        if features:
+            cut = self._with_features(cut)
+        return cut
+
+    def _with_features(self, cut: Cut) -> Cut:
+        d = TemporaryDirectory()
+        self.dirs.append(d)
+        with LilcomFilesWriter(d.name) as storage:
+            return cut.compute_and_store_features(Fbank(), storage=storage)
diff --git a/test/cut/test_cut_mixing.py b/test/cut/test_cut_mixing.py
--- a/test/cut/test_cut_mixing.py
+++ b/test/cut/test_cut_mixing.py
@@ -5,7 +5,7 @@
 
 from lhotse.cut import CutSet, MixedCut
 from lhotse.supervision import SupervisionSegment
-from lhotse.test_utils import remove_spaces_from_segment_text
+from lhotse.testing.dummies import remove_spaces_from_segment_text
 
 
 # Note:
diff --git a/test/cut/test_cut_set.py b/test/cut/test_cut_set.py
--- a/test/cut/test_cut_set.py
+++ b/test/cut/test_cut_set.py
@@ -5,7 +5,7 @@
 from lhotse import Features, Recording, SupervisionSegment
 from lhotse.audio import AudioSource
 from lhotse.cut import Cut, CutSet, MixTrack, MixedCut
-from lhotse.test_utils import remove_spaces_from_segment_text
+from lhotse.testing.dummies import remove_spaces_from_segment_text
 
 
 @pytest.fixture
diff --git a/test/cut/test_cut_truncate.py b/test/cut/test_cut_truncate.py
--- a/test/cut/test_cut_truncate.py
+++ b/test/cut/test_cut_truncate.py
@@ -2,10 +2,10 @@
 
 import pytest
 
-from lhotse.cut import Cut, MixedCut, MixTrack
+from lhotse.cut import Cut, MixTrack, MixedCut
 from lhotse.features import Features
 from lhotse.supervision import SupervisionSegment
-from lhotse.test_utils import dummy_cut
+from lhotse.testing.dummies import dummy_cut
 
 
 @pytest.fixture
diff --git a/test/cut/test_invariants_randomized.py b/test/cut/test_invariants_randomized.py
new file mode 100644
--- /dev/null
+++ b/test/cut/test_invariants_randomized.py
@@ -0,0 +1,94 @@
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from lhotse.testing.fixtures import RandomCutTestCase
+
+
+class TestMixedCutNumFramesNumSamplesRandomized(RandomCutTestCase):
+    @settings(deadline=None, print_blob=True)
+    @given(
+        st.one_of(st.just(8000), st.just(16000), st.just(44100), st.just(48000)),
+        st.data(),
+    )
+    def test_invariants_mix(
+            self,
+            sampling_rate: int,
+            data
+    ):
+        # Generate 2 - 10 cut durations in numbers of samples
+        nums_samples = data.draw(
+            st.lists(
+                st.integers(
+                    round(sampling_rate * 0.1),
+                    round(sampling_rate * 5.0)
+                ),
+                min_size=2,
+                max_size=10
+            ),
+            label='Cuts numbers of samples'
+        )
+        # Generate random cuts
+        cuts = [
+            self.with_cut(sampling_rate=sampling_rate, num_samples=num_samples)
+            for num_samples in nums_samples
+        ]
+        # Mix them with random offsets
+        mixed = cuts[0]
+        for idx, cut in enumerate(cuts[1:]):
+            offset = data.draw(st.floats(min_value=0.1, max_value=mixed.duration), label=f'Offset for cut {idx + 1}')
+            mixed = mixed.mix(cut, offset_other_by=offset)
+        # Truncate somewhere around the middle
+        end = data.draw(st.floats(mixed.duration * 0.4, mixed.duration * 0.6), label='Truncated duration')
+        mixed = mixed.truncate(duration=end)
+        # Test the invariants
+        feats = mixed.load_features()
+        samples = mixed.load_audio()
+        assert mixed.has_features
+        assert feats.shape[0] == mixed.num_frames
+        assert feats.shape[1] == mixed.num_features
+        assert mixed.has_recording
+        assert samples.shape[0] == 1
+        assert samples.shape[1] == mixed.num_samples
+        self.cleanup()
+
+    @settings(deadline=None, print_blob=True)
+    @given(
+        st.one_of(st.just(8000), st.just(16000), st.just(44100), st.just(48000)),
+        st.data(),
+    )
+    def test_invariants_append(
+            self,
+            sampling_rate: int,
+            data
+    ):
+        # Generate 2 - 10 cut durations in numbers of samples
+        nums_samples = data.draw(
+            st.lists(
+                st.integers(
+                    round(sampling_rate * 0.1),
+                    round(sampling_rate * 5.0)
+                ),
+                min_size=2,
+                max_size=10
+            ),
+            label='Cuts numbers of samples'
+        )
+        # Generate random cuts
+        cuts = [
+            self.with_cut(sampling_rate=sampling_rate, num_samples=num_samples)
+            for num_samples in nums_samples
+        ]
+        # Append the cuts
+        mixed = cuts[0]
+        for idx, cut in enumerate(cuts[1:]):
+            mixed = mixed.append(cut)
+        # Test the invariants
+        feats = mixed.load_features()
+        samples = mixed.load_audio()
+        assert mixed.has_features
+        assert feats.shape[0] == mixed.num_frames
+        assert feats.shape[1] == mixed.num_features
+        assert mixed.has_recording
+        assert samples.shape[0] == 1
+        assert samples.shape[1] == mixed.num_samples
+        self.cleanup()
diff --git a/test/dataset/test_speech_recognition_dataset.py b/test/dataset/test_speech_recognition_dataset.py
--- a/test/dataset/test_speech_recognition_dataset.py
+++ b/test/dataset/test_speech_recognition_dataset.py
@@ -6,7 +6,7 @@
 from lhotse.dataset import SpeechRecognitionDataset
 from lhotse.dataset.speech_recognition import K2DataLoader, K2SpeechRecognitionDataset, \
     K2SpeechRecognitionIterableDataset, concat_cuts
-from lhotse.test_utils import DummyManifest, dummy_cut
+from lhotse.testing.dummies import DummyManifest, dummy_cut
 
 
 @pytest.fixture
diff --git a/test/known_issues/test_augment_with_executor.py b/test/known_issues/test_augment_with_executor.py
--- a/test/known_issues/test_augment_with_executor.py
+++ b/test/known_issues/test_augment_with_executor.py
@@ -6,32 +6,33 @@
 import pytest
 
 from lhotse import CutSet, Fbank, LilcomFilesWriter, SoxEffectTransform, speed
-from test.known_issues.utils import make_cut
+from lhotse.testing.fixtures import RandomCutTestCase
 
 torchaudio = pytest.importorskip('torchaudio', minversion='0.6')
 
 
-@pytest.mark.parametrize(
-    'exec_type',
-    [
-        # Multithreading works
-        ThreadPoolExecutor,
-        # Multiprocessing works, but only when using the "spawn" context
-        partial(ProcessPoolExecutor, mp_context=multiprocessing.get_context("spawn")),
-    ]
-)
-def test_wav_augment_with_executor(exec_type):
-    with make_cut(sampling_rate=16000, num_samples=16000) as cut, \
-            TemporaryDirectory() as d, \
-            LilcomFilesWriter(storage_path=d) as storage, \
-            exec_type(max_workers=4) as ex:
-        cut_set = CutSet.from_cuts(
-            cut.with_id(str(i)) for i in range(100)
-        )
-        # Just test that it runs and does not hang.
-        cut_set_feats = cut_set.compute_and_store_features(
-            extractor=Fbank(),
-            storage=storage,
-            augment_fn=SoxEffectTransform(speed(16000)),
-            executor=ex
-        )
+class TestAugmentationWithExecutor(RandomCutTestCase):
+    @pytest.mark.parametrize(
+        'exec_type',
+        [
+            # Multithreading works
+            ThreadPoolExecutor,
+            # Multiprocessing works, but only when using the "spawn" context
+            partial(ProcessPoolExecutor, mp_context=multiprocessing.get_context("spawn")),
+        ]
+    )
+    def test_wav_augment_with_executor(self, exec_type):
+        cut = self.with_cut(sampling_rate=16000, num_samples=16000)
+        with TemporaryDirectory() as d, \
+                LilcomFilesWriter(storage_path=d) as storage, \
+                exec_type(max_workers=4) as ex:
+            cut_set = CutSet.from_cuts(
+                cut.with_id(str(i)) for i in range(100)
+            )
+            # Just test that it runs and does not hang.
+            cut_set_feats = cut_set.compute_and_store_features(
+                extractor=Fbank(),
+                storage=storage,
+                augment_fn=SoxEffectTransform(speed(16000)),
+                executor=ex
+            )
diff --git a/test/known_issues/test_cut_consistency.py b/test/known_issues/test_cut_consistency.py
--- a/test/known_issues/test_cut_consistency.py
+++ b/test/known_issues/test_cut_consistency.py
@@ -1,35 +1,27 @@
-from tempfile import TemporaryDirectory
-
 import pytest
 
-from lhotse import Fbank, LilcomFilesWriter
-from test.known_issues.utils import make_cut
+from lhotse.testing.fixtures import RandomCutTestCase
 
 
-@pytest.mark.parametrize(
-    ['sampling_rate', 'num_samples'],
-    [
-        (16000, 15995),
-        (16000, 15996),
-        (16000, 15997),
-        (16000, 15998),
-        (16000, 15999),
-        (16000, 16000),
-        (16000, 16001),
-        (16000, 16002),
-        (16000, 16003),
-        (16000, 16004),
-        (16000, 16005),
-    ]
-)
-def test_simple_cut_num_frames_and_samples_are_consistent(sampling_rate, num_samples):
-    with make_cut(sampling_rate, num_samples) as cut, \
-            TemporaryDirectory() as dir, \
-            LilcomFilesWriter(dir) as storage:
-        cut = cut.compute_and_store_features(
-            extractor=Fbank(),
-            storage=storage
-        )
+class TestConsistentNumFramesAndSamples(RandomCutTestCase):
+    @pytest.mark.parametrize(
+        ['sampling_rate', 'num_samples'],
+        [
+            (16000, 15995),
+            (16000, 15996),
+            (16000, 15997),
+            (16000, 15998),
+            (16000, 15999),
+            (16000, 16000),
+            (16000, 16001),
+            (16000, 16002),
+            (16000, 16003),
+            (16000, 16004),
+            (16000, 16005),
+        ]
+    )
+    def test_simple_cut_num_frames_and_samples_are_consistent(self, sampling_rate, num_samples):
+        cut = self.with_cut(sampling_rate, num_samples)
         feats = cut.load_features()
         samples = cut.load_audio()
 
@@ -44,31 +36,24 @@ def test_simple_cut_num_frames_and_samples_are_consistent(sampling_rate, num_sam
         assert samples.shape[1] == cut.recording.num_samples
         assert samples.shape[1] == cut.num_samples
 
-
-@pytest.mark.parametrize(
-    ['sampling_rate', 'num_samples', 'padded_duration'],
-    [
-        (16000, 15995, 1.5),
-        (16000, 15996, 1.5),
-        (16000, 15997, 1.5),
-        (16000, 15998, 1.5),
-        (16000, 15999, 1.5),
-        (16000, 16000, 1.5),
-        (16000, 16001, 1.5),
-        (16000, 16002, 1.5),
-        (16000, 16003, 1.5),
-        (16000, 16004, 1.5),
-        (16000, 16005, 1.5),
-    ]
-)
-def test_padded_cut_num_frames_and_samples_are_consistent(sampling_rate, num_samples, padded_duration):
-    with make_cut(sampling_rate, num_samples) as cut, \
-            TemporaryDirectory() as dir, \
-            LilcomFilesWriter(dir) as storage:
-        cut = cut.compute_and_store_features(
-            extractor=Fbank(),
-            storage=storage
-        )
+    @pytest.mark.parametrize(
+        ['sampling_rate', 'num_samples', 'padded_duration'],
+        [
+            (16000, 15995, 1.5),
+            (16000, 15996, 1.5),
+            (16000, 15997, 1.5),
+            (16000, 15998, 1.5),
+            (16000, 15999, 1.5),
+            (16000, 16000, 1.5),
+            (16000, 16001, 1.5),
+            (16000, 16002, 1.5),
+            (16000, 16003, 1.5),
+            (16000, 16004, 1.5),
+            (16000, 16005, 1.5),
+        ]
+    )
+    def test_padded_cut_num_frames_and_samples_are_consistent(self, sampling_rate, num_samples, padded_duration):
+        cut = self.with_cut(sampling_rate, num_samples)
         cut = cut.pad(padded_duration)
         feats = cut.load_features()
         samples = cut.load_audio()
diff --git a/test/known_issues/test_mixed_cut_num_frames.py b/test/known_issues/test_mixed_cut_num_frames.py
--- a/test/known_issues/test_mixed_cut_num_frames.py
+++ b/test/known_issues/test_mixed_cut_num_frames.py
@@ -1,22 +1,16 @@
-from tempfile import TemporaryDirectory
-
-from lhotse import Fbank, LilcomFilesWriter
 from lhotse.cut import MixedCut
-from test.known_issues.utils import make_cut
+from lhotse.testing.fixtures import RandomCutTestCase
 
 
-def test_mixed_cut_num_frames_example_1():
-    fbank = Fbank()
-    with make_cut(sampling_rate=16000, num_samples=237920) as cut1, \
-            make_cut(sampling_rate=16000, num_samples=219600) as cut2, \
-            TemporaryDirectory() as d, \
-            LilcomFilesWriter(d) as storage:
+class TestKnownProblematicCuts(RandomCutTestCase):
+    def test_mixed_cut_num_frames_example_1(self):
+        cut1 = self.with_cut(sampling_rate=16000, num_samples=237920)
+        cut2 = self.with_cut(sampling_rate=16000, num_samples=219600)
         # These are two cuts of similar duration, concatenated together with 1 second of silence
         # in between, and padded to duration of 31.445.
         mixed: MixedCut = (
-            cut1.compute_and_store_features(fbank, storage)
-                .pad(duration=cut1.duration + 1.0)
-                .append(cut2.compute_and_store_features(fbank, storage))
+            cut1.pad(duration=cut1.duration + 1.0)
+                .append(cut2)
                 .pad(duration=31.445)
         )
         assert mixed.duration == 31.445  # Padded correctly
@@ -26,25 +20,13 @@ def test_mixed_cut_num_frames_example_1():
         features = mixed.load_features()
         assert features.shape[0] == 3145  # Loaded features num frames matches the meta-data
 
-
-def test_mixed_cut_num_frames_example_2():
-    fbank = Fbank()
-    with make_cut(sampling_rate=16000, num_samples=252879) as cut1, \
-            make_cut(sampling_rate=16000, num_samples=185280) as cut2, \
-            make_cut(sampling_rate=16000, num_samples=204161) as cut3, \
-            TemporaryDirectory() as d, \
-            LilcomFilesWriter(d) as storage:
-        # These are two cuts of similar duration, concatenated together with 1 second of silence
-        # in between, and padded to duration of 31.445.
-        mixed: MixedCut = (
-            cut1.compute_and_store_features(fbank, storage)
-                .pad(duration=cut1.duration + 1.0)
-                .append(cut2.compute_and_store_features(fbank, storage))
-        )
-        mixed = (
-            mixed.pad(duration=mixed.duration + 1.0)
-                .append(cut3.compute_and_store_features(fbank, storage))
-        )
+    def test_mixed_cut_num_frames_example_2(self):
+        cut1 = self.with_cut(sampling_rate=16000, num_samples=252879)
+        cut2 = self.with_cut(sampling_rate=16000, num_samples=185280)
+        cut3 = self.with_cut(sampling_rate=16000, num_samples=204161)
+        # Three cuts padded with 1s of silence in between
+        mixed = cut1.pad(duration=cut1.duration + 1.0).append(cut2)
+        mixed = mixed.pad(duration=mixed.duration + 1.0).append(cut3)
         assert mixed.duration == 42.145  # Padded correctly
         assert mixed.num_frames == 4215  # Round last 5 up
         # TODO(pzelasko): This assertion would not pass for now, as we're adding an extra frame during load_features.
diff --git a/test/known_issues/utils.py b/test/known_issues/utils.py
deleted file mode 100644
--- a/test/known_issues/utils.py
+++ /dev/null
@@ -1,43 +0,0 @@
-from contextlib import contextmanager
-from tempfile import NamedTemporaryFile
-
-import numpy as np
-import soundfile
-
-from lhotse import AudioSource, Cut, Recording
-
-
-@contextmanager
-def make_recording(sampling_rate: int, num_samples: int) -> Recording:
-    # The idea is that we're going to write to a temporary file with a sine wave recording
-    # of specified duration and sampling rate, and clean up only after the test is executed.
-    with NamedTemporaryFile('wb', suffix='.wav') as f:
-        duration = num_samples / sampling_rate
-        samples: np.ndarray = np.sin(2 * np.pi * np.arange(0, num_samples) / sampling_rate)
-        soundfile.write(f, samples, samplerate=sampling_rate)
-        yield Recording(
-            id=f'recording-{sampling_rate}-{duration}',
-            sources=[
-                AudioSource(
-                    type='file',
-                    channels=[0],
-                    source=f.name
-                )
-            ],
-            sampling_rate=sampling_rate,
-            num_samples=num_samples,
-            duration=duration
-        )
-
-
-@contextmanager
-def make_cut(sampling_rate: int, num_samples: int) -> Cut:
-    with make_recording(sampling_rate, num_samples) as recording:
-        duration = num_samples / sampling_rate
-        yield Cut(
-            id=f'cut-{sampling_rate}-{duration}',
-            start=0,
-            duration=duration,
-            channel=0,
-            recording=recording
-        )
diff --git a/test/test_feature_set.py b/test/test_feature_set.py
--- a/test/test_feature_set.py
+++ b/test/test_feature_set.py
@@ -12,7 +12,7 @@
 from lhotse.features import (Fbank, FeatureExtractor, FeatureMixer, FeatureSet, FeatureSetBuilder, Features, Mfcc,
                              Spectrogram, create_default_feature_extractor)
 from lhotse.features.io import LilcomFilesWriter, LilcomHdf5Writer, NumpyFilesWriter, NumpyHdf5Writer
-from lhotse.test_utils import DummyManifest
+from lhotse.testing.dummies import DummyManifest
 from lhotse.utils import Seconds, time_diff_to_num_frames
 
 other_params = {}
diff --git a/test/test_manipulation.py b/test/test_manipulation.py
--- a/test/test_manipulation.py
+++ b/test/test_manipulation.py
@@ -8,7 +8,7 @@
 from lhotse.features import FeatureSet
 from lhotse.manipulation import combine, load_manifest
 from lhotse.supervision import SupervisionSet
-from lhotse.test_utils import DummyManifest
+from lhotse.testing.dummies import DummyManifest
 
 
 @mark.parametrize('manifest_type', [RecordingSet, SupervisionSet, FeatureSet, CutSet])
diff --git a/test/test_recording_set.py b/test/test_recording_set.py
--- a/test/test_recording_set.py
+++ b/test/test_recording_set.py
@@ -6,8 +6,8 @@
 import pytest
 from pytest import mark, raises
 
-from lhotse.audio import AudioSource, Recording, RecordingSet, AudioMixer
-from lhotse.test_utils import DummyManifest
+from lhotse.audio import AudioMixer, AudioSource, Recording, RecordingSet
+from lhotse.testing.dummies import DummyManifest
 from lhotse.utils import INT16MAX
 
 
diff --git a/test/test_supervision_set.py b/test/test_supervision_set.py
--- a/test/test_supervision_set.py
+++ b/test/test_supervision_set.py
@@ -3,7 +3,7 @@
 import pytest
 
 from lhotse.supervision import SupervisionSegment, SupervisionSet
-from lhotse.test_utils import DummyManifest, remove_spaces_from_segment_text
+from lhotse.testing.dummies import DummyManifest, remove_spaces_from_segment_text
 
 
 @pytest.fixture