From f51d966ea16e35fbae120d9f6e164d415443766e Mon Sep 17 00:00:00 2001 From: fan wu <336798967+FanWu-ai@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:30:31 -0700 Subject: [PATCH 1/3] Preserve recorded duration when resampling sessions AI assistance: OpenAI Codex was used for investigation, implementation, regression tests, and automated validation. --- pyinstrument/session.py | 9 ++++++- test/test_session.py | 59 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 test/test_session.py diff --git a/pyinstrument/session.py b/pyinstrument/session.py index 42543344..9613900d 100644 --- a/pyinstrument/session.py +++ b/pyinstrument/session.py @@ -223,14 +223,21 @@ def _resample_frame_records( """ result: list[FrameRecordType] = [] accumulated_time = 0.0 + # Time carried across interval boundaries has already been recorded. + pending_time = 0.0 for frame_info_stack, time in frame_records: accumulated_time += time + pending_time += time if accumulated_time >= interval: - result.append((frame_info_stack, accumulated_time)) + result.append((frame_info_stack, pending_time)) + pending_time = 0.0 accumulated_time = accumulated_time % interval + if pending_time: + result.append((frame_records[-1][0], pending_time)) + return result def resample(self, interval: float) -> Session: diff --git a/test/test_session.py b/test/test_session.py new file mode 100644 index 00000000..16a448ed --- /dev/null +++ b/test/test_session.py @@ -0,0 +1,59 @@ +import pytest + +from pyinstrument.session import Session + + +@pytest.mark.parametrize( + "durations, expected_indices, expected_durations", + [ + ([0.006] * 4, [1, 3], [0.012, 0.012]), + ([0.006] * 5, [1, 3, 4], [0.012, 0.012, 0.006]), + ([0.002, 0.007, 0.003, 0.004, 0.009], [2, 4], [0.012, 0.013]), + ([0.009, 0.004, 0.006], [1, 2], [0.013, 0.006]), + ([0.004, 0.002], [1], [0.006]), + ([0.015, 0.002], [0, 1], [0.015, 0.002]), + ([0.01] * 3, [0, 1, 2], [0.01] * 3), + ([], [], []), + ], +) +def test_resample_preserves_recorded_time(durations, expected_indices, expected_durations): + frame_records = [ + (["root\x00example.py\x001", f"sample{index}\x00example.py\x002"], duration) + for index, duration in enumerate(durations) + ] + session = Session( + frame_records=frame_records, + start_time=123, + duration=sum(durations), + min_interval=0.001, + max_interval=0.015, + sample_count=len(frame_records), + start_call_stack=[], + target_description="resampling test", + cpu_time=sum(durations), + sys_path=[], + sys_prefixes=[], + ) + + resampled = session.resample(0.01) + + assert [stack for stack, _ in resampled.frame_records] == [ + frame_records[index][0] for index in expected_indices + ] + assert [duration for _, duration in resampled.frame_records] == pytest.approx( + expected_durations + ) + assert sum(duration for _, duration in resampled.frame_records) == pytest.approx(sum(durations)) + assert resampled.sample_count == len(expected_indices) + assert resampled.duration == session.duration + assert resampled.cpu_time == session.cpu_time + assert resampled.min_interval == resampled.max_interval == 0.01 + assert session.frame_records == frame_records + assert [duration for _, duration in frame_records] == durations + + root = resampled.root_frame() + if durations: + assert root is not None + assert root.time == pytest.approx(sum(durations)) + else: + assert root is None From f3bf2e7e66f29efe8d6b0c03afcdf118ee0217e1 Mon Sep 17 00:00:00 2001 From: fan wu <336798967+FanWu-ai@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:07:16 -0700 Subject: [PATCH 2/3] Floor resampled durations without double-counting the remainder Follow maintainer feedback: emit only whole sampling intervals with one accumulator, carry the remainder forward, and omit a final partial interval. Replace exact-preservation assertions with quantization and bounded-error regressions. Validated on CPython 3.12/Linux: all 159 tests across both required modes; nine pinned hooks; docs; wheel and sdist; strict Twine checks. AI assistance: OpenAI Codex. --- pyinstrument/session.py | 11 ++----- test/test_session.py | 68 ++++++++++++++++++++++++++++++++++------- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/pyinstrument/session.py b/pyinstrument/session.py index 9613900d..a6df6813 100644 --- a/pyinstrument/session.py +++ b/pyinstrument/session.py @@ -220,24 +220,19 @@ def _resample_frame_records( ) -> list[FrameRecordType]: """ Resample frame records to a given interval. Discards samples as needed. + Durations are rounded down to whole intervals, carrying the remainder + forward. A final partial interval is discarded. """ result: list[FrameRecordType] = [] accumulated_time = 0.0 - # Time carried across interval boundaries has already been recorded. - pending_time = 0.0 for frame_info_stack, time in frame_records: accumulated_time += time - pending_time += time if accumulated_time >= interval: - result.append((frame_info_stack, pending_time)) - pending_time = 0.0 + result.append((frame_info_stack, accumulated_time - accumulated_time % interval)) accumulated_time = accumulated_time % interval - if pending_time: - result.append((frame_records[-1][0], pending_time)) - return result def resample(self, interval: float) -> Session: diff --git a/test/test_session.py b/test/test_session.py index 16a448ed..63f8d751 100644 --- a/test/test_session.py +++ b/test/test_session.py @@ -1,3 +1,6 @@ +import itertools +import math + import pytest from pyinstrument.session import Session @@ -6,17 +9,20 @@ @pytest.mark.parametrize( "durations, expected_indices, expected_durations", [ - ([0.006] * 4, [1, 3], [0.012, 0.012]), - ([0.006] * 5, [1, 3, 4], [0.012, 0.012, 0.006]), - ([0.002, 0.007, 0.003, 0.004, 0.009], [2, 4], [0.012, 0.013]), - ([0.009, 0.004, 0.006], [1, 2], [0.013, 0.006]), - ([0.004, 0.002], [1], [0.006]), - ([0.015, 0.002], [0, 1], [0.015, 0.002]), + ([0.006] * 4, [1, 3], [0.01, 0.01]), + ([0.006] * 5, [1, 3, 4], [0.01] * 3), + ([0.002, 0.007, 0.003, 0.004, 0.009], [2, 4], [0.01, 0.01]), + ([0.009, 0.004, 0.006], [1], [0.01]), + ([0.004, 0.002], [], []), + ([0.015, 0.002], [0], [0.01]), + ([0.035, 0.006], [0, 1], [0.03, 0.01]), ([0.01] * 3, [0, 1, 2], [0.01] * 3), + ([0.0, 0.006, 0.0, 0.006, 0.0], [3], [0.01]), + ([0.0] * 3, [], []), ([], [], []), ], ) -def test_resample_preserves_recorded_time(durations, expected_indices, expected_durations): +def test_resample_quantizes_recorded_time(durations, expected_indices, expected_durations): frame_records = [ (["root\x00example.py\x001", f"sample{index}\x00example.py\x002"], duration) for index, duration in enumerate(durations) @@ -26,7 +32,7 @@ def test_resample_preserves_recorded_time(durations, expected_indices, expected_ start_time=123, duration=sum(durations), min_interval=0.001, - max_interval=0.015, + max_interval=0.035, sample_count=len(frame_records), start_call_stack=[], target_description="resampling test", @@ -43,7 +49,9 @@ def test_resample_preserves_recorded_time(durations, expected_indices, expected_ assert [duration for _, duration in resampled.frame_records] == pytest.approx( expected_durations ) - assert sum(duration for _, duration in resampled.frame_records) == pytest.approx(sum(durations)) + assert sum(duration for _, duration in resampled.frame_records) == pytest.approx( + sum(expected_durations) + ) assert resampled.sample_count == len(expected_indices) assert resampled.duration == session.duration assert resampled.cpu_time == session.cpu_time @@ -52,8 +60,46 @@ def test_resample_preserves_recorded_time(durations, expected_indices, expected_ assert [duration for _, duration in frame_records] == durations root = resampled.root_frame() - if durations: + if expected_indices: assert root is not None - assert root.time == pytest.approx(sum(durations)) + assert root.time == pytest.approx(sum(expected_durations)) else: assert root is None + + +def test_resample_carries_remainder_across_records(): + # Binary-exact durations provide an independent integer-arithmetic oracle. + # Check every prefix, including zero durations and multiple-interval samples. + for ticks in itertools.product([0, 1, 3, 4, 9], repeat=4): + records = [([str(index)], tick / 32) for index, tick in enumerate(ticks)] + for length in range(1, len(records) + 1): + result = Session._resample_frame_records(records[:length], interval=4 / 32) + expected_ticks = sum(ticks[:length]) // 4 * 4 + assert sum(duration for _, duration in result) == expected_ticks / 32 + assert all(duration > 0 and duration % (4 / 32) == 0 for _, duration in result) + + +@pytest.mark.parametrize( + "duration, expected", + [(0.375 - 2**-54, 0.25), (0.375, 0.375), (0.375 + 2**-54, 0.375)], +) +def test_resample_near_interval_multiple(duration, expected): + # Retain the existing floating-point boundary semantics: a value just below + # a multiple rounds down, while the exact multiple and the next float do not. + result = Session._resample_frame_records([(["sample"], duration)], interval=0.125) + assert result == [(["sample"], expected)] + + +def test_resample_rounding_error_does_not_accumulate(): + interval = 0.01 + records = [(["sample"], duration) for duration in [0.006, 0.017, 0.002] * 1001] + result = Session._resample_frame_records(records, interval) + error = math.fsum(duration for _, duration in records) - math.fsum( + duration for _, duration in result + ) + # Only the final partial interval is omitted, rather than one per sample. + # A small tolerance allows for binary floating-point arithmetic. + assert -1e-12 <= error < interval + 1e-12 + assert all( + duration / interval == pytest.approx(round(duration / interval)) for _, duration in result + ) From 7fcb9ea08a6e93be0f1833d7e4e001225a092887 Mon Sep 17 00:00:00 2001 From: fan wu <336798967+FanWu-ai@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:18:59 -0700 Subject: [PATCH 3/3] Move resampling details from docstring to code comment AI assistance: OpenAI Codex. --- pyinstrument/session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyinstrument/session.py b/pyinstrument/session.py index a6df6813..b4865862 100644 --- a/pyinstrument/session.py +++ b/pyinstrument/session.py @@ -220,12 +220,12 @@ def _resample_frame_records( ) -> list[FrameRecordType]: """ Resample frame records to a given interval. Discards samples as needed. - Durations are rounded down to whole intervals, carrying the remainder - forward. A final partial interval is discarded. """ result: list[FrameRecordType] = [] accumulated_time = 0.0 + # Durations are rounded down to whole intervals, carrying the remainder + # forward. A final partial interval is discarded. for frame_info_stack, time in frame_records: accumulated_time += time