Boundary bias
Равномерная случайная выборка тратит большинство прогонов на неинтересную середину диапазона. Баги кластеризуются на краях — 0, ±1, точные границы — поэтому числовые генераторы намеренно смещены к ним, а не сэмплируют равномерно.
Как это работает
Gen::int(), Gen::intBetween(), Gen::float() и Gen::floatBetween() на каждом draw кидают шанс 1 из 5 (BIAS_DENOMINATOR = 5 у IntArbitrary/FloatArbitrary). При срабатывании вместо равномерного значения возвращается один из in-range boundary-кандидатов, вычисленных внутренним хелпером Boundary:
- Целые: различные значения среди
0, 1, -1, $min, $max, попадающие в[$min, $max].$minи$maxвсегда в диапазоне по построению, поэтому список кандидатов никогда не пуст — диапазон вродеintBetween(10, 20)всё равно смещается к своим собственным10и20, даже если0/1/-1все вне диапазона. - Float: различные значения среди
0.0, $min, попадающие в[$min, $max).FloatArbitraryгенерирует полуоткрытый диапазон, поэтому сам$maxнамеренно исключён и из списка границ, и из обычной генерации — он никогда не может быть выдан как значение. Вырожденный диапазон, где ни0.0, ни$minне подходят (пустой список границ), просто откатывается к равномерной выборке для этого draw.
Остальные 4 draw'а из 5 сэмплируют равномерно по всему диапазону, так что генератор всё ещё исследует внутреннюю часть — bias смещает вероятности, а не заменяет покрытие.
Shrinking не затронут
Boundary bias меняет только то, что возвращает генерация; он ничего не говорит о том, как значение шринкается. Оба arbitrary шринкают к одной и той же цели независимо от того, как значение было получено — IntArbitrary делением пополам расстояния до max($min, min($max, 0)), FloatArbitrary единственным кандидатом max($min, min($max, 0.0)). Boundary-biased draw и равномерно сэмплированный draw с тем же значением дают идентичные shrink-деревья.
Почему это важно при проектировании property
- Не пишите
Gen::filter($ints, fn($n) => $n !== 0), рассчитывая, что фильтр сработает редко — с шансом boundary-bias 1 из 5 попасть в0(когда он в диапазоне), плюс0как частая цель обычного shrinking, такой фильтр отбрасывает значительно чаще, чем предполагала бы чисто равномерная модель. ПредпочитайтеGen::intBetween(1, $max)(сконструировать домен) вместо фильтрации нуля из него. - Property, которое «обычно проходит, но иногда падает на границах» — ровно то, для быстрого выявления чего создан этот bias: не нужен
runs: 10000, чтобы попасть вmin/max/0; уже несколько сотен прогонов сэмплируют их многократно. Gen::floatSpecial()— отдельный opt-in механизм дляNAN/±INF/-0.0; дефолтный biasfloat()/floatBetween()намеренно остаётся полностью в диапазоне и конечным. Полный каталог — на странице обзора генераторов.