Repository navigation
feat(backend): CPU/GPU acceleration drivers for matrix arithmetic - #19
Merged
Merged
Conversation
The acceleration backends that follow compute in double precision only. asFloat() makes that widening available as ordinary, catchable userland code instead of an implicit side effect of picking a driver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
…ds registry The arithmetic of Matrix moves, unchanged down to its iteration order, into an interchangeable driver: PhpBackend. Matrix keeps the validation, the dimension guards and the hooks, and asks the registry which driver should carry out an operation. The registry validates a selection eagerly in userland — an unusable driver discovered inside an FFI callback would be a fatal error, not an exception — and defaults to routing that keeps every all-integer operation on pure PHP. Nothing observable changes yet: only one driver is registered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Selection failures are asserted with try/catch on purpose: they are raised in userland, unlike the dimension mismatches raised inside the hooks, which can only be matched inside fatal-error output. The third-party driver test follows a registered stub all the way from the "+" operator, and the auto-routing test pins the rule that all-integer arithmetic stays on pure PHP even where acceleration is installed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Matrix multiplication becomes a cblas_dgemm call, addition and subtraction a cblas_daxpy over the flattened cells, and the scalar operations a cblas_dscal. The library is declared inline and loaded lazily from a list of LP64 sonames; the ILP64 build is deliberately never probed, its wider integers would corrupt every dimension argument. The driver proves itself with a real 1x1 multiplication before reporting that it is available, because a missing symbol discovered inside an operator hook would be a fatal error rather than an exception. Automatic routing picks it for float operands only, wrapped so that a failure at operation time recomputes in pure PHP. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Every operator gets a file pinned to the blas driver through --ENV--, including the integer input that comes back as floats — the visible half of the float-only rule. The parity test computes the same 8x8 product on both drivers and compares them exactly: integral float cells keep every partial sum representable, so a difference in summation order may not become a difference in the result. The shared SKIPIF probe asks the registry for availability instead of guessing, so a skip can never disagree with what the test would have done. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
CLBlast is a BLAS written in OpenCL, which makes it the portable way to reach a GPU from PHP: the same driver runs on NVIDIA, AMD and Intel hardware, on the integrated GPU of a laptop, and — through a runtime such as PoCL — on the CPU, which is how the GPU code path can be exercised without a GPU at all. NATIVE_PHP_MATRIX_CL_DEVICE picks the device type, defaulting to the GPU. Both APIs come from a single FFI::cdef against CLBlast, whose own link to the OpenCL library resolves the cl* symbols. Device memory is not reference counted by PHP, so every buffer is released in a finally block; host buffers are left to PHP. Like the CPU driver, this one proves itself with a real multiplication before reporting that it is available, and it is never selected automatically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
The GPU driver gets the same treatment as the CPU one, including the parity test that compares an 8x8 product computed on the device against the interpreted result. Its SKIPIF probe initialises OpenCL for exactly the device type NATIVE_PHP_MATRIX_CL_DEVICE selects, so a skip always means what it says. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
The suite fails on skipped tests, so the libraries are installed rather than the tests being allowed to skip: a skip in CI now means a broken image. PoCL supplies an OpenCL device on the CPU, which lets a runner without a GPU still execute the GPU code path — that is what the new gpu-path job does, with everything pinned to the clblast driver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
The script times the whole PHP-level operation — packing, the kernel call, reading the result back and validating it into a new Matrix — because that is what a caller actually pays, and it is what decides where the crossover sits. A warm-up run is discarded so that the kernels CLBlast compiles for the device are not charged to the first measurement, and the median of several runs is reported. --markdown prints a table ready to paste into the README, with the machine and the exact command above it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
The README gains the driver table, the selection API, the install lines and the two rules a caller has to know — accelerated drivers are float-only, and auto keeps integers on pure PHP — followed by an AI/ML section built around the operation inference actually spends its time in. The benchmark table is measured, not claimed, and it is honest in both directions: matrix multiplication reaches x132 at 1024x1024, while element-wise operations are slower through a driver than through the interpreter, because there is no O(n^3) work to amortise the marshalling. Both tables are in the README, with the hardware and the command that produced them. CLAUDE.md gains the backend architecture map, its three non-negotiable rules, the backend test conventions and the policy that system libraries are optional runtime dependencies rather than Composer requirements. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
lisachenko
commented
Aug 13, 2026
lisachenko
commented
Aug 13, 2026
lisachenko
commented
Aug 14, 2026
lisachenko
commented
Aug 14, 2026
lisachenko
commented
Aug 14, 2026
lisachenko
commented
Aug 14, 2026
The import lists had grown to sixty "use function" and "use const" lines across six files, long enough in benchmark.php and Matrix.php to push the actual class imports off the screen. PHP resolves an unqualified global function through the same fallback whether or not it was imported, so the lines bought nothing but vertical space. Every one of them is dropped and the call sites left unqualified. Only classes, interfaces, traits and enums keep a "use" statement. The php-cs-fixer config neither adds nor removes these imports, so the convention survives "composer cs:fix" instead of being undone by it, and CLAUDE.md now states it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
A Matrix no longer holds a PHP array of rows. It owns a single contiguous, row-major "double[rows * columns]" allocation, which is the shape a BLAS kernel, an OpenCL buffer and an interpreted loop can all read without a conversion in between. The constructor validates and converts in one pass, writing each cell straight into the buffer where FFI performs the int-to-double conversion natively, so there is no intermediate array and no second loop. That removes the userland cast loop entirely: asFloat() is gone, and so is the containsFloats flag it existed to compensate for. Cells are float64, period. Integers are still accepted as input and stored as the doubles they convert to, the way numpy.array([[1, 2]]) yields dtype=float64. The driver contract changes with it. Operations take the operand buffers and return a freshly allocated one, so nothing is packed on the way in or unpacked on the way out: - BlasBackend calls cblas_dgemm/daxpy/dscal directly on the stored buffers. The only copy left is the one the accumulating kernels force, a single memcpy, instead of a cell-by-cell conversion of both operands and of the result. - PhpBackend runs the same loops over buffer offsets, keeping its accumulation order so that it and the accelerated drivers still agree bit for bit. - ClblastBackend uploads straight from the stored buffer and reads back into the buffer that becomes the result. - AcceleratedBackendTrait shrinks to the one operation no BLAS provides. Two consequences follow. Automatic routing no longer needs to know the operand types: with marshalling gone there is no operation that is cheaper interpreted, so "auto" uses OpenBLAS for every operation when it is available and pure PHP otherwise, and a GPU is still never chosen for you. And equals() compares the two buffers with a single memcmp, which is bit-exact and documented as such. The driver names become a string-backed Driver enum instead of loose class constants; the registry still accepts plain strings, because a third-party driver registered through register() cannot be a case of it. Every test now expects floats, which is what finally lets the whole suite pass under every pinned backend rather than only under the default routing. BREAKING CHANGE: matrix cells are float64 only. Integer input is accepted but stored and returned as float, so "new Matrix([[1, 2]]) + new Matrix([[3, 4]])" now yields [[4.0, 6.0]] instead of [[4, 6]]. Matrix::asFloat() is removed, it no longer means anything. The Matrix<T> generic is removed for the same reason. BackendInterface operations take and return FFI\CData buffers instead of arrays, so out-of-tree drivers must be updated. Backends::resolveFor(bool) is replaced by Backends::resolve(), and the Backends::PHP/BLAS/CLBLAST/AUTO constants by the Driver enum, which Backends::active() now returns for built-in drivers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
The gpu-path job hardcoded php-version 8.4 while every other job builds its PHP
version from a matrix, so the device code path was only ever exercised on one of
the two supported minors. It now carries the same strategy.matrix.php as the
tests job and reads ${{ matrix.php }}, with the minor in the job name.
It also stops naming individual test files. That workaround existed because
pinning an accelerated backend process-wide turned integer results into floats
and broke every test asserting integers; with cells stored as float64 there are
no integer expectations left, so the entire suite passes pinned to clblast and
the job simply runs "composer test" on the device.
Dependabot already tracks the github-actions ecosystem weekly alongside the
daily composer updates, so the action pins in this workflow keep updating on
their own and no change is needed there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
The README still described the design this branch replaced: plain arrays handed to drivers, an integer-preserving pure-PHP driver, a float-only asterisk on the accelerated ones, and a crossover section explaining why element-wise operations were slower accelerated than interpreted. None of that is true any more. It now documents the storage — one contiguous double[rows * columns] per matrix, integers accepted as input and stored as float64 the way numpy reports dtype=float64 — and states the single remaining routing rule, that auto uses OpenBLAS for every operation when it is loadable and never picks a GPU for you. The crossover section is replaced by an explanation of why the crossover is gone. Both benchmark tables are re-measured on the new storage. Element-wise addition at 512x512 went from 25.27 ms to 2.15 ms on blas, turning a x0.3 penalty into a x6.1 win, and multiplication at 1024x1024 from 113.61 ms to 22.67 ms now that the kernel is nearly all that is being timed. The pure-PHP column got slower in the same measurements, because walking FFI\CData costs more than walking a PHP array, and the text says so rather than quoting only the flattering half. CLAUDE.md follows: the generics section becomes the float64 storage rule, the backend rules gain the read-only-operands contract, and the test conventions now require float expectations everywhere and a suite that stays green under every pinned backend. The code-reviewer agent carried the old generics rule as a blocking check and is updated with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
lisachenko
marked this pull request as ready for review
October 19, 2026 21:07
Reconciles the branch with the two changes master gained since it forked: - stable z-engine: composer.json takes master's "~8.4.2 || ~8.5.0" requirement without the root stability flags, keeping the branch's suggest entries and bench script; CLAUDE.md drops the "tighten once z-engine ships stable releases" bullet that requirement fulfils. - two-line --INI--: every .phpt carries only ffi.enable and opcache.jit, including the 18 backend tests the branch added; CLAUDE.md combines master's two-line text with the branch's backend sections, and its anatomy example now asserts float cells like the test it quotes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Calling FFI::new() statically has been deprecated since PHP 8.3. The suite's error_reporting suppression used to hide it; with that line gone from every .phpt, the deprecation was prepended to the output of 30 tests. Float64Buffer now allocates through a lazily created, empty FFI::cdef() scope, and the PHPStan ignore for the static call is removed with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
…g the driver Walking FFI\CData cell by cell made the pure-PHP driver two to three times slower than the array loops it replaced. A matrix now stores its cells in the form of the driver active when it is built: nested PHP float arrays under the php driver, which computes with the original array loops again, and native float64 buffers under blas and clblast, which keep their zero-marshalling kernels. - BackendInterface is the common root; a driver declares its storage by implementing ArrayBackendInterface or BufferBackendInterface, and Backends::use() rejects one that implements neither. resolve() returns the storage-typed driver and caches it until the selection changes. - An operand built under another driver is converted once for that operation, in bulk (pack/memcpy, FFI::string/unpack); the result takes the computing driver's form. FallbackBackend recomputes a failed buffer operation on the array-native php driver the same way. - equals() stays bit-exact in every combination: memcmp for buffers, packed doubles for rows, a buffer conversion for mixed operands. - Cells remain float64 everywhere: the constructor casts while it validates, and the multiplication accumulator starts at 0.0. The debug-output test pins the php driver, since what the property table shows now depends on whether OpenBLAS is installed. New tests cover storage following the driver, operations and comparisons across a driver switch, bit-exact comparison, the fallback recomputation and the rejection of a storage-less driver. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
README explains the two storage forms — native buffers for the accelerated drivers, PHP float arrays for the interpreter — and replaces the "the fallback got slower, deliberately" note with the measured result of giving the php driver its arrays back: on the same container, 512x512 multiplication 4,146 ms -> 1,849 ms, addition 9.46 -> 5.12 ms, scaling 6.76 -> 3.43 ms. Both benchmark tables are re-measured. CLAUDE.md gains the storage contracts in the backend map, the storage-follows-driver float64 rule and the test convention that a fixture exposing storage pins its backend; the reviewer checklist and the benchmark's own docblock follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
The agent template and rules still taught int(...) expectations and PHP's int/float division rules for matrix cells, which contradicts the float64 storage: every cell dumps as float(...) whichever driver computed it. The template expectation, the pre-writing checklist and the "types matter" rule now state the float64 reality, and a new rule covers pinning a backend when a test's output exposes the storage form. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Require lisachenko/z-engine as "~8.4.2 || ~8.5.0 || 8.6.x-dev". z-engine's master is its 8.6 line (php ~8.6.0, branch-aliased 8.6.x-dev), so PHP 8.6 is added together with the z-engine line built for it, and Composer still resolves exactly one line per running minor. The explicit dev constraint sets a root stability flag for the whole package, which would also admit the 8.4/8.5 development branches and, on PHP 8.5, resolve 8.5.x-dev over the 8.5.0 tag. "prefer-stable": true keeps 8.4 and 8.5 on their stable tags; minimum-stability stays "stable". Document the third minor, the "tighten to ~8.6.0 when tagged" rule, and that consumers on PHP 8.6 must require z-engine 8.6.x-dev at their own root, because stability flags do not propagate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
Add 8.6 to the tests, gpu-path and static-analysis matrices. The legs are set up the way z-engine's own CI sets up its 8.6 line (plain php-version '8.6', same ini values) and, as there, they are required rather than allowed to fail. coding-standards stays pinned to 8.4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Delivers the roadmap's "FFI BLAS backend" item as an interchangeable driver architecture: the operators stay exactly where they are, the arithmetic behind them becomes pluggable.
$logits = $input * $weights + $biasnow runs on OpenBLAS or a GPU while remaining ordinary PHP.Following review, the storage model was rewritten twice. First a
Matrixbecame a native float64 buffer, which removed the marshalling the accelerated drivers paid on every operation — but made the pure-PHP driver walkFFI\CData, two to three times slower than the array loops it replaced. So now storage follows the driver: native buffers underblas/clblast, PHP float arrays underphp, each driver computing on the form it reads fastest. The suite passes under every backend.Matrix cells are float64 only. Integers are still accepted as input and stored as the doubles they convert to —
numpy.array([[1, 2]])reportingdtype=float64is the same bargain. Whether those doubles sit in adouble[rows * columns]buffer or in a list of rows of PHP floats depends on the driver active when the matrix is built; the values are identical either way.Matrix::asFloat()@template-covariant T of int|float,Matrix<int>BackendInterfacewith six array operationsBackendInterfaceis the root (isAvailable()); a driver implementsBufferBackendInterface(FFI\CDatain, fresh buffer out) orArrayBackendInterface(float rows in, float rows out)Backends::resolveFor(bool)Backends::resolve()— returns the storage-typed driver, cached until the selection changesBackends::PHP/BLAS/CLBLAST/AUTOconstantsDriverstring-backed enum;Backends::active()returns it for built-insOut-of-tree drivers declare their storage by picking one of the two contracts;
Backends::use()rejects a driver that implements neither. Two obligations come with either: operands are read-only (a buffer kernel that accumulates into an argument, likedaxpy, copies it first) and the result is freshly computed — that is also what makes the automatic fallback safe to re-run.Architecture
Matrixkeeps the validation, the dimension guards and the engine hooks; it asks a registry which driver should compute and hands it the operands in the storage that driver declares. Drivers never see aMatrix.phpforeachloops, verbatim, same summation orderblascblas_dgemm/cblas_daxpy/cblas_dscalcalled directly on the stored buffersclblastNATIVE_PHP_MATRIX_BACKEND=php|blas|clblast|autopins one process-wide,NATIVE_PHP_MATRIX_CL_DEVICE=gpu|cpu|allpicks the OpenCL device type.Semantics decided here
(float)(rows), in the same pass that validates it. No cast loop anywhere else; the array multiplication's accumulator starts at0.0.foreach. A mismatched operand is converted once, in bulk (pack/FFI::memcpy,FFI::string/unpack— no PHP loop over cells), and the result takes the computing driver's form.Fatal error: Throwing from FFI callbacks is not allowed. Selection is validated eagerly, in userland; availability is reported from a non-throwingisAvailable(); underautoa driver that fails mid-operation is caught and recomputed on the array-native PHP driver.autois boring on purpose. OpenBLAS (on buffers) whenever it is loadable, for every operation, pure PHP (on arrays) otherwise, and never a GPU on its own.equals()is bit-exact in every form.memcmpfor two buffers, packed doubles for two arrays, a buffer conversion for mixed operands — all documented as stricter than==for-0.0andNAN.cl_memis not reference counted by PHP: every buffer is freed in afinally.Tests
The suite is one behaviour per
.phpt, all with master's two-line--INI--(ffi.enable=1,opcache.jit=off). Every cell expectation is a float, which is what lets the whole suite pass under every pinned backend — default,php,blasandclblast. Tests that existed solely to describe integer semantics (testAutoKeepsIntMatricesOnPhpBackend, theasFloat()test) are removed;testMatrixStoresFloat64replaces them.Storage-following-driver is covered by
testStorageFollowsDriver,testCanOperateAcrossDriverSwitch(operations and==on matrices built under the other driver),testComparesCellsBitExactly,testAutoRecomputesInPhpWhenAcceleratedDriverFails(a failing buffer driver underauto, recomputed on arrays) andtestFailsOnSelectingBackendWithoutStorage.testKeepsDefaultDebugOutputnow pinsNATIVE_PHP_MATRIX_BACKEND=php, because what the property table shows depends on the driver — a test whose output exposes storage must not depend on whether OpenBLAS is installed.Determinism is by construction: integral-valued float fixtures with small dimensions keep every partial sum exactly representable, which is what lets
testBlasMatchesPhpBackendResultsandtestClblastMatchesPhpBackendResultscompare an 8×8 product exactly against the interpreted one despite a different summation order. Divisions use power-of-two divisors, because BLAS scales by the reciprocal.CI
tests(8.4, 8.5, 8.6): installs the acceleration libraries, runs the suite under default routing and underNATIVE_PHP_MATRIX_BACKEND=php.gpu-path(8.4, 8.5, 8.6): runscomposer testin full on PoCL's CPU device withNATIVE_PHP_MATRIX_BACKEND=clblast.static-analysisnow also runs on 8.6;coding-standardsstays on 8.4. The 8.6 legs are set up exactly as z-engine's own CI sets up its 8.6 line (plainphp-version: '8.6', same ini values) and are required, as they are there; PHPStan stays at level max with no baseline, and every ignore is a genuine FFI modelling gap scoped to a path with an identifier and a comment — the pure-PHP driver no longer needs any, it computes on typed arrays.Benchmarks
composer benchtimes the whole PHP-level operation, from the operator to a finishedMatrix, with the operands built under the driver being measured.phpblasclblastblasspeed-upclblastspeed-up$a * $b$a * $b$a * $b$a * $b$a * $bElement-wise operations:
phpblasclblastblasspeed-upclblastspeed-up$a + $b$a + $b$a * 2.5$a * 2.5The
phpcolumn is the original array loops again, so every speed-up above is measured against the interpreter at its best. Against the previous buffer-only storage, where the interpreter had to walkFFI\CData— same container, same--backends=php --sizes=64,128,256,512invocation:phpdriverFFI\CData)The accelerated drivers lose nothing for it: their matrices are still buffers, handed to the kernels without a conversion.
PHP 8.5.11, Linux x86_64, Intel® Xeon® @ 2.10 GHz (shared cloud container, no GPU), OpenBLAS 0.3.26, CLBlast 1.6.2 on PoCL 5.0 with
NATIVE_PHP_MATRIX_CL_DEVICE=cpu. Median of 5, one warm-up discarded. Theclblastcolumn is a portability proof, not a GPU benchmark.Docs
README documents the two storage forms, the
Driverenum, the two driver contracts and the single remaining routing rule, with the tables above. CLAUDE.md's backend map lists the storage contracts, the float64 rule becomes "everything is float64, and storage follows the driver", and the test conventions require float expectations, a suite that stays green under every pinned backend, and a pinned backend for any test whose output exposes storage.Merged with master
Master's stable z-engine requirement (
~8.4.2 || ~8.5.0, no root stability flags) and its two-line--INI--convention are merged in; every test this PR adds follows it. Dropping theE_DEPRECATEDsuppression surfaced a real deprecation in this PR's code — staticFFI::new()— fixed by allocating from anFFI::cdef()scope rather than restoring the suppression.PHP 8.6 (z-engine 8.6.x-dev)
z-engine's
masteris its 8.6 line ("php": "~8.6.0", branch-aliased8.6.x-dev), so PHP 8.6 is added together with the z-engine line built for it: the requirement is now~8.4.2 || ~8.5.0 || 8.6.x-dev, still exactly one line per running minor, and theCore::init()guard is untouched.masteron 8.6 withoutminimum-stability: dev. That flag covers the whole package, though, and would otherwise let PHP 8.5 resolve the8.5.x-devbranch over the8.5.0tag — the root now carries"prefer-stable": true, which keeps 8.4 and 8.5 on their stable tags.lisachenko/z-engine:8.6.x-devat its own root (README Requirements/Installation say so). 8.4/8.5 consumers need nothing extra.8.6.x-devbecomes~8.6.0andprefer-stablegoes, in a single change (CLAUDE.md Dependency policy).Verification
=php,=blas,=clblaston PoCL — 42 tests each.dev-master; 8.4 and 8.5 still lock the8.4.2/8.5.0tags), PHPStan level max (no baseline) on all three, coding standards;composer cs:fixidempotent.🤖 Generated with Claude Code
https://claude.ai/code/session_017HAT4khAiANxtSUrqFSeQE