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