diff --git a/AGENTS.md b/AGENTS.md index c0e326a..31e26be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,7 @@ parity is golden rule 3. | `PROPERTY_PHASES` | Always (`false`/`''` = unset) | Comma-separated phase names, case-insensitive: `examples`, `corpus`, `random`, `shrink` | Stages of every run, in run order — **overrides** `phases()` | `InvalidArgumentException` naming the accepted values | | `PROPERTY_DERANDOMIZE` | Always | Any value except `''` and `'0'` enables | Derives every unset seed from the property id — **overrides** `derandomize()` | n/a (falsy values disable) | | `PROPERTY_PATH` | Only when `path()` was not called (explicit path wins) | A recorded `CounterExample::$path` | Replays that shrink descent instead of searching for it; needs the seed of the run that produced it | engine rejects a path that would be a silent no-op | +| `PROPERTY_EDGE_CASES` | Always (`false`/`''` = unset) | `mixin` or `none`, case-insensitive, trimmed | Numeric boundary bias for every run — **overrides** `edgeCases()` | `InvalidArgumentException` naming the accepted values | The split is deliberate and worth stating: **the environment dials the suite, the code pins the property.** `PROPERTY_RUNS`, `PROPERTY_PHASES` and diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f2a6a7..cf3521a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- Added `PropertyCheck::edgeCases()` and `PROPERTY_EDGE_CASES` (`mixin` or + `none`), reaching core 0.3's switch for the numeric boundary bias. Turning it + off stops a property that cannot use `0`, `±1` or a range's ends from + spending one run in five on a value it discards. The variable overrides the + chain, like every other CI-facing knob, and an unknown value throws rather + than silently keeping the bias it was told to drop. +- **Requires `rasuvaeff/property-testing-core` `^0.3`.** + ## 0.2.0 — 2026-08-14 - Added the 0.2 run knobs, as fluent setters and as environment variables: diff --git a/README.md b/README.md index b276024..e545d74 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ Reproduce the exact run by pinning the reported seed: `->seed(7382910)`. | `phases(array)` | Stages to perform (`Phase::Examples`, `Corpus`, `Random`, `Shrink`) — a subset trades coverage for time on purpose | | `derandomize(bool)` | Derives an unset seed from the property id instead of drawing one; an explicit `seed()` still wins | | `path(string)` | Replays a recorded shrink descent instead of searching for it; needs the seed that produced it | +| `edgeCases(EdgeCases)` | `None` turns off the numeric boundary bias — for a property the edges only cost runs | | `output($stdout, $stderr)` | Redirects the distribution report, discard warning and verbose trace (used by this package's own tests) | ### Naming a property (`id()`) @@ -162,6 +163,7 @@ Byte-for-byte parity with the Testo adapter — one contract across adapters: | `PROPERTY_PHASES` | Comma-separated stage list (`examples,corpus,random,shrink`, case-insensitive) that overrides `phases()` — an unknown name throws rather than skipping a stage. `examples,corpus` is the fast pull-request gate | | `PROPERTY_DERANDOMIZE` | Any value except `''`/`'0'` derives every unset seed from the property id, making a whole suite reproducible without editing it | | `PROPERTY_PATH` | A recorded shrink descent (`CounterExample::$path`) replayed instead of searched for. Needs the seed that produced it; an explicit `path()` wins | +| `PROPERTY_EDGE_CASES` | `mixin` or `none` (case-insensitive) — the numeric boundary bias for the whole suite, overriding `edgeCases()`. An unknown value throws | The corpus format is exactly the one `rasuvaeff/property-testing` 2.8 wrote — a corpus recorded under Testo (or under 2.x) replays here and vice versa. On diff --git a/README.ru.md b/README.ru.md index 20d8902..1f6048b 100644 --- a/README.ru.md +++ b/README.ru.md @@ -110,6 +110,7 @@ Property falsified after 12 successful run(s); seed=7382910 | `phases(array)` | Какие стадии выполнять (`Phase::Examples`, `Corpus`, `Random`, `Shrink`); подмножество осознанно обменивает покрытие на время прогона | | `derandomize(bool)` | Выводит незаданный seed из id property вместо случайного; явный `seed()` всё равно побеждает | | `path(string)` | Воспроизводит записанный спуск shrink вместо повторного поиска; нужен seed того прогона | +| `edgeCases(EdgeCases)` | `None` выключает граничное смещение числовых генераторов — для property, которой края стоят только прогонов | | `output($stdout, $stderr)` | Перенаправляет отчёт распределения, предупреждение о discard'ах и verbose-трассу (используется тестами самого пакета) | ### Имя property (`id()`) @@ -162,6 +163,7 @@ $this->forAll(['values' => Gen::arrayOf(Gen::int())]) | `PROPERTY_PHASES` | Список стадий через запятую (`examples,corpus,random,shrink`, регистр не важен), перекрывающий `phases()`; неизвестное имя — исключение, а не пропуск стадии. `examples,corpus` — быстрый гейт для pull request | | `PROPERTY_DERANDOMIZE` | Любое значение, кроме `''`/`'0'`, выводит каждый незаданный seed из id property: весь сьют становится воспроизводимым без правки кода | | `PROPERTY_PATH` | Записанный спуск shrink (`CounterExample::$path`) воспроизводится вместо повторного поиска. Нужен seed того прогона; явный `path()` побеждает | +| `PROPERTY_EDGE_CASES` | `mixin` или `none` (регистр не важен) — граничное смещение для всего сьюта, перекрывает `edgeCases()`. Неизвестное значение — исключение | Формат корпуса — ровно тот, что писал `rasuvaeff/property-testing` 2.8: корпус, записанный под Testo (или под 2.x), реплеится здесь, и наоборот. При diff --git a/composer.json b/composer.json index 42d15d7..6fcd58e 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "require": { "php": "8.3 - 8.5", "phpunit/phpunit": "^11.5 || ^12.0 || ^13.0", - "rasuvaeff/property-testing-core": "^0.2" + "rasuvaeff/property-testing-core": "^0.3" }, "require-dev": { "ergebnis/composer-normalize": "^2.51", diff --git a/infection.json5 b/infection.json5 index 24f7135..a8738ea 100644 --- a/infection.json5 +++ b/infection.json5 @@ -7,7 +7,7 @@ "text": "php://stderr", "summary": "build/infection-summary.log" }, - // 146 of 154 mutants are killed; the remaining eight are equivalent + // 160 of 168 mutants are killed; the remaining eight are equivalent // (report wording and ordering that no observable behaviour distinguishes), // so 100 is unreachable without ignores — which are forbidden here. "minMsi": 90, diff --git a/llms.txt b/llms.txt index d249ce4..d31f152 100644 --- a/llms.txt +++ b/llms.txt @@ -64,6 +64,7 @@ public function shrinkBudgetMs(int $ms): self; // wall-clock budget for th public function phases(array $phases): self; // list: Examples, Corpus, Random, Shrink public function derandomize(bool $on = true): self; // unset seed derived from the property id public function path(string $path): self; // replay a recorded shrink descent; needs its seed +public function edgeCases(EdgeCases $mode): self; // Mixin (default) | None = no numeric boundary bias public function seed(int $seed): self; // pin the random phase; disables corpus replay public function maxShrinks(int $maxShrinks): self; // cap accepted shrink steps; 0 = no shrinking public function maxDiscards(int $maxDiscards): self; // discard budget; default runs * 10 @@ -112,6 +113,7 @@ final class SortPropertyTest extends TestCase | `PROPERTY_PHASES` | `examples,corpus,random,shrink` (comma-separated, case-insensitive) | Stages to run; OVERRIDES `phases()`; unknown name throws | | `PROPERTY_DERANDOMIZE` | anything but `''`/`'0'` | Unset seeds derived from the property id; `seed()`/`PROPERTY_SEED` still win | | `PROPERTY_PATH` | recorded `CounterExample::$path` | Replays that shrink descent; needs the seed of that run; explicit `path()` wins | +| `PROPERTY_EDGE_CASES` | `mixin` \| `none` (case-insensitive) | Numeric boundary bias; OVERRIDES `edgeCases()`; unknown value throws | ### Classification output diff --git a/src/PhpUnit/PropertyCheck.php b/src/PhpUnit/PropertyCheck.php index 3a81a19..647e9d2 100644 --- a/src/PhpUnit/PropertyCheck.php +++ b/src/PhpUnit/PropertyCheck.php @@ -11,6 +11,7 @@ use Rasuvaeff\PropertyTesting\PropertyListener; use Rasuvaeff\PropertyTesting\Runner\CallableTrialExecutor; use Rasuvaeff\PropertyTesting\Runner\CoverageFailed; +use Rasuvaeff\PropertyTesting\Runner\EdgeCases; use Rasuvaeff\PropertyTesting\Runner\FilesystemCorpus; use Rasuvaeff\PropertyTesting\Runner\GaveUp; use Rasuvaeff\PropertyTesting\Runner\Passed; @@ -70,6 +71,7 @@ final class PropertyCheck private ?ShrinkMode $shrink = null; private ?int $shrinkBudgetMs = null; private ?string $path = null; + private ?EdgeCases $edgeCases = null; private ?bool $derandomize = null; /** @var ?list */ @@ -234,6 +236,22 @@ public function derandomize(bool $derandomize = true): self return $this; } + /** + * Whether the numeric generators keep biasing toward their boundary values + * ({@see EdgeCases::Mixin}, the default) or generate uniformly + * ({@see EdgeCases::None}). + * + * Turn them off when the edges are what this property cannot use — a body + * discarding `0`, a range end that violates a precondition — so the + * discard budget stops paying for one run in five. + */ + public function edgeCases(EdgeCases $edgeCases): self + { + $this->edgeCases = $edgeCases; + + return $this; + } + /** * Replays the shrink descent of an earlier failure, as reported by * `CounterExample::$path`, instead of searching for it again. It needs the @@ -318,6 +336,7 @@ public function check(\Closure $property): void phases: $this->envPhases() ?? $this->phases, derandomize: $this->envDerandomize() ?? $this->derandomize ?? false, path: $this->path ?? $this->envPath(), + edgeCases: $this->envEdgeCases() ?? $this->edgeCases ?? EdgeCases::Mixin, ), examples: $this->examples, replayRegressions: $this->seed === null, @@ -463,6 +482,31 @@ private function envPhases(): ?array return $phases; } + /** + * `PROPERTY_EDGE_CASES` selects the numeric boundary bias for the whole + * suite: `mixin` (the default) or `none`, case-insensitive. It overrides + * the chain, like every other CI-facing knob, and an unknown value is an + * error rather than a silent fallback — a suite that quietly kept the bias + * it was told to drop would spend the discard budget it was trying to save. + */ + private function envEdgeCases(): ?EdgeCases + { + $env = getenv('PROPERTY_EDGE_CASES'); + + if ($env === false || $env === '') { + return null; + } + + return match (strtolower(trim($env))) { + 'mixin' => EdgeCases::Mixin, + 'none' => EdgeCases::None, + default => throw new \InvalidArgumentException(sprintf( + 'PROPERTY_EDGE_CASES must be one of mixin, none, got "%s"', + trim($env), + )), + }; + } + /** * `PROPERTY_DERANDOMIZE` (any value except '' and '0') derives every * unset seed from the property id, which makes a whole suite reproducible diff --git a/tests/EnvironmentParityTest.php b/tests/EnvironmentParityTest.php index d98bdbd..6b654f7 100644 --- a/tests/EnvironmentParityTest.php +++ b/tests/EnvironmentParityTest.php @@ -18,6 +18,7 @@ use Rasuvaeff\PropertyTesting\PhpUnit\Tests\Support\RecordingListener; use Rasuvaeff\PropertyTesting\PropertyViolationException; use Rasuvaeff\PropertyTesting\RegressionViolationException; +use Rasuvaeff\PropertyTesting\Runner\EdgeCases; use Rasuvaeff\PropertyTesting\Runner\Phase; /** @@ -473,6 +474,84 @@ public function testANamedPropertyWarnsAboutNothing(): void self::assertSame('', (string) stream_get_contents($stderr)); } + public function testEdgeCasesOffKeepsBoundaryValuesOutOfTheRun(): void + { + // The knob's whole purpose: a property that cannot use the edges would + // otherwise throw away one run in five. + self::assertSame(0, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check->edgeCases(EdgeCases::None))); + } + + public function testByDefaultTheBoundaryValuesAreStillGenerated(): void + { + self::assertGreaterThan(10, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check)); + } + + public function testPropertyEdgeCasesOverridesTheChain(): void + { + putenv('PROPERTY_EDGE_CASES=none'); + + self::assertSame(0, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check->edgeCases(EdgeCases::Mixin))); + } + + public function testPropertyEdgeCasesIgnoresSpacingAndCase(): void + { + putenv('PROPERTY_EDGE_CASES= NONE '); + + self::assertSame(0, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check)); + } + + public function testPropertyEdgeCasesCanTurnThemBackOn(): void + { + putenv('PROPERTY_EDGE_CASES=mixin'); + + self::assertGreaterThan(10, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check->edgeCases(EdgeCases::None))); + } + + public function testPropertyEdgeCasesRejectsAnUnknownValue(): void + { + // Spaces on purpose: the message quotes the trimmed value, so a + // reader sees what was compared rather than what was typed. + putenv('PROPERTY_EDGE_CASES= sometimes '); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PROPERTY_EDGE_CASES must be one of mixin, none, got "sometimes"'); + + $this->forAll(['value' => Gen::intBetween(0, 10)]) + ->check(static function (int $value): void { + self::assertGreaterThanOrEqual(0, $value); + }); + } + + /** + * How many runs generated an in-range boundary value. + * + * @param \Closure(PropertyCheck): PropertyCheck $configure + */ + private function edgesSeen(\Closure $configure): int + { + $listener = new RecordingListener(); + + $configure( + $this->forAll(['value' => Gen::intBetween(-1_000_000, 1_000_000)]) + ->id('parity::edge-cases') + ->runs(200) + ->seed(5) + ->listeners($listener), + )->check(static function (int $value): void { + self::assertIsInt($value); + }); + + $edges = 0; + + foreach ($listener->events as $event) { + if ($event instanceof RunStarted && in_array($event->arguments['value'] ?? null, [0, 1, -1, -1_000_000, 1_000_000], true)) { + ++$edges; + } + } + + return $edges; + } + /** * The seed the engine actually ran with, as the PropertyStarted event * reports it — the one observable that says what a seed knob decided. diff --git a/tests/Support/Env.php b/tests/Support/Env.php index 7955155..4fe89c2 100644 --- a/tests/Support/Env.php +++ b/tests/Support/Env.php @@ -72,6 +72,7 @@ public static function isolateProperty(): \Closure 'PROPERTY_PHASES' => null, 'PROPERTY_DERANDOMIZE' => null, 'PROPERTY_PATH' => null, + 'PROPERTY_EDGE_CASES' => null, ]); } }