diff --git a/tools/scale_trace.py b/tools/scale_trace.py index 35350ff1..858238a6 100644 --- a/tools/scale_trace.py +++ b/tools/scale_trace.py @@ -346,6 +346,7 @@ def main(argv=None): """ vals, why = sample_per_frame(conn, pc_, count, reg_) if vals: + doc["native_sampling"] = "per-frame" return vals, why print(f" psx-runtime: per-frame captured nothing ({why}); retrying " f"free-running — the steps will be aliased, but a measurement " @@ -355,6 +356,10 @@ def main(argv=None): doc["native_sampling"] = "free-running-fallback" return vals2, (why2 or why) + # Recorded up front and overwritten by whichever path actually ran. Leaving + # it unset on success meant a per-frame series and a free-running one looked + # identical in the report -- and they support completely different claims. + doc["native_sampling"] = "free-running" if args.per_frame: print(" psx-runtime: stepping one frame between reads, so the deltas " "are the real per-frame increment") @@ -495,16 +500,30 @@ def main(argv=None): print(f"\n Step sizes are comparable " f"(psx-runtime max {a['max_step']}, oracle max " f"{b['max_step']}), so the fade granularity matches too.") - elif (args.per_frame and a["samples"] >= 5 - and doc.get("native_sampling") != "free-running-fallback"): + elif doc.get("native_sampling") == "per-frame" and a["samples"] >= 4: + # Consecutive frames measure the increment directly. That is not a + # sample of a distribution needing a quorum -- four adjacent frames + # showing steps of 2 IS the step, whereas four free-running samples + # ninety frames apart say nothing however many there are. doc["granularity"] = "native-measured-per-frame" - print(f"\n psx-runtime, sampled on CONSECUTIVE frames, moves in " - f"steps of up to {a['max_step']} (median " - f"{a['median_step']}). That is its real animation increment. " - f"The oracle's series is not frame-adjacent, so its apparent " - f"step is an upper bound only — but a per-frame increment " - f"this large is a coarse fade regardless of what the oracle " - f"does.") + smooth = a["max_step"] <= 8 + doc["native_smooth"] = smooth + print(f"\n psx-runtime, on {a['samples']} CONSECUTIVE frames, " + f"moves in steps of up to {a['max_step']} (median " + f"{a['median_step']}). That is its real animation increment.") + if smooth: + print(f" That is a SMOOTH fade — at {a['median_step']} per " + f"frame the scale crosses its whole range in about " + f"{128 // max(1, int(a['median_step']))} frames. It does " + f"not produce hard bands, so the coarse-fade explanation " + f"is out.") + else: + print(f" That is a COARSE fade: jumping by up to " + f"{a['max_step']} per frame produces hard bands rather " + f"than a gradient, which is the reported symptom.") + print(f" The oracle's series is not frame-adjacent, so its " + f"apparent step is an upper bound only and the two step " + f"figures are not comparable.") elif doc.get("native_sampling") == "free-running-fallback": # The claim that must never be made from aliased data: these steps # are an artefact of sampling ~90 frames apart, not the animation diff --git a/tools/tests/test_scale_trace.py b/tools/tests/test_scale_trace.py index 3bef1dcf..1cfff99b 100644 --- a/tools/tests/test_scale_trace.py +++ b/tools/tests/test_scale_trace.py @@ -376,7 +376,10 @@ class TestAliasedDataIsNeverCalledPerFrame(unittest.TestCase): for marker in ('"native-measured-per-frame"', '"native-per-frame-only"'): i = src.index(marker) window = src[max(0, i - 400):i] - self.assertIn("free-running-fallback", window, + # Either spelling of the guard is fine — excluding the fallback, or + # requiring per-frame outright. What matters is that the claim is + # conditioned on HOW the numbers were obtained. + self.assertIn("native_sampling", window, f"{marker} is set without checking native_sampling") def test_a_fallback_run_gets_its_own_granularity_label(self): @@ -404,3 +407,52 @@ class TestSampleSelection(unittest.TestCase): src = inspect.getsource(ST.sample_per_frame) self.assertIn("for cand in ranked:", src) self.assertIn("break", src) + + +class TestSamplingMethodIsAlwaysRecorded(unittest.TestCase): + """The report must say HOW the numbers were obtained, on every path. + + A clean run came back with four psx-runtime samples stepping by 2 and no + native_sampling field at all, because it was only set on the fallback path. + A per-frame series and a free-running one then look identical in the report + — and they support completely different claims. Steps of 2 across + consecutive frames is a smooth fade; steps of 2 across samples ninety + frames apart is nothing. + """ + + def test_a_default_is_set_before_either_path_runs(self): + import inspect + src = inspect.getsource(ST.main) + self.assertIn('doc["native_sampling"] = "free-running"', src) + + def test_success_labels_itself_per_frame(self): + import inspect + src = inspect.getsource(ST.main) + self.assertIn('doc["native_sampling"] = "per-frame"', src) + + def test_the_fallback_still_labels_itself(self): + import inspect + src = inspect.getsource(ST.main) + self.assertIn('"free-running-fallback"', src) + + +class TestConsecutiveFramesNeedNoQuorum(unittest.TestCase): + """Four adjacent frames stepping by 2 IS the step. + + Per-frame samples measure the increment directly rather than sampling a + distribution, so the eight-sample floor that free-running data needs does + not apply — and applying it discarded a measurement that was already + conclusive. + """ + + def test_four_per_frame_samples_are_enough(self): + import inspect + src = inspect.getsource(ST.main) + i = src.index('doc["granularity"] = "native-measured-per-frame"') + # The condition sits above the comment block explaining it. + self.assertIn('a["samples"] >= 4', src[max(0, i - 700):i]) + + def test_free_running_still_needs_more(self): + import inspect + src = inspect.getsource(ST.main) + self.assertIn('a["samples"] >= 8 and b["samples"] >= 8', src)