Skip to content

Sample a half-open range, and prepare 0.4.0 - #56

Merged
keithlostracco merged 7 commits into
mainfrom
fix/half-open-rate-sampling
Jul 27, 2026
Merged

Sample a half-open range, and prepare 0.4.0#56
keithlostracco merged 7 commits into
mainfrom
fix/half-open-rate-sampling

Conversation

@keithlostracco

@keithlostracco keithlostracco commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Sampling by rate returned one more sample than the duration and rate imply:
4 seconds at 30 Hz gave 121 values rather than 120. Fixing that turned into a
review of the whole sampling surface, so this is the 0.4.0 release.

The + 1 was load-bearing

evaluate_range divided by num_samples - 1, spanning a closed range, and
evaluate_range_by_rate delegated to it. Passing duration * rate + 1 was the
only count that made the spacing come out at 1 / sample_rate. Deleting the
+ 1 alone would have returned 120 samples of wrong data:

N passed step
121 0.033333333 exactly 1/30
120 0.033613445 4/119 — every sample after the first between frames

Sample times are now derived from the sample index, which lets the range be
half-open: end_time is not sampled, a span of n periods gives n samples, and
the count is the product callers expect.

Two further faults the old count hid

  • Off-rate spacing. A range that was not a whole number of periods was
    rounded up and then compressed to fit the closed range. 1.05 seconds at 30 Hz
    came back as 33 values 0.0328 apart — not 30 Hz at all.
  • Rounding error inflating the count. ceil on a product landing a few ulps
    above a whole number added a sample spanning a fraction of a period.
    nextafter(4.0, 5.0) at 30 Hz returned 122.

Measured against the built library, all cases now on-rate with the end excluded:

duration rate now last sample before
4.0 30 120 3.966667 121
1.05 30 32 1.033333 33 at 0.0328 spacing
nextafter(4.0, 5.0) 30 120 3.966667 122
2.9 100 290 2.890000 291

RangeEnd

Half-open is right for a timeline, where a sample covers the interval that
follows it. It is wrong for the cases that treat samples as points on a curve:
plotting, interpolation lookup tables, and numerical integration all need the
last point on the end rather than one step short. The viewer showed the cost of
not having it, sampling by hand and appending the end point so the plotted line
would reach the last keyframe.

RangeEnd is a trailing argument on both range methods and num_samples,
defaulting to Exclusive. For a rate, the two ends differ only when the span is
a whole number of periods, since that is the only case where a sample lands on
the end at all.

SampleTimes

Sampling returns values without times, so a caller needing them had to restate
the step — and its divisor depends on the RangeEnd, which is the same
confusion that made the original count wrong.

Returning a second vector would answer that badly: the times carry no
information beyond a start, a step and a count, so storing them doubles the
memory of a bake to memoise a closed form. SampleTimes holds those three
numbers and computes a time from an index. Measured over 50 million indexings it
is 42.73 ms against 42.74 ms for the arithmetic written out — the same code.

The methods live on Animation, whose start and end are the time base a whole
animation is sampled over, so every channel shares one set of times whatever its
own keyframes do. Free functions cover any other range.

Channel::num_samples removed

It counted over the channel's keyframe extent, which is an editing concept —
where a curve's data happens to lie, not the range a host samples over. A
channel bound to a timeline was counted over the wrong span with nothing to
indicate it, and a channel holds no reference to the animation that owns it to
answer otherwise. Every other sampling entry point already names its range.

ch.num_samples(rate)
  →  sample_times_by_rate(ch.start_time(), ch.end_time(), rate).size()

Also in this release

  • Channel::evaluate_range returns exactly the requested count in every case.
    It previously collapsed to a single sample when the range was empty, breaking
    any caller sizing a buffer from the count it passed in.
  • It also validates its range before the early returns, so a reversed range is
    rejected for every count rather than only for large ones.
  • Animation::num_samples returns size_t rather than int.
  • The viewer plots from a single evaluate_range call and takes its x axis from
    sample_times, replacing an accumulating loop and its endpoint fixup. The
    points differ by at most 2e-12 and the last lands on the end time exactly.
  • CODE_OF_CONDUCT.md is dropped: the Contributor Covenant describes a
    moderation and enforcement process that overstates how this project is run.

Verification

