From 1aa7f175e9df2c9ca041d38c0db5d7337661ae46 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:17:30 +0200 Subject: [PATCH] fix(scan): a portion sizer that never extrapolates from a sample with no work in it Measured on the plugin, first run of the adaptive sizer. A 600-visit portion answered in 184 s and the budget was correctly cut to 262. The next portion, 199 visits, answered in 73 s -- three seconds of work above the 70 s fixed start -- and 3 s over 199 visits read as 0.015 s each, so the budget grew thirteen-fold to 3,404 visits. The ceiling killed that portion. The harvest kept the 13 answers it had streamed, which is the mechanism working; the sizing was not. Two rules. A sample with less than ten seconds of work above the fixed start is not extrapolated from, because it carries no information about cost per visit. And a budget may double per portion, never more: the sizing may still be wrong, but by at most one factor of two, which a kill undoes. 1,423 tests pass, nineteen skipped; the sizer control asserts both rules. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo --- src/openultrasast/model/scan.py | 14 +++++++++++--- tests/test_model_scope.py | 7 +++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/openultrasast/model/scan.py b/src/openultrasast/model/scan.py index 9a0f7bd..4da8bcf 100644 --- a/src/openultrasast/model/scan.py +++ b/src/openultrasast/model/scan.py @@ -922,6 +922,8 @@ def _batched( # a mis-estimate is bounded and a kill costs little. PORTION_TARGET_SECONDS = 120.0 PORTION_FIXED_SECONDS = 70.0 # measured start-and-load on a 4 MB graph +PORTION_MIN_INFORMATIVE_SECONDS = 10.0 # less work than this above the fixed start says nothing about cost per visit +PORTION_MAX_GROWTH = 2.0 # a budget may double per portion, never more class _PortionSizer: @@ -965,9 +967,15 @@ class _PortionSizer: self.budget = max(1.0, self.budget / 2) logger.info("cpg portion of %d sink visit(s) did not answer in %.0fs; budget halved to %d", visits, seconds, int(self.budget)) return - per_visit = max(0.0, seconds - PORTION_FIXED_SECONDS) / visits - if per_visit > 0: - self.budget = max(1.0, (PORTION_TARGET_SECONDS - PORTION_FIXED_SECONDS) / per_visit) + work = seconds - PORTION_FIXED_SECONDS + # A portion that finished inside the fixed start carries no information about cost per visit -- 3 s + # of work over 199 visits read as 0.015 s each and the next budget grew thirteen-fold to 3,404, which + # the ceiling then killed. Measured on the plugin. So a sample below the floor is not extrapolated + # from, and growth is capped at doubling per step: the sizing may be wrong, but never by more than + # one factor of two per portion, which a kill can then undo. + if work >= PORTION_MIN_INFORMATIVE_SECONDS: + fitted = (PORTION_TARGET_SECONDS - PORTION_FIXED_SECONDS) / (work / visits) + self.budget = max(1.0, min(fitted, self.budget * PORTION_MAX_GROWTH)) logger.info("cpg portion of %d sink visit(s) answered in %.0fs; next budget %d visit(s)", visits, seconds, int(self.budget)) diff --git a/tests/test_model_scope.py b/tests/test_model_scope.py index bb3d48a..3f20903 100644 --- a/tests/test_model_scope.py +++ b/tests/test_model_scope.py @@ -338,3 +338,10 @@ def test_a_portion_sizer_shrinks_to_budget_and_learns_from_what_answered() -> No assert sizer.budget == 300.0 sizer.observe({"y": {}, "z": {}}, scan.PORTION_FIXED_SECONDS + 20.0, answered=True) # 0.1 s per visit assert 450 < sizer.budget < 550, sizer.budget + # A portion that finished inside the fixed start says nothing: the budget must not move on it. + before = sizer.budget + sizer.observe({"y": {}}, scan.PORTION_FIXED_SECONDS + 1.0, answered=True) + assert sizer.budget == before + # And a genuinely cheap portion may double the budget, never more: 0.01 s per visit would fit 5,000. + sizer.observe({"w": {}}, scan.PORTION_FIXED_SECONDS + 5.0 + 5.0, answered=True) + assert sizer.budget == before * scan.PORTION_MAX_GROWTH, sizer.budget