diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 9165e80..6b6f31d 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -27,7 +27,7 @@ Checks for Θ, Ξ, and Ψ work that no unit test can close. Build **Release**, i - [ ] One razor-union case (two disjoint areas, one track) — lands the requested window, no `ReaSampler capture failed:` line (`PLAN.md:2137`) - [ ] Capture an item whose extent already equals the window — still lands, unchanged (the byte-identity regression floor) (`docs/COMPLETED.md:829`) - [ ] **The millisecond floor — what to expect.** A custom-bounds render is known to floor its window's END to the millisecond and write the floored value back over `RENDER_ENDPOS`. Mechanism, why two `RENDER_BOUNDSFLAG` channels exist, and the two live observations behind this: `src/core/capture/render_settings.h`'s `RenderBoundsChannel` and `docs/TODO.md` "An offline capture can be refused...". **Both live observations started at `0s`, on the grid, so nothing is known about the START edge** -- [ ] **The one experiment — does another bounds mode escape the floor?** This build renders on the TIME SELECTION channel (`RENDER_BOUNDSFLAG=2`, window handed over via `GetSet_LoopTimeRange`) instead of custom time bounds. Every capture prints one line beginning `ReaSampler capture -- bounds channel:` — including the two paths that answer before any bounds are judged (unsupported format, no output file), which print `NOT JUDGED` rather than staying silent. Read the verdict: a bare **SHORT**/**LONG** (no tag), or one naming "floored to the millisecond", is the floor's signature — it did not escape this channel. **(WITHIN TOLERANCE)** on a SHORT/LONG is the gate's ordinary ±1-frame edge-convention slack (`render_window.h`), not the floor — don't read it as either result. **EXACT on a window whose END is off the millisecond grid** is the fix — the floor did not reach this channel. **EXACT on a window whose END lands on the grid is NOT conclusive**: the line adds "The END edge is UNTESTED here too" — a floored render prints the identical count by coincidence, so re-run with an off-grid end before trusting EXACT. **NOT JUDGED naming a bounds channel** means that capture answered nothing (tail mode was not None, the render was empty, the render never even reached a bounds check) OR the render source itself derives its own bounds and never consulted the channel — selected-items/razor captures always read this way, so pick a window narrower than the selected item(s) to route through the time-bounded source instead +- [ ] **The one experiment — does another bounds mode escape the floor?** This build renders on the TIME SELECTION channel (`RENDER_BOUNDSFLAG=2`, window handed over via `GetSet_LoopTimeRange`) instead of custom time bounds. On every return past the point a bounds channel is chosen (the format/mode/empty-range/no-project refusals answer earlier and print nothing), one line beginning `ReaSampler capture -- bounds channel:` prints — including the two paths that answer before any bounds are judged (unsupported format, no output file), which print `NOT JUDGED` rather than staying silent. Read the verdict: **the floor's signature is ONLY a sentence naming "floored to the millisecond"** — a bare SHORT with no such sentence means the shortfall's cause is unestablished, and LONG can never be the floor's signature (a floor only removes frames, never adds them). **(WITHIN TOLERANCE)** on a SHORT/LONG is the gate's ordinary ±1-frame edge-convention slack (`render_window.h`), not the floor — don't read it as either result, and it can mask a floor: a window whose start and end sit in the same millisecond bucket makes the floored count equal the exact one, so a real one-frame floor there reads as a bare SHORT (WITHIN TOLERANCE) with no floor sentence at all. **EXACT is the fix only when the line carries no further caveat.** The verdict checks the observed count against every millisecond-floored model of the window (start floored alone, end floored alone, both together) and names any that reproduce it — grid membership on either edge is a proxy for that collision, not the test itself, so the caveat can fire even when NEITHER edge sits on the millisecond grid (sub-millisecond remainders on the two edges can cancel under a full floor — a dragged, fixed-length time selection is the reproducible case). Re-run with a window the caveat doesn't name before trusting EXACT. **NOT JUDGED naming a bounds channel** means that capture answered nothing (tail mode was not None, the render was empty, the render never even reached a bounds check, or the window rounds to 0 frames at this rate) OR the render source itself is INFERRED (not SDK-confirmed) to derive its own bounds and never consult the channel — selected-items captures always read this way; razor edits never do today, because no offline capture path assigns `SourceMode::RazorArea` (razor is a range source resolved through track/item scope, not a render source of its own) — so pick a window narrower than the selected item(s) to route through the time-bounded source instead - [ ] **Same run, the START edge.** The verdict line also says whether the run tested the start. Capture a range whose start is NOT a whole millisecond (set View → time unit to Samples, then nudge the selection start off the grid) so the line reads `The START edge IS tested here`. Report that line verbatim — it is the only evidence available for whether the start floors too, and a start floor is the case that would break the null test silently rather than loudly - [ ] **Auto and Manual tail.** Repeat the off-grid-start/off-grid-end capture once with the panel tail toggle at **Auto** and once at **Manual**. Neither is judged against a frame count, so the evidence is the `after render` drift lines: report whether either channel's bounds read back changed. `[verify — DAW]` A tail is assumed to render PAST the window end — the SDK header (`:3048`) confirms only that `RENDER_TAILMS` is a length in ms, not that it extends past the end. If that assumption is wrong, an end floor could be costing Auto/Manual real content with no detector (`checkRenderedBounds` returns immediately for `tailMode != None`) — so also report by ear/measurement whether either tail capture comes up short against the source, not only whether the bounds fields drifted - [ ] Whichever way the experiment lands, the refused render is still kept for diagnosis at `/reasampler_refused/` (the refusal line names the path; a failed move leaves it unindexed in the bank folder and says so). Delete the folder when done — nothing in the bank references it diff --git a/src/core/capture/render_settings.h b/src/core/capture/render_settings.h index 34a627c..cc04cfc 100644 --- a/src/core/capture/render_settings.h +++ b/src/core/capture/render_settings.h @@ -145,13 +145,16 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry); // naming them apart would assert a render distinction that does not exist. const char* renderSourceLabel(SourceMode mode); -// True for a render source that derives its bounds from content rather than from -// RENDER_BOUNDSFLAG at all: SelectedItems (&32) and RazorArea (&4096) bound -// themselves to the selected items'/areas' own extents (see the &32 inference in -// src/core/capture/CLAUDE.md §Gotchas; RazorArea is read the same way, sharing the -// single-file bit for the same reason). A capture on one of these never consults -// RenderBoundsChannel, so describeBoundsExperiment's verdict must not be read as -// evidence about the channel for it — the caller names the source instead. +// True for a render source INFERRED (not SDK-confirmed) to derive its bounds from +// content rather than RENDER_BOUNDSFLAG: SelectedItems (&32), per the observed-defect +// inference in src/core/capture/CLAUDE.md §Gotchas, and RazorArea (&4096) by analogy to +// it — the SDK header (~3042) actually separates bounds (RENDER_BOUNDSFLAG, its own +// value 4 = selected media items) from source (&32), which leans the other way. +// RazorArea stays in the set on that inference even though no offline capture path +// assigns it today — razor is a RANGE source (capture_batch.cpp's razor units render +// through track scope), not a render source of its own. A capture on either never +// consults RenderBoundsChannel, so describeBoundsExperiment's verdict must not be read +// as evidence about the channel — the caller names the source instead. bool sourceBypassesBoundsChannel(SourceMode mode); // --- Capture scope: the FX-scope invariant ------------------------------------ diff --git a/src/core/capture/render_window.cpp b/src/core/capture/render_window.cpp index 225e18e..8e92b36 100644 --- a/src/core/capture/render_window.cpp +++ b/src/core/capture/render_window.cpp @@ -76,20 +76,30 @@ std::string describeBoundsExperiment(const char* channelLabel, s += ". "; if (bypassingSourceLabel && bypassingSourceLabel[0]) { - s += "NOT JUDGED -- rendered from " + std::string(bypassingSourceLabel) + - ", which derives its bounds from content and never consulted this channel;" - " this capture is not evidence either way about it."; + s += "NOT JUDGED -- this capture's render source is " + + std::string(bypassingSourceLabel) + + ", INFERRED (not SDK-confirmed) to derive its bounds from content rather" + " than consult this channel; this capture is not evidence either way about it."; return s; } if (sampleRate <= 0) { - s += "NOT JUDGED -- the landed render's frames were never counted against the " + s += "NOT JUDGED -- this capture's frames were never counted against the " "window, so this capture is not evidence either way about the channel."; return s; } const long long expected = frameCountFor(reqStart, reqEnd, sampleRate); const long long delta = actualFrames - expected; + + // A window under a frame at this rate has nothing to compare: a 0-frame render + // against a 0-frame window is a coincidence of degenerate inputs, not a match. + if (expected == 0 && actualFrames == 0) { + s += "NOT JUDGED -- the requested window rounds to 0 frames at this rate, so a " + "0-frame render is not evidence either way about the channel."; + return s; + } + // Within the gate's own edge-convention slack (render_window.h): its normal // tolerance, not evidence the millisecond floor was escaped or hit. const bool withinTolerance = @@ -101,13 +111,14 @@ std::string describeBoundsExperiment(const char* channelLabel, " the window asks for at " + std::to_string(sampleRate) + " Hz."; // The shape both live short renders matched to the frame. A match says this channel - // produced a floored window; it does not locate where inside REAPER the floor is. - if (delta != 0) { + // produced a floored window; it does not locate where inside REAPER the floor is. A + // floor only removes frames, so this can only ever match a SHORT, never a LONG. + if (delta < 0) { const long long msFloored = msFlooredEndFrameCount(reqStart, reqEnd, sampleRate); if (msFloored > 0 && actualFrames == msFloored) s += " That is exactly the count this window holds with its end floored to" - " the millisecond -- this channel did not escape the floor."; + " the millisecond -- the shape REAPER's render was measured producing."; } s += isOnMillisecondGrid(reqStart) @@ -117,15 +128,38 @@ std::string describeBoundsExperiment(const char* channelLabel, : " The START edge IS tested here: " + exactly(reqStart) + "s carries a sub-millisecond remainder."; - // An EXACT verdict on an on-grid END is not proof: a channel that floors the end - // would have printed this same count, since floor/ceil/round all leave a grid point - // alone. Without this, EXACT reads as settled when this run could not have told the - // two apart. - if (delta == 0 && isOnMillisecondGrid(reqEnd)) { - s += " The END edge is UNTESTED here too: " + exactly(reqEnd) + - "s is already on the millisecond grid, so a channel that floors the end" - " would have printed this same EXACT count -- re-run over a window whose" - " end is off the grid before reading EXACT as the fix."; + // EXACT is proof only when no millisecond-floored model of this window could have + // produced the same count. Grid membership on an edge is a PROXY for that collision, + // not the test itself: sub-millisecond remainders on the two edges can cancel under + // a full floor even when neither edge is on the grid (a dragged, fixed-length time + // selection reproduces this), and a remainder under half a frame collides with a + // floored edge without ever registering as off-grid. Enumerate every floored model + // directly rather than inferring from grid membership. + if (delta == 0) { + const double flooredStart = floorToMilliseconds(reqStart); + const double flooredEnd = floorToMilliseconds(reqEnd); + const bool startAlone = + actualFrames == frameCountFor(flooredStart, reqEnd, sampleRate); + const bool endAlone = + actualFrames == frameCountFor(reqStart, flooredEnd, sampleRate); + const bool bothTogether = + actualFrames == frameCountFor(flooredStart, flooredEnd, sampleRate); + + if (startAlone || endAlone || bothTogether) { + std::string models; + auto addModel = [&](const char* label) { + if (!models.empty()) models += ", or "; + models += label; + }; + if (startAlone) addModel("floors the START edge alone"); + if (endAlone) addModel("floors the END edge alone"); + if (bothTogether) addModel("floors START and END together"); + + s += " EXACT here is not proof: a render that " + models + + " to the millisecond would print this identical count -- re-run over a" + " window where a floored edge would show a different count before" + " reading EXACT as the fix."; + } } return s; } diff --git a/src/core/capture/render_window.h b/src/core/capture/render_window.h index eb6df14..5e64d90 100644 --- a/src/core/capture/render_window.h +++ b/src/core/capture/render_window.h @@ -85,20 +85,32 @@ bool isOnMillisecondGrid(double seconds); // at all. Always non-empty — a capture that answered nothing has to say so, or its // silence reads as a pass. // -// EXACT never stands alone as proof: an END that sits on the millisecond grid prints -// the SAME EXACT count whether the channel honored the window or floored it and landed -// back on the grid by coincidence, so that case is called out in the sentence rather -// than left to read as settled — same principle as the existing START-edge caveat. +// EXACT never stands alone as proof: the observed count is checked against every +// millisecond-floored model of the same window (start floored alone, end floored alone, +// both together), and any model that reproduces it is named in the sentence. Grid +// membership on an edge is a PROXY for that collision, not the test itself — remainders +// on the two edges can cancel under a full floor even when NEITHER edge sits on the +// grid, and a remainder under half a frame collides with a floored edge without ever +// registering as off-grid at all. Checking the models directly is what a grid test on +// either edge alone cannot do. // // A non-zero delta that still falls inside the gate's own tolerance (renderHonoredBounds) // is tagged "(WITHIN TOLERANCE)" — that is the gate's ordinary edge-convention slack, not -// evidence of the millisecond floor; a bare SHORT/LONG, or a delta matching -// msFlooredEndFrameCount exactly, is the floor's signature. +// evidence of the millisecond floor. The floor's signature is ONLY the "floored to the +// millisecond" sentence (a delta matching msFlooredEndFrameCount exactly); a bare +// SHORT with no such sentence means the shortfall's cause is unestablished, and LONG can +// never be the floor's signature — a floor only removes frames, never adds them. A +// window whose start and end sit in the same millisecond bucket makes msFlooredEndFrameCount +// equal the exact count, so a real one-frame floor there reads as a bare SHORT (WITHIN +// TOLERANCE) with no floor sentence at all — that combination is not evidence the floor +// didn't happen, just a case this diagnostic can't see into. // // `sampleRate <= 0` means the landed file was never measured: a tail mode adds frames by // design and is not judged, an empty render has none, and a render whose layout failed to // parse or declared no sample rate is refused before it can be judged either — the -// sentence then says the run answered nothing rather than inventing a comparison. +// sentence then says the run answered nothing rather than inventing a comparison. A +// window that rounds to 0 frames at this rate reads NOT JUDGED the same way: a 0-frame +// render against a 0-frame window is not a comparison either. // // `bypassingSourceLabel`, when non-null and non-empty, means the render source itself // defined the window (render_settings::sourceBypassesBoundsChannel) — `channelLabel` was diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 0e683be..ccce78f 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -563,10 +563,11 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // because a verdict that appeared only on some outcomes would read its own absence // on the rest as a pass. Auto/Manual are not judged against a frame count (they add // frames by design) and the sentence says so rather than comparing anyway. - // SelectedItems/RazorArea derive their bounds from content and never consult the - // channel at all (render_settings::sourceBypassesBoundsChannel) — the batch-item - // path renders through SelectedItems on every capture, so without this the verdict - // would print an EXACT/SHORT/LONG claim about a channel that was never in play. + // SelectedItems/RazorArea are INFERRED (not SDK-confirmed) to derive their bounds + // from content and never consult the channel at all + // (render_settings::sourceBypassesBoundsChannel) — the batch-item path renders + // through SelectedItems on every capture, so without this the verdict would print an + // EXACT/SHORT/LONG claim about a channel that was never in play. const char* boundsBypassLabel = sourceBypassesBoundsChannel(request.sourceMode) ? renderSourceLabel(request.sourceMode) : nullptr; diff --git a/tests/test_render_window.cpp b/tests/test_render_window.cpp index 721e183..184fe18 100644 --- a/tests/test_render_window.cpp +++ b/tests/test_render_window.cpp @@ -417,10 +417,79 @@ static void testAWindowAlreadyOnTheGridIsUnaffectedByTheChannelSwitch() { CHECK(contains(s, "EXACT")); CHECK(contains(s, "96000")); CHECK(!contains(s, "floored to the millisecond")); - // The false positive this window is the shape of: an end-floored render would have - // printed this identical EXACT count, so the line must say this run cannot tell the - // two apart rather than reading EXACT as settled. - CHECK(contains(s, "END edge is UNTESTED")); + // The false positive this window is the shape of: a render that floored either edge + // alone, or both together, would have printed this identical EXACT count (every + // edge here is on the grid) -- the line has to say this run cannot rule any of them + // out rather than reading EXACT as settled. + CHECK(contains(s, "EXACT here is not proof")); + CHECK(contains(s, "floors the START edge alone")); + CHECK(contains(s, "floors the END edge alone")); + CHECK(contains(s, "floors START and END together")); +} + +static void testEqualRemaindersCancelUnderAFullFloorEvenOffGrid() { + // C1: a dragged, fixed-length time selection reproduces this. Neither edge sits on + // the millisecond grid (isOnMillisecondGrid is false for both), but the START and + // END frame-rounding remainders are EQUAL (rs == re == 8 frames), so a render that + // floors both edges together lands on the identical count -- the grid predicate on + // either edge alone would have missed this collision entirely. + const double start = 1.0001724, end = 2.0001724; + CHECK(!isOnMillisecondGrid(start)); + CHECK(!isOnMillisecondGrid(end)); + const long long expected = frameCountFor(start, end, 48000); + CHECK(expected == 48000); + // The both-edges-floored render lands on the SAME count as the exact one. + CHECK(frameCountFor(1.000, 2.000, 48000) == expected); + // Neither edge floored ALONE reproduces it -- only the combined floor does. + CHECK(frameCountFor(1.000, end, 48000) != expected); + CHECK(frameCountFor(start, 2.000, 48000) != expected); + + const std::string s = + describeBoundsExperiment("time selection", start, end, expected, 48000); + CHECK(contains(s, "EXACT")); + CHECK(contains(s, "EXACT here is not proof")); + CHECK(contains(s, "floors START and END together")); + CHECK(!contains(s, "floors the START edge alone")); + CHECK(!contains(s, "floors the END edge alone")); +} + +static void testEndOffGridByUnderHalfAFrameStillCollidesWithAFlooredEnd() { + // C1's second live shape: isOnMillisecondGrid reads this END as off-grid, but the + // remainder is under half a frame at 48 kHz, so flooring it doesn't move its frame + // index -- a grid test on the edge alone would still miss this collision. + const double start = 0.0, end = 1.000005; + CHECK(!isOnMillisecondGrid(end)); + const long long expected = frameCountFor(start, end, 48000); + CHECK(expected == 48000); + CHECK(frameCountFor(start, 1.000, 48000) == expected); // the floored-end model matches + + const std::string s = + describeBoundsExperiment("time selection", start, end, expected, 48000); + CHECK(contains(s, "EXACT")); + CHECK(contains(s, "EXACT here is not proof")); + CHECK(contains(s, "floors the END edge alone")); +} + +static void testALongVerdictNeverCarriesTheFloorSentence() { + // A floor only removes frames, so LONG can never be its signature -- the sentence + // must not appear even though the delta here is a "clean" one-frame LONG. + const std::string s = + describeBoundsExperiment("time selection", 5.0, 6.0, 48001, 48000); + CHECK(contains(s, "LONG")); + CHECK(!contains(s, "floored to the millisecond")); +} + +static void testASubFrameWindowIsNotJudgedNotExact() { + // A window under one frame at this rate rounds to 0 expected frames. A 0-frame + // render against that is a 0-vs-0 coincidence of degenerate inputs, not a match -- + // it must read NOT JUDGED, never EXACT. + const double oneTenthOfAFrame = 1.0 / (48000.0 * 10.0); + const long long expected = frameCountFor(0.0, oneTenthOfAFrame, 48000); + CHECK(expected == 0); + const std::string s = + describeBoundsExperiment("time selection", 0.0, oneTenthOfAFrame, 0, 48000); + CHECK(contains(s, "NOT JUDGED")); + CHECK(!contains(s, "EXACT")); } static void testAWithinToleranceDeltaIsTaggedNotFloorShaped() { @@ -460,10 +529,18 @@ static void testABypassingSourceReadsNotJudgedAndNamesTheSourceNotTheChannel() { } static void testANullOrEmptyBypassLabelFallsBackToTheOrdinaryVerdict() { - CHECK(contains(describeBoundsExperiment("time selection", 0.0, 1.0, 48000, 48000, - nullptr), - "EXACT")); - CHECK(contains(describeBoundsExperiment("time selection", 0.0, 1.0, 48000, 48000, ""), + // Off-grid, non-cancelling edges (see testEqualRemaindersCancelUnderAFullFloorEvenOffGrid + // for the window shape that WOULD trip the collision caveat, whose own text also + // contains "EXACT") so this assertion is pinned to the verdict word itself, not to a + // caveat sentence that happens to contain the same substring. + const double start = 1.0001724, end = 2.0009724; + const long long expected = frameCountFor(start, end, 48000); + const std::string withNull = + describeBoundsExperiment("time selection", start, end, expected, 48000, nullptr); + CHECK(contains(withNull, "EXACT")); + CHECK(!contains(withNull, "EXACT here is not proof")); + CHECK(contains(describeBoundsExperiment("time selection", start, end, expected, 48000, + ""), "EXACT")); } @@ -476,18 +553,24 @@ static void testAnOnGridStartSaysTheStartEdgeIsUntested() { } static void testAnOffGridStartSaysTheStartEdgeIsTested() { - // The run that would settle the start question: a start carrying its own remainder. - // Whether REAPER floors the start or not, THIS run is the one that shows it. + // The run that would genuinely settle the start question: a start carrying its own + // remainder, paired with an end whose remainder does NOT cancel it (unlike + // testEqualRemaindersCancelUnderAFullFloorEvenOffGrid's window, where the same shape + // of start value pairs with an end that cancels it and the collision caveat fires + // instead). No floored model reproduces this count, so EXACT here is unqualified. + const double start = 1.0001724, end = 2.0009724; + const long long expected = frameCountFor(start, end, 48000); const std::string s = - describeBoundsExperiment("time selection", 1.0001724, 2.0001724, 48000, 48000); + describeBoundsExperiment("time selection", start, end, expected, 48000); CHECK(contains(s, "IS tested")); CHECK(!contains(s, "UNTESTED")); - // A floored start would have printed 48008 frames, not 48000 — so the same line - // reads EXACT here and SHORT/LONG on the floored outcome. - CHECK(frameCountFor(1.000, 2.0001724, 48000) == 48008); CHECK(contains(s, "EXACT")); - CHECK(contains(describeBoundsExperiment("time selection", 1.0001724, 2.0001724, - 48008, 48000), + CHECK(!contains(s, "EXACT here is not proof")); + // A start-floored-alone render would have printed a DIFFERENT count here, so a + // mismatch against `expected` on a re-run is real evidence, not ambiguous. + CHECK(frameCountFor(1.000, end, 48000) != expected); + CHECK(contains(describeBoundsExperiment("time selection", start, end, + frameCountFor(1.000, end, 48000), 48000), "LONG")); } @@ -611,6 +694,10 @@ int main() { testAShortfallThatIsNotTheMillisecondShapeClaimsNothingAboutIt(); testARenderPastTheWindowReadsLong(); testAWindowAlreadyOnTheGridIsUnaffectedByTheChannelSwitch(); + testEqualRemaindersCancelUnderAFullFloorEvenOffGrid(); + testEndOffGridByUnderHalfAFrameStillCollidesWithAFlooredEnd(); + testALongVerdictNeverCarriesTheFloorSentence(); + testASubFrameWindowIsNotJudgedNotExact(); testAWithinToleranceDeltaIsTaggedNotFloorShaped(); testABypassingSourceReadsNotJudgedAndNamesTheSourceNotTheChannel(); testANullOrEmptyBypassLabelFallsBackToTheOrdinaryVerdict();