75 tests pass. The viewer was built from a clean checkout with no imgui.ini,
launched, and driven — both windows lay out correctly, all five curves render
and reach the last keyframe at t = 32, and expanding a curve in the editor shows
its keyframes. Release notes extraction was dry-run for 0.4.0 (6359 bytes, all
four sections, no leak into 0.3.0) and for a nonexistent version, which still
yields nothing so the workflow's empty-notes guard holds.

After merge

Tag v0.4.0 on the merge commit and push, which publishes the release from the
changelog entry. Anything pinning this branch needs repointing, since the squash
rewrites the commit.

Sampling by rate returned one more sample than the duration and rate
imply: 4 seconds at 30 Hz gave 121 values rather than 120. The count came
from evaluate_range, which spreads a sample count across a closed range
and so divides by num_samples - 1. Passing duration * rate + 1 was what
made that spacing come out at 1 / sample_rate, so the extra sample was
load-bearing rather than a stray increment.

Sample times are now derived from the sample index instead, which lets
the range be half-open. end_time is no longer sampled, so a span of n
periods gives n samples, and the count is the product callers expect.
evaluate_range keeps its closed-range meaning for callers who want the
end included.

This also fixes two faults in the old count. A range that was not a whole
number of periods was rounded up and then compressed to fit the closed
range, so 1.05 seconds at 30 Hz came back as 33 values 0.0328 apart
rather than at 30 Hz at all. And because the count used ceil, a product
landing a few ulps above a whole number added a sample spanning a
fraction of a period.

The rounding rule is shared between Channel and Animation rather than
written out at each site, so the two cannot drift.
evaluate_range returned a single sample whenever start_time equalled
end_time, no matter what count was asked for, which silently broke any
caller sizing a buffer from the count it passed in. The step is zero for
an empty range, not undefined, so the general path already handles it;
only a count of one needs a special case, to avoid dividing by zero.

The range was also validated after the early return for counts of one or
less, so evaluate_range(10, 0, 0) returned a value while the same
reversed range with a count of 2 threw. The range is now checked first,
for every count. A count of zero returns nothing rather than one sample,
and a negative count is rejected rather than quietly treated as one.

Animation::num_samples returns size_t, matching Channel::num_samples.
Both count the same thing and had no reason to differ.
@keithlostracco

Copy link
Copy Markdown
Contributor Author

Picked up both follow-ups, plus one more in the same family.

evaluate_range now returns the count you asked for, always. It collapsed
to a single sample whenever start_time == end_time, regardless of the count —
which silently breaks a caller sizing a buffer from the count it passed in.
Worth noting the special case was never needed for safety: the step is
(end - start) / (count - 1), which is zero for an empty range, not undefined.
Only a count of one needs guarding, to avoid dividing by zero.

Validation moved ahead of the early returns, so a reversed range is rejected
for every count rather than only for counts large enough to reach the loop. A
count of zero returns an empty vector; a negative count throws rather than being
treated as one sample.

Animation::num_samples returns size_t, matching Channel::num_samples.

evaluate_range(1.0, 1.0, 5) returning 5 values instead of 1 is a third
breaking change beyond the two you named — flagging it explicitly since it
changes an existing documented expectation. It seemed clearly right given the
CHOP buffer-sizing case, but say so if you would rather keep the old collapse.

73/73 tests pass.

One lead I chased and dropped

evaluate_range seeds the Bézier solver with the previous sample's value,
where the parameter is documented as a t in [0, 1]. That looked like a
performance trap for normalized channels, whose values land in [0, 1] and so get
accepted as seeds.

It is not a problem. Measured over 1M samples, seed choice changes the result by
nothing at all on a well-formed curve, and timing differences were noise in both
directions. My first attempt at measuring it was invalid — I varied the channel's
value scale to toggle whether the seed was accepted, but the auto-computed handle
modes derive handles from neighbouring values, so scaling the values changed the
curve rather than isolating the seed. Re-run with HandleMode::Free as a proper
control, the two agree to 11 digits, which is solver tolerance rather than a
different answer. No change needed.

Both range methods sampled a half-open range, which is right for a
timeline, where a sample covers the interval that follows it and the end
is an edge. It is wrong for the cases that treat samples as points on the
curve: plotting a curve, building an interpolation lookup table, and
integrating numerically all need the last point to land on the end rather
than one step short. The bundled viewer shows the cost of not having it,
sampling by hand and then appending the end point to stop the plotted
line falling short of the last keyframe.

