diff --git a/docs/trials_table_mapping.md b/docs/trials_table_mapping.md index 23ea0e9..c9f0b5d 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,4 +232,5 @@ 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. | | 2026-08-20 | `bait_left` / `bait_right` now read `trial.metadata.extra.is_left_baited` / `is_right_baited` from the acquisition software instead of being re-derived from `p_reward_left` / `p_reward_right` and the `is_auto_reward_right` channel. The software is the authority on bait state, so the two can disagree — notably a port with `p_reward == 1` is no longer assumed baited. `False` when the trial carries no extra metadata. | diff --git a/src/dynamic_foraging_processing/processing/_trial_table.py b/src/dynamic_foraging_processing/processing/_trial_table.py index ca0b5b1..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 diff --git a/tests/test_processing/test_trial_table.py b/tests/test_processing/test_trial_table.py index fd6aa73..6417cef 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, @@ -181,7 +182,10 @@ 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( distribution_parameters=ExponentialDistributionParameters(rate=0.05), @@ -380,7 +384,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. @@ -484,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()