From bf9e06b6733765fa29208c1cdb184632dcffc584 Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 Date: Thu, 20 Aug 2026 20:33:52 -0700 Subject: [PATCH 1/5] fix: use scaling parameters offset for iti min --- src/dynamic_foraging_processing/processing/_trial_table.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/dynamic_foraging_processing/processing/_trial_table.py b/src/dynamic_foraging_processing/processing/_trial_table.py index 3ae489d..7b8c486 100644 --- a/src/dynamic_foraging_processing/processing/_trial_table.py +++ b/src/dynamic_foraging_processing/processing/_trial_table.py @@ -663,6 +663,11 @@ def _session_columns(self, task_logic: AindDynamicForagingTaskLogic) -> t.Dict[s iti_beta, iti_min, iti_max = self._distribution_stats( generator.inter_trial_interval_duration ) + + # use the distribution offset as the min for iti min + iti_scaling = generator.inter_trial_interval_duration.scaling_parameters + if iti_scaling is not None and iti_scaling.offset is not None: + iti_min = iti_scaling.offset delay_beta, delay_min, delay_max = self._distribution_stats(generator.quiescent_duration) # Coupled generators normalize both sides to a fixed reward-probability From ce72f988fa5378f2b75fe08b4a94b162c22ca212 Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 Date: Thu, 20 Aug 2026 20:34:01 -0700 Subject: [PATCH 2/5] test: update tests --- tests/test_processing/test_trial_table.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_processing/test_trial_table.py b/tests/test_processing/test_trial_table.py index 3990b94..776bdbb 100644 --- a/tests/test_processing/test_trial_table.py +++ b/tests/test_processing/test_trial_table.py @@ -22,6 +22,7 @@ ExponentialDistributionParameters, Scalar, ScalarDistributionParameter, + ScalingParameters, TruncationParameters, UniformDistribution, UniformDistributionParameters, @@ -174,6 +175,7 @@ def _task_logic(quiescent="scalar"): inter_trial_interval_duration=ExponentialDistribution( distribution_parameters=ExponentialDistributionParameters(rate=0.2), truncation_parameters=TruncationParameters(min=1.0, max=10.0), + scaling_parameters=ScalingParameters(offset=0.5), ), block_length=ExponentialDistribution( distribution_parameters=ExponentialDistributionParameters(rate=0.05), @@ -365,7 +367,8 @@ def test_build_full_dataset(): # Session-level distribution summaries. assert first["ITI_beta"] == pytest.approx(5.0) - assert first["ITI_min"] == 1.0 and first["ITI_max"] == 10.0 + # ``ITI_min`` comes from the scaling offset, not the truncation minimum. + assert first["ITI_min"] == 0.5 and first["ITI_max"] == 10.0 assert first["block_beta"] == pytest.approx(20.0) # Only block_max takes the floor adjustment: the configured 20/60 truncation # yields a longest realizable block of 59 trials. From 9398988b053ec6e884c98d8eda67b1673647771b Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 Date: Thu, 20 Aug 2026 20:34:13 -0700 Subject: [PATCH 3/5] docs: update trial table docs --- docs/trials_table_mapping.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/trials_table_mapping.md b/docs/trials_table_mapping.md index c23a944..25fb303 100644 --- a/docs/trials_table_mapping.md +++ b/docs/trials_table_mapping.md @@ -74,7 +74,7 @@ Columns are grouped by the raw source they map from. | Trials column | Source field | | --- | --- | -| `ITI_beta`, `ITI_min`, `ITI_max`, `ITI_duration` | `inter_trial_interval_duration` | +| `ITI_beta`, `ITI_min`, `ITI_max`, `ITI_duration` | `inter_trial_interval_duration`. `ITI_min` is the distribution's scaling `offset` (the sampled value is shifted by it, so the offset is the shortest possible ITI) rather than the truncation minimum. | | `block_beta`, `block_duration`, `block_min`, `block_max` | `block_length`. `block_max` is one below the configured maximum, which accounts for the floor applied upstream. | | `delay_beta`, `delay_duration`, `delay_min`, `delay_max` | `quiescent_duration_key` (scalar distribution, so no beta/min/max) | @@ -232,3 +232,4 @@ These were mapped during exploration but are no longer in scope: | 2026-08-17 | `auto_waterL` / `auto_waterR` now read `trial.metadata.extra.is_autowater` rather than the `is_auto_reward_right` channel, making them **scheduled autowater only** and mutually exclusive with `anti_bias_left_water` / `anti_bias_right_water`. `is_auto_reward_right` says free water fired and on which side but not what kind; the mechanism is in the metadata. Neither column is gated on `is_rewarded`, since both record what the task did. This is narrower than the legacy `dynamic-foraging-task` column of the same name, which was the ungated channel and predates anti-bias water. | | 2026-08-17 | The reward-delivery labels stay `earned` / `auto` / `manual`: free water is `auto` whatever mechanism produced it, so the series does not split scheduled autowater from anti-bias water. That split lives in the trials table. Consequence: the series' `auto` count tracks the channel while `auto_waterL` / `auto_waterR` track `is_autowater`, so the two are not expected to be equal. | | 2026-08-20 | `block_max` is now one below `block_length`'s configured maximum, which accounts for the floor applied upstream: a block is a whole number of trials, so the configured bound is never itself reachable. `block_min`, `block_beta`, and the `ITI_*` / `delay_*` bounds are unchanged — those durations are continuous and take no such adjustment. | +| 2026-08-20 | `ITI_min` now reports `inter_trial_interval_duration`'s scaling `offset` instead of its truncation minimum: the sampled ITI is shifted by the offset, so the offset is the shortest ITI the generator can produce. Falls back to the truncation minimum when no scaling parameters are configured. | From 12391191c65fdd2cadc8f6a686b83993f361561b Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 Date: Wed, 26 Aug 2026 15:20:54 -0700 Subject: [PATCH 4/5] refactor: add logic for scale and offset --- .../processing/_trial_table.py | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/dynamic_foraging_processing/processing/_trial_table.py b/src/dynamic_foraging_processing/processing/_trial_table.py index 452971f..dc9da79 100644 --- a/src/dynamic_foraging_processing/processing/_trial_table.py +++ b/src/dynamic_foraging_processing/processing/_trial_table.py @@ -260,6 +260,20 @@ def _distribution_stats( except for a uniform distribution, whose bounds are its own ``min`` and ``max`` distribution parameters. + The optional ``scaling_parameters`` apply ``value * scale + offset`` to + each sample, so they are folded into all three values. Two details + decide where each one lands: + + * The truncation bounds are applied *after* scaling (per the schema), so + they are already in output units and are **not** re-transformed. The + offset does raise the support floor of an exponential, though — its + samples start at ``0``, hence at ``offset`` once shifted — so the + reported minimum is whichever of the two constraints binds. This is + what makes a configured offset visible when the truncation minimum is + left at its ``0`` default. + * A uniform's bounds live in the distribution parameters, which *are* + pre-scaling, so they take the full transform. + Parameters ---------- distribution : Distribution @@ -273,15 +287,20 @@ def _distribution_stats( beta: t.Optional[float] = None params = distribution.distribution_parameters truncation = distribution.truncation_parameters + scaling = distribution.scaling_parameters + scale = scaling.scale if scaling is not None else 1.0 + offset = scaling.offset if scaling is not None else 0.0 minimum = truncation.min if truncation is not None else None maximum = truncation.max if truncation is not None else None if params.family == DistributionFamily.EXPONENTIAL and params.rate: - beta = 1.0 / params.rate + beta = scale / params.rate + # An exponential's support starts at the offset once shifted. + minimum = offset if minimum is None else max(minimum, offset) elif params.family == DistributionFamily.UNIFORM: # A uniform distribution carries its bounds in the distribution # parameters rather than the truncation parameters. - minimum = params.min - maximum = params.max + minimum = params.min * scale + offset + maximum = params.max * scale + offset return beta, minimum, maximum @staticmethod @@ -650,11 +669,6 @@ def _session_columns(self, task_logic: AindDynamicForagingTaskLogic) -> t.Dict[s iti_beta, iti_min, iti_max = self._distribution_stats( generator.inter_trial_interval_duration ) - - # use the distribution offset as the min for iti min - iti_scaling = generator.inter_trial_interval_duration.scaling_parameters - if iti_scaling is not None and iti_scaling.offset is not None: - iti_min = iti_scaling.offset delay_beta, delay_min, delay_max = self._distribution_stats(generator.quiescent_duration) # Coupled generators normalize both sides to a fixed reward-probability From 17b6141e7f7269672a4a6d280225149fe4403fec Mon Sep 17 00:00:00 2001 From: arjunsridhar12345 Date: Wed, 26 Aug 2026 15:21:02 -0700 Subject: [PATCH 5/5] test: update tests --- tests/test_processing/test_trial_table.py | 61 ++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/test_processing/test_trial_table.py b/tests/test_processing/test_trial_table.py index c532a78..6417cef 100644 --- a/tests/test_processing/test_trial_table.py +++ b/tests/test_processing/test_trial_table.py @@ -182,7 +182,9 @@ def _task_logic(quiescent="scalar"): quiescent_duration=quiescent, inter_trial_interval_duration=ExponentialDistribution( distribution_parameters=ExponentialDistributionParameters(rate=0.2), - truncation_parameters=TruncationParameters(min=1.0, max=10.0), + # The truncation minimum is left at its 0 default, so the scaling + # offset is what bounds the shortest realizable ITI. + truncation_parameters=TruncationParameters(min=0.0, max=10.0), scaling_parameters=ScalingParameters(offset=0.5), ), block_length=ExponentialDistribution( @@ -487,6 +489,63 @@ def test_build_uniform_quiescent_sets_delay_bounds_from_parameters(): assert first["delay_max"] == pytest.approx(0.75) +def test_distribution_stats_exponential_minimum_takes_the_binding_constraint(): + """The reported minimum is the tighter of the truncation floor and the offset.""" + + def stats(trunc_min, offset): + """Summarize a truncated, offset exponential distribution.""" + return TrialTableBuilder._distribution_stats( + ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=0.5), + truncation_parameters=TruncationParameters(min=trunc_min, max=10.0), + scaling_parameters=ScalingParameters(offset=offset), + ) + ) + + # Offset above the truncation floor: the shifted support is what binds. + assert stats(0.0, 1.5)[1] == pytest.approx(1.5) + # Offset below it: truncation still excludes everything under its own minimum, + # so the offset is not reachable. The truncation bounds are applied after + # scaling, so they are already in output units and take no further shift. + assert stats(2.0, 0.5)[1] == pytest.approx(2.0) + # No scaling parameters at all -> the truncation floor, unchanged. + assert TrialTableBuilder._distribution_stats( + ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=0.5), + truncation_parameters=TruncationParameters(min=2.0, max=10.0), + ) + )[1] == pytest.approx(2.0) + # Scaling without truncation: the offset alone is the floor. + assert TrialTableBuilder._distribution_stats( + ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=0.5), + scaling_parameters=ScalingParameters(offset=0.75), + ) + )[1] == pytest.approx(0.75) + + +def test_distribution_stats_folds_scale_into_beta_and_uniform_bounds(): + """``scale`` multiplies an exponential's beta and transforms uniform bounds.""" + beta, _, _ = TrialTableBuilder._distribution_stats( + ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=0.5), + scaling_parameters=ScalingParameters(scale=3.0), + ) + ) + # beta is 1 / rate = 2.0 in the base distribution's units, scaled by 3. + assert beta == pytest.approx(6.0) + # A uniform's bounds are distribution parameters, which are pre-scaling and + # so take the full ``value * scale + offset`` transform. + _, minimum, maximum = TrialTableBuilder._distribution_stats( + UniformDistribution( + distribution_parameters=UniformDistributionParameters(min=1.0, max=2.0), + scaling_parameters=ScalingParameters(scale=2.0, offset=0.5), + ) + ) + assert minimum == pytest.approx(2.5) + assert maximum == pytest.approx(4.5) + + def test_build_warns_on_misaligned_streams(caplog): """A per-trial stream shorter than TrialOutcome warns but still builds.""" table = TrialTableBuilder(_misaligned_dataset()).build()