RangeEnd is a trailing argument on both range methods and both
num_samples overloads, defaulting to Exclusive so the timeline reading
stays the default. Making the choice explicit also keeps the two methods
from disagreeing silently, which is what made the old sample count wrong:
the count-based method spanned a closed range, the rate-based one assumed
otherwise, and a + 1 bridged them.

evaluate_range is half-open by default now, so evaluate_range_by_rate can
delegate to it again rather than running its own loop. It passes the span
the samples actually cover instead of the requested end, since the two
differ when the range is not a whole number of periods, and hands over
the same range end so the divisor matches. Measured against evaluating
start + i / rate directly, sample times are off by at most 1.4e-14, and
not at all for ranges starting far from zero.
The viewer sampled each curve by accumulating t += eval_step and then
appended the end point, because the loop's last step generally lands
short of the last keyframe and the plotted line would stop before it.
That fixup is what the closed range now covers.

Sampling by rate would not do, even though eval_step is a step: a closed
rate-based range only lands on the end time when the span is a whole
number of steps, which an arbitrary keyframe layout will not be. The
count-based overload derives its step from the span, so the last sample
is on the end time whatever the duration.

Over the example's own curves the two agree to within 2e-12, the
difference being the drift the accumulated loop picked up over 3200
steps, and the final point is now exactly the end time rather than a step
away from it.

Also corrects two changelog entries that the RangeEnd work had made
stale, one of which claimed evaluate_range still spans a closed range.
Sampling returns values without times, so a caller that needs them, such
as anything plotting against a time axis, has to restate the step. That
arithmetic is not quite trivial: the divisor is the sample count for a
half-open range and one less for a closed one, so it is easy to pair with
the wrong range end, which is the same confusion that made the old sample
count wrong.

Returning a second vector would answer it badly. The times carry no
information beyond a start, a step and a count, so storing them doubles
the memory of a bake to memoise a closed form, and leaves two buffers that
can disagree. SampleTimes holds the three numbers instead and computes a
time from an index: 24 bytes, nothing allocated, and indexing it measures
the same as writing out the multiply and add, over 50 million samples.

The methods live on Animation, whose start and end are the time base a
whole animation is sampled over, so every channel shares one set of times
whatever its own keyframes do. Free functions cover any other range. Both
derive the step the way the matching evaluate_range call does rather than
restating the rule, so they cannot drift from it.

Channel gets no equivalent, and its num_samples goes with them. Those
sampled the channel's keyframe extent, which is an editing concept --
where a curve's data happens to lie, not the range a host samples over.
Where channels are bound to a timeline, counting over the keyframe span
gives the wrong buffer size with nothing to indicate it, and a channel
holds no reference to its animation to answer otherwise. Adding one would
not settle it either, since start_time() and end_time() would still report
the keyframe extent, leaving one class describing two ranges depending on
which method was called. Every other sampling entry point already names
its range; these were the only ones guessing.

The viewer takes its x axis from sample_times, passing the keyframe range
explicitly, which is what prompted this: it was the caller restating the
step arithmetic.
@keithlostracco
keithlostracco force-pushed the fix/half-open-rate-sampling branch from 472c849 to e1fa702 Compare July 26, 2026 22:26
The Contributor Covenant lays out a moderation and enforcement process --
warnings, temporary bans, permanent bans, an appeals path -- that reads as
a description of how this project is governed, and it is not. CONTRIBUTING
already covers how to take part, and security reports have their own
private channel in SECURITY.

Also corrects the sampling entry to describe removing Channel::num_samples
alone. The channel sample_times methods it also named were squashed out of
this release rather than added and withdrawn within it, so nobody reading
these notes will have seen them exist.
Close the [Unreleased] section as [0.4.0] and bump the project version,
which is the single source for the installed find_package config.
@keithlostracco keithlostracco changed the title Sample a half-open range when sampling by rate Sample a half-open range, and prepare 0.4.0 Jul 27, 2026
@keithlostracco
keithlostracco merged commit bb0a5ba into main Jul 27, 2026
8 checks passed
@keithlostracco
keithlostracco deleted the fix/half-open-rate-sampling branch July 27, 2026 04:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant