This commit is contained in:
ywnsya 2026-05-01 23:40:14 +08:00
commit b8f599a617
3867 changed files with 478663 additions and 0 deletions

0
.codex Normal file
View File

9
.env Normal file
View File

@ -0,0 +1,9 @@
LLM_API_BASE_URL="https://open.bigmodel.cn/api/paas/v4"
LLM_API_KEY="469ff60878bb4d5b9b76577635118c1e.zsHPoKkDv5XEzpkl"
LLM_CHAT_MODEL="glm-4.7-flash"
LLM_CHAT_MAX_TOKENS=5120
LLM_CHAT_TEMPERATURE=0.2
LLM_METADATA_ENABLED="true"
LLM_METADATA_MODEL="glm-4.7-flash"
LLM_METADATA_MAX_TOKENS=2480
LLM_METADATA_TEMPERATURE=0.1

18
Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM php:8.3.22-cli-alpine
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk update --no-cache \
&& docker-php-source extract
# install extensions
RUN docker-php-ext-install pdo pdo_mysql -j$(nproc) pcntl
# enable opcache and pcntl
RUN docker-php-ext-enable opcache pcntl
RUN docker-php-source delete \
rm -rf /var/cache/apk/*
RUN mkdir -p /app
WORKDIR /app

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 walkor<walkor@workerman.net> and contributors (see https://github.com/walkor/webman/contributors)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

277
apidoc/importapi.md Normal file
View File

@ -0,0 +1,277 @@
# 档案导入 API
## 接口说明
导入一份 Markdown 档案文本,将其规范化为 Archive、Page、Chunk 三层结构,并为每个 chunk 生成全局唯一的 `chunk_uid`
正式使用时,推荐直接上传原始 Markdown 文件,或者把 Markdown 原文作为请求体发送。系统会根据 Markdown 中的分页标记自动分页、分段和切 chunk。
导入会先写入 PostgreSQL。如果 `title/year/author/tags/summary` 缺失,系统会把 `archive_uid` 推入 Redis 队列,由独立的 `ai_metadata` process 异步请求 AI 补全并更新数据库。
当前版本会把导入结果保存为运行时快照文件:
```text
runtime/proofdb/imports/{import_uid}.json
```
后续接入 MySQL、OpenSearch、Vector DB 时,会沿用当前返回结构中的 UID。
## 请求方式
```http
POST /api/articles/import
```
支持三种调用方式:
1. `multipart/form-data` 上传 Markdown 文件。
2. `text/markdown``text/plain` 直接发送 Markdown 原文。
3. `application/json` 发送 Markdown 字符串,适合程序内部调用。
## 请求字段
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `file` | file | 否 | Markdown 文件。使用 `multipart/form-data` 时推荐传此字段 |
| `archive_uid` | string | 否 | 档案级 ULID。不传时系统自动生成 |
| `title` | string | 否 | 档案标题。不传时,系统会优先请求 AI 生成,失败后从 Markdown 标题或文件名推断 |
| `year` | int | 否 | 档案年份。不传时,系统会请求 AI 从文本中抽取或推断 |
| `author` | string | 否 | 作者或签发人。不传时,系统会请求 AI 从文本中抽取或推断 |
| `summary` | string | 否 | 档案摘要。不传时,系统会请求 AI 生成 |
| `source` | string | 否 | 来源标识。不传时,系统会使用上传文件名或 `raw-markdown` |
| `series` | string | 否 | 所属系列集 |
| `tags` | array | 否 | 标签数组。不传时,系统会请求 AI 生成 |
| `metadata` | object | 否 | 档案级元数据,例如年份、作者、馆藏信息 |
| `content` | string | 否 | Markdown 原文。JSON 调用时使用 |
| `pages` | array | 否 | 页数组。兼容字段,一般不需要调用方传入 |
| `pages[].page_number` | int|string | 是 | 页码,可传数字或原始页码字符串,例如 `12`、`12a`、`封面` |
| `pages[].content` | string | 是 | 当前页 OCR 或转录文本 |
| `pages[].metadata` | object | 否 | 页级元数据 |
| `paragraphs` | array | 否 | 段落数组。兼容字段,一般不需要调用方传入 |
| `paragraphs[].page_number` | int|string | 否 | 当前段落所在页码 |
| `paragraphs[].content` | string | 是 | 段落正文 |
| `paragraphs[].metadata` | object | 否 | 段落级元数据 |
| `chunk_size` | int | 否 | 每个 chunk 的目标最大字符数,默认 `800`,范围 `100-4000` |
| `chunk_overlap` | int | 否 | 超长文本硬切时的重叠字符数,默认 `120`,必须小于 `chunk_size` |
## Markdown 分页规则
系统会优先识别以下分页格式:
```markdown
<!-- DOCMASTER:PAGE 0001 -->
## Page 1
第一页正文...
---
<!-- DOCMASTER:PAGE 0002 -->
## Page 2
第二页正文...
```
如果没有 `DOCMASTER:PAGE` 标记,系统会把整份 Markdown 当作单页处理。
## 分段与 Chunk 切分策略
当前 import 使用 Markdown 预处理 + 页内向量 chunk 切分:
1. 先根据 Markdown 分页标记拆成 page。
2. 每页内部去掉分页标题、水平分隔线等结构性标记。
3. 页眉、页脚、密级、纯页码等噪声块会被过滤,不作为向量 chunk 的正文。
4. 检索证据只定位到页码,因此 chunk 可以跨段落、跨列表项,但不会跨页。
5. 每页内部会按段落/句子/自然停顿形成文本单元,并尽量打包到接近 `chunk_size`
6. 如果单个文本单元超过 `chunk_size`,才退回固定字符窗口硬切,并使用 `chunk_overlap` 保留上下文。
## 请求示例
```bash
curl -X POST http://127.0.0.1:8787/api/articles/import \
-F 'title=NSD 76 Disposition of NSC Policy Documents' \
-F 'source=archive://nsc/nsd-76' \
-F 'chunk_size=800' \
-F 'chunk_overlap=120' \
-F 'file=@test/1.test.md;type=text/markdown'
```
也可以直接发送 Markdown 原文:
```bash
curl -X POST 'http://127.0.0.1:8787/api/articles/import?title=NSD%2076&source=archive://nsc/nsd-76' \
-H 'Content-Type: text/markdown' \
--data-binary '@test/1.test.md'
```
JSON 调用示例:
```bash
curl -X POST http://127.0.0.1:8787/api/articles/import \
-H 'Content-Type: application/json' \
--data '{
"title": "NSD 76 Disposition of NSC Policy Documents",
"source": "archive://nsc/nsd-76",
"content": "<!-- DOCMASTER:PAGE 0001 -->\n\n## Page 1\n\nMarkdown 正文..."
}'
```
## 成功响应
状态码:
```http
201 Created
```
响应示例:
```json
{
"code": 0,
"message": "Archive imported.",
"data": {
"import_uid": "01HX8K7V9Y3QF2Z6M4A1B8C9D0",
"archive": {
"archive_uid": "01HX8K7V9Y3QF2Z6M4A1B8C9D0",
"title": "NSD 76 Disposition of NSC Policy Documents",
"year": 1992,
"author": "Brent Scowcroft",
"source": "archive://nsc/nsd-76",
"series": null,
"tags": [
"美国国家安全委员会",
"政策文件",
"NSD 76"
],
"summary": "这份档案是 1992 年关于 NSC 政策文件处置的国家安全指令,列明已被取代或已完成、不再有效的政策文件。",
"metadata": {
"ai_enrichment": {
"enabled": true,
"attempted": true,
"filled": [
"year",
"author",
"tags",
"summary"
],
"missing": [],
"model": "glm-4.7-flash"
}
}
},
"pages": [
{
"page_number": 1,
"block_count": 8,
"chunk_count": 2,
"content_length": 1042,
"chunk_uids": [
"01HX8K7V9Y3QF2Z6M4A1B8C9D0_1_28172",
"01HX8K7V9Y3QF2Z6M4A1B8C9D0_2_19304"
]
}
],
"chunks": [
{
"chunk_uid": "01HX8K7V9Y3QF2Z6M4A1B8C9D0_1_28172",
"chunk_index": 1,
"page_start": 1,
"page_end": 1,
"pages": [
1
],
"text": "NSD 45 20 AUG 90 U.S. Policy in Response to the Iraqi Invasion of Kuwait (C)\n* *COMMENT** OBE by operations Desert Shield and Desert Storm.",
"length": 142,
"embedding_ref": null
}
],
"stats": {
"page_count": 8,
"page_block_count": 86,
"chunk_count": 14,
"chunk_size": 800,
"chunk_overlap": 120
},
"queue": {
"ai_metadata_enqueued": true,
"needs_ai_metadata": true
}
}
}
```
## 错误响应
### JSON 格式错误
状态码:
```http
400 Bad Request
```
```json
{
"code": 400,
"message": "Invalid JSON body.",
"errors": {
"body": "Syntax error"
}
}
```
### 参数校验失败
状态码:
```http
422 Unprocessable Entity
```
```json
{
"code": 422,
"message": "Archive import validation failed.",
"errors": {
"source": [
"source is required."
],
"content": [
"content, file, pages, or paragraphs is required."
]
}
}
```
### 快照保存失败
状态码:
```http
500 Internal Server Error
```
```json
{
"code": 500,
"message": "Archive import snapshot could not be saved.",
"errors": {
"storage": "具体错误信息"
}
}
```
## UID 说明
| UID | 说明 |
| --- | --- |
| `import_uid` | 本次导入任务 ID目前等同于档案级 ULID |
| `archive_uid` | 档案级 ULID例如 `01HX8K7V9Y3QF2Z6M4A1B8C9D0` |
| `chunk_uid` | chunk 级核心 ID格式为 `{archive_uid}_{chunk_index}_{short_uid}` |
`chunk_uid` 是后续 MySQL、OpenSearch、Vector DB 之间关联的核心字段。
`chunk_index``1` 开始递增。`short_uid` 是 5 位数字短码,由 chunk 的稳定内容哈希生成,方便人工查看和引用。

View File

@ -0,0 +1,133 @@
<?php
namespace app\controller\Api;
use app\service\ArticleImportService;
use JsonException;
use support\Request;
use support\Response;
use Webman\Http\UploadFile;
use Throwable;
class ArticleImportController
{
public function import(Request $request): Response
{
try {
$payload = $this->payload($request);
} catch (JsonException $exception) {
return $this->jsonResponse([
'code' => 400,
'message' => 'Invalid JSON body.',
'errors' => ['body' => $exception->getMessage()],
], 400);
}
$service = new ArticleImportService();
$result = $service->import($payload);
if (!$result['ok']) {
return $this->jsonResponse([
'code' => 422,
'message' => 'Archive import validation failed.',
'errors' => $result['errors'],
], 422);
}
try {
$service->persistSnapshot($result['data']);
(new \app\service\ArchiveRepository())->saveImport($result['data']);
if (($result['data']['queue']['needs_ai_metadata'] ?? false) === true) {
(new \app\service\AiMetadataQueue())->push($result['data']['archive']['archive_uid']);
$result['data']['queue']['ai_metadata_enqueued'] = true;
}
} catch (Throwable $exception) {
return $this->jsonResponse([
'code' => 500,
'message' => 'Archive import data could not be saved.',
'errors' => ['storage' => $exception->getMessage()],
], 500);
}
return $this->jsonResponse([
'code' => 0,
'message' => 'Archive imported.',
'data' => $result['data'],
], 201);
}
/**
* @throws JsonException
*/
private function payload(Request $request): array
{
$payload = array_merge($request->get(), $request->post());
$file = $request->file('file');
if ($file instanceof UploadFile) {
if (!$file->isValid()) {
return $payload + ['file_error' => $file->getUploadErrorCode()];
}
$payload['content'] = file_get_contents($file->getPathname()) ?: '';
$payload['source'] = $payload['source'] ?? $file->getUploadName();
$payload['title'] = $payload['title'] ?? pathinfo($file->getUploadName() ?? '', PATHINFO_FILENAME);
$payload['metadata'] = $this->metadata($payload['metadata'] ?? null);
return $payload;
}
$rawBody = trim($request->rawBody());
if ($rawBody === '') {
$payload['metadata'] = $this->metadata($payload['metadata'] ?? null);
return $payload;
}
if ($this->isJsonRequest($request)) {
$jsonPayload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($jsonPayload)) {
return [];
}
$jsonPayload['metadata'] = $this->metadata($jsonPayload['metadata'] ?? null);
return $jsonPayload;
}
$payload['content'] = $rawBody;
$payload['source'] = $payload['source'] ?? 'raw-markdown';
$payload['metadata'] = $this->metadata($payload['metadata'] ?? null);
return $payload;
}
private function isJsonRequest(Request $request): bool
{
$contentType = strtolower($request->header('content-type', ''));
return str_contains($contentType, 'application/json') || str_contains($contentType, '+json');
}
private function metadata(mixed $metadata): array
{
if (is_array($metadata)) {
return $metadata;
}
if (!is_string($metadata) || trim($metadata) === '') {
return [];
}
try {
$decoded = json_decode($metadata, true, 512, JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : [];
} catch (JsonException) {
return [];
}
}
private function jsonResponse(array $data, int $status): Response
{
return response(
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
$status,
['Content-Type' => 'application/json']
);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace app\controller;
use support\Request;
class IndexController
{
public function index(Request $request)
{
return <<<EOF
<style>
* {
padding: 0;
margin: 0;
}
iframe {
border: none;
overflow: scroll;
}
</style>
<iframe
src="https://www.workerman.net/wellcome"
width="100%"
height="100%"
allow="clipboard-write"
sandbox="allow-scripts allow-same-origin allow-popups allow-downloads"
></iframe>
EOF;
}
public function view(Request $request)
{
return view('index/view', ['name' => 'webman']);
}
public function json(Request $request)
{
return json(['code' => 0, 'msg' => 'ok']);
}
}

4
app/functions.php Normal file
View File

@ -0,0 +1,4 @@
<?php
/**
* Here is your custom functions.
*/

View File

@ -0,0 +1,42 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace app\middleware;
use Webman\MiddlewareInterface;
use Webman\Http\Response;
use Webman\Http\Request;
/**
* Class StaticFile
* @package app\middleware
*/
class StaticFile implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
// Access to files beginning with. Is prohibited
if (strpos($request->path(), '/.') !== false) {
return response('<h1>403 forbidden</h1>', 403);
}
/** @var Response $response */
$response = $handler($request);
// Add cross domain HTTP header
/*$response->withHeaders([
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Credentials' => 'true',
]);*/
return $response;
}
}

29
app/model/Test.php Normal file
View File

@ -0,0 +1,29 @@
<?php
namespace app\model;
use support\Model;
class Test extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'test';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = false;
}

View File

@ -0,0 +1,77 @@
<?php
namespace app\process;
use app\service\AiMetadataQueue;
use app\service\ArchiveMetadataEnrichmentService;
use app\service\ArchiveRepository;
use Throwable;
use Workerman\Timer;
class AiMetadata
{
private AiMetadataQueue $queue;
private ArchiveRepository $archives;
private ArchiveMetadataEnrichmentService $enrichment;
public function __construct()
{
$this->queue = new AiMetadataQueue();
$this->archives = new ArchiveRepository();
$this->enrichment = new ArchiveMetadataEnrichmentService();
}
public function onWorkerStart(): void
{
Timer::add(10, fn (): int => $this->queue->releaseDueDelayed());
while (true) {
$this->queue->releaseDueDelayed();
$archiveUid = $this->queue->pop($this->queue->blockTimeout());
if ($archiveUid === null) {
continue;
}
$this->handle($archiveUid);
}
}
private function handle(string $archiveUid): void
{
try {
$archive = $this->archives->findArchive($archiveUid);
if ($archive === null) {
$this->queue->clearRetry($archiveUid);
return;
}
if (!$this->archives->archiveNeedsMetadata($archive)) {
$this->queue->clearRetry($archiveUid);
return;
}
$payload = $archive;
$payload['content'] = $this->archives->findChunksText($archiveUid);
$enriched = $this->enrichment->enrich($payload);
$aiMeta = $enriched['metadata']['ai_enrichment'] ?? [];
if (($aiMeta['attempted'] ?? false) !== true || ($aiMeta['error'] ?? null)) {
$this->queue->retryLater($archiveUid, $aiMeta['error'] ?? 'AI metadata enrichment did not complete.');
return;
}
$fields = [];
foreach (['title', 'year', 'author', 'tags', 'summary'] as $field) {
if (array_key_exists($field, $enriched)) {
$fields[$field] = $enriched[$field];
}
}
$this->archives->updateMetadata($archiveUid, $fields, $aiMeta);
$this->queue->clearRetry($archiveUid);
} catch (Throwable $exception) {
$this->queue->retryLater($archiveUid, $exception->getMessage());
}
}
}

10
app/process/Http.php Normal file
View File

@ -0,0 +1,10 @@
<?php
namespace app\process;
use Webman\App;
class Http extends App
{
}

305
app/process/Monitor.php Normal file
View File

@ -0,0 +1,305 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace app\process;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use Workerman\Timer;
use Workerman\Worker;
/**
* Class FileMonitor
* @package process
*/
class Monitor
{
/**
* @var array
*/
protected array $paths = [];
/**
* @var array
*/
protected array $extensions = [];
/**
* @var array
*/
protected array $loadedFiles = [];
/**
* @var int
*/
protected int $ppid = 0;
/**
* Pause monitor
* @return void
*/
public static function pause(): void
{
file_put_contents(static::lockFile(), time());
}
/**
* Resume monitor
* @return void
*/
public static function resume(): void
{
clearstatcache();
if (is_file(static::lockFile())) {
unlink(static::lockFile());
}
}
/**
* Whether monitor is paused
* @return bool
*/
public static function isPaused(): bool
{
clearstatcache();
return file_exists(static::lockFile());
}
/**
* Lock file
* @return string
*/
protected static function lockFile(): string
{
return runtime_path('monitor.lock');
}
/**
* FileMonitor constructor.
* @param $monitorDir
* @param $monitorExtensions
* @param array $options
*/
public function __construct($monitorDir, $monitorExtensions, array $options = [])
{
$this->ppid = function_exists('posix_getppid') ? posix_getppid() : 0;
static::resume();
$this->paths = (array)$monitorDir;
$this->extensions = $monitorExtensions;
foreach (get_included_files() as $index => $file) {
$this->loadedFiles[$file] = $index;
if (strpos($file, 'webman-framework/src/support/App.php')) {
break;
}
}
if (!Worker::getAllWorkers()) {
return;
}
$disableFunctions = explode(',', ini_get('disable_functions'));
if (in_array('exec', $disableFunctions, true)) {
echo "\nMonitor file change turned off because exec() has been disabled by disable_functions setting in " . PHP_CONFIG_FILE_PATH . "/php.ini\n";
} else {
if ($options['enable_file_monitor'] ?? true) {
Timer::add(1, function () {
$this->checkAllFilesChange();
});
}
}
$memoryLimit = $this->getMemoryLimit($options['memory_limit'] ?? null);
if ($memoryLimit && ($options['enable_memory_monitor'] ?? true)) {
Timer::add(60, [$this, 'checkMemory'], [$memoryLimit]);
}
}
/**
* @param $monitorDir
* @return bool
*/
public function checkFilesChange($monitorDir): bool
{
static $lastMtime, $tooManyFilesCheck;
if (!$lastMtime) {
$lastMtime = time();
}
clearstatcache();
if (!is_dir($monitorDir)) {
if (!is_file($monitorDir)) {
return false;
}
$iterator = [new SplFileInfo($monitorDir)];
} else {
// recursive traversal directory
$dirIterator = new RecursiveDirectoryIterator($monitorDir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS);
$iterator = new RecursiveIteratorIterator($dirIterator);
}
$count = 0;
foreach ($iterator as $file) {
$count ++;
/** @var SplFileInfo $file */
if (is_dir($file->getRealPath())) {
continue;
}
// check mtime
if (in_array($file->getExtension(), $this->extensions, true) && $lastMtime < $file->getMTime()) {
$lastMtime = $file->getMTime();
if (DIRECTORY_SEPARATOR === '/' && isset($this->loadedFiles[$file->getRealPath()])) {
echo "$file updated but cannot be reloaded because only auto-loaded files support reload.\n";
continue;
}
$var = 0;
exec('"'.PHP_BINARY . '" -l ' . $file, $out, $var);
if ($var) {
continue;
}
// send SIGUSR1 signal to master process for reload
if (DIRECTORY_SEPARATOR === '/') {
if ($masterPid = $this->getMasterPid()) {
echo $file . " updated and reload\n";
posix_kill($masterPid, SIGUSR1);
} else {
echo "Master process has gone away and can not reload\n";
}
return true;
}
echo $file . " updated and reload\n";
return true;
}
}
if (!$tooManyFilesCheck && $count > 1000) {
echo "Monitor: There are too many files ($count files) in $monitorDir which makes file monitoring very slow\n";
$tooManyFilesCheck = 1;
}
return false;
}
/**
* @return int
*/
public function getMasterPid(): int
{
if ($this->ppid === 0) {
return 0;
}
if (function_exists('posix_kill') && !posix_kill($this->ppid, 0)) {
echo "Master process has gone away\n";
return $this->ppid = 0;
}
if (PHP_OS_FAMILY !== 'Linux') {
return $this->ppid;
}
$cmdline = "/proc/$this->ppid/cmdline";
if (!is_readable($cmdline) || !($content = file_get_contents($cmdline)) || (!str_contains($content, 'WorkerMan') && !str_contains($content, 'php'))) {
// Process not exist
$this->ppid = 0;
}
return $this->ppid;
}
/**
* @return bool
*/
public function checkAllFilesChange(): bool
{
if (static::isPaused()) {
return false;
}
foreach ($this->paths as $path) {
if ($this->checkFilesChange($path)) {
return true;
}
}
return false;
}
/**
* @param $memoryLimit
* @return void
*/
public function checkMemory($memoryLimit): void
{
if (static::isPaused() || $memoryLimit <= 0) {
return;
}
$masterPid = $this->getMasterPid();
if ($masterPid <= 0) {
echo "Master process has gone away\n";
return;
}
$childrenFile = "/proc/$masterPid/task/$masterPid/children";
if (!is_file($childrenFile) || !($children = file_get_contents($childrenFile))) {
return;
}
foreach (explode(' ', $children) as $pid) {
$pid = (int)$pid;
$statusFile = "/proc/$pid/status";
if (!is_file($statusFile) || !($status = file_get_contents($statusFile))) {
continue;
}
$mem = 0;
if (preg_match('/VmRSS\s*?:\s*?(\d+?)\s*?kB/', $status, $match)) {
$mem = $match[1];
}
$mem = (int)($mem / 1024);
if ($mem >= $memoryLimit) {
posix_kill($pid, SIGINT);
}
}
}
/**
* Get memory limit
* @param $memoryLimit
* @return int
*/
protected function getMemoryLimit($memoryLimit): int
{
if ($memoryLimit === 0) {
return 0;
}
$usePhpIni = false;
if (!$memoryLimit) {
$memoryLimit = ini_get('memory_limit');
$usePhpIni = true;
}
if ($memoryLimit == -1) {
return 0;
}
$unit = strtolower($memoryLimit[strlen($memoryLimit) - 1]);
$memoryLimit = (int)$memoryLimit;
if ($unit === 'g') {
$memoryLimit = 1024 * $memoryLimit;
} else if ($unit === 'k') {
$memoryLimit = ($memoryLimit / 1024);
} else if ($unit === 'm') {
$memoryLimit = (int)($memoryLimit);
} else if ($unit === 't') {
$memoryLimit = (1024 * 1024 * $memoryLimit);
} else {
$memoryLimit = ($memoryLimit / (1024 * 1024));
}
if ($memoryLimit < 50) {
$memoryLimit = 50;
}
if ($usePhpIni) {
$memoryLimit = (0.8 * $memoryLimit);
}
return (int)$memoryLimit;
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace app\service;
use support\Redis;
class AiMetadataQueue
{
public function push(string $archiveUid): void
{
Redis::lPush($this->pendingKey(), $archiveUid);
}
public function pop(int $timeout = 5): ?string
{
$result = Redis::brPop([$this->pendingKey()], $timeout);
if (!is_array($result) || count($result) < 2) {
return null;
}
return (string) $result[1];
}
public function retryLater(string $archiveUid, string $error): void
{
$retryKey = $this->retryKey($archiveUid);
$retryCount = (int) Redis::incr($retryKey);
Redis::setEx($this->errorKey($archiveUid), 86400, $error);
if ($retryCount > $this->maxRetries()) {
Redis::lPush($this->failedKey(), $archiveUid);
return;
}
$delay = $this->baseDelaySeconds() * (2 ** max(0, $retryCount - 1));
Redis::zAdd($this->delayedKey(), time() + $delay, $archiveUid);
}
public function releaseDueDelayed(): int
{
$now = time();
$items = Redis::zRangeByScore($this->delayedKey(), '-inf', (string) $now, ['limit' => [0, 100]]);
if (!is_array($items) || $items === []) {
return 0;
}
foreach ($items as $archiveUid) {
Redis::zRem($this->delayedKey(), $archiveUid);
Redis::lPush($this->pendingKey(), $archiveUid);
}
return count($items);
}
public function clearRetry(string $archiveUid): void
{
Redis::del($this->retryKey($archiveUid), $this->errorKey($archiveUid));
}
public function blockTimeout(): int
{
return (int) config('queue.ai_metadata.block_timeout', 5);
}
private function pendingKey(): string
{
return config('queue.ai_metadata.pending', 'proofdb:ai:metadata:pending');
}
private function delayedKey(): string
{
return config('queue.ai_metadata.delayed', 'proofdb:ai:metadata:delayed');
}
private function failedKey(): string
{
return config('queue.ai_metadata.failed', 'proofdb:ai:metadata:failed');
}
private function retryKey(string $archiveUid): string
{
return config('queue.ai_metadata.retry_prefix', 'proofdb:ai:metadata:retry:') . $archiveUid;
}
private function errorKey(string $archiveUid): string
{
return config('queue.ai_metadata.error_prefix', 'proofdb:ai:metadata:error:') . $archiveUid;
}
private function maxRetries(): int
{
return (int) config('queue.ai_metadata.max_retries', 5);
}
private function baseDelaySeconds(): int
{
return (int) config('queue.ai_metadata.base_delay_seconds', 60);
}
}

View File

@ -0,0 +1,197 @@
<?php
namespace app\service;
use app\service\LLM\OpenAICompatibleClient;
use app\service\LLM\LLMRetryQueue;
use Throwable;
class ArchiveMetadataEnrichmentService
{
private OpenAICompatibleClient $client;
private LLMRetryQueue $queue;
public function __construct(?OpenAICompatibleClient $client = null, ?LLMRetryQueue $queue = null)
{
$this->client = $client ?? new OpenAICompatibleClient();
$this->queue = $queue ?? new LLMRetryQueue();
}
public function enrich(array $payload): array
{
$missing = $this->missingFields($payload);
if ($missing === [] || !$this->enabled()) {
return $this->withAiMeta($payload, [
'enabled' => $this->enabled(),
'attempted' => false,
'filled' => [],
'missing' => $missing,
]);
}
try {
$result = $this->queue->run(
fn (): array => $this->client->chatJson($this->messages($payload, $missing), [
'model' => config('LLMapi.metadata.model'),
'temperature' => config('LLMapi.metadata.temperature', 0.1),
'max_tokens' => config('LLMapi.metadata.max_tokens', 1200),
'stream' => false,
'response_format' => config('LLMapi.metadata.response_format', ['type' => 'json_object']),
'thinking' => config('LLMapi.metadata.thinking', ['type' => 'disabled']),
'request_id' => $this->requestId($payload, $missing),
]),
config('LLMapi.metadata.retry', [])
);
} catch (Throwable $exception) {
return $this->withAiMeta($payload, [
'enabled' => true,
'attempted' => true,
'filled' => [],
'missing' => $missing,
'error' => $exception->getMessage(),
]);
}
$filled = [];
foreach ($missing as $field) {
if (!$this->hasUsefulValue($result, $field)) {
continue;
}
$payload[$field] = $this->normalizeField($field, $result[$field]);
$filled[] = $field;
}
return $this->withAiMeta($payload, [
'enabled' => true,
'attempted' => true,
'filled' => $filled,
'missing' => array_values(array_diff($missing, $filled)),
'model' => config('LLMapi.metadata.model'),
'stream' => false,
'response_format' => config('LLMapi.metadata.response_format', ['type' => 'json_object']),
'thinking' => config('LLMapi.metadata.thinking', ['type' => 'disabled']),
]);
}
private function missingFields(array $payload): array
{
$fields = ['title', 'year', 'author', 'tags', 'summary'];
return array_values(array_filter($fields, fn (string $field): bool => !$this->hasUsefulValue($payload, $field)));
}
private function enabled(): bool
{
return (bool) config('LLMapi.metadata.enabled', true) && $this->client->isConfigured();
}
private function messages(array $payload, array $missing): array
{
$text = $this->sampleText($payload);
return [
[
'role' => 'system',
'content' => implode("\n", [
'你是历史档案元数据整理助手。',
'你只能根据用户提供的档案文本抽取或推断元数据。',
'请只返回 JSON 对象,不要返回 Markdown不要解释。',
'字段title(string), year(integer|null), author(string|null), tags(array<string>), summary(string)。',
'summary 简洁概括档案内容80-200 字。',
'tags 用档案中常见专名和涉及主题5-10 个。',
'无法判断的字段返回 null 或空数组。',
'以上请均使用档案中的语言。',
]),
],
[
'role' => 'user',
'content' => json_encode([
'missing_fields' => $missing,
'known_fields' => [
'title' => $payload['title'] ?? null,
'year' => $payload['year'] ?? null,
'author' => $payload['author'] ?? null,
'source' => $payload['source'] ?? null,
'series' => $payload['series'] ?? null,
],
'archive_text_sample' => $text,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
],
];
}
private function requestId(array $payload, array $missing): string
{
return 'metadata-' . substr(hash('sha256', implode('|', [
(string) ($payload['source'] ?? ''),
(string) ($payload['archive_uid'] ?? ''),
mb_substr((string) ($payload['content'] ?? ''), 0, 1000),
implode(',', $missing ?? []),
])), 0, 32);
}
private function sampleText(array $payload): string
{
$text = '';
if (isset($payload['content']) && is_string($payload['content'])) {
$text = $payload['content'];
} elseif (isset($payload['pages']) && is_array($payload['pages'])) {
$parts = [];
foreach ($payload['pages'] as $page) {
if (isset($page['content']) && is_string($page['content'])) {
$parts[] = $page['content'];
}
}
$text = implode("\n\n", $parts);
}
$maxChars = (int) config('LLMapi.metadata.max_input_chars', 12000);
return mb_substr($text, 0, $maxChars);
}
private function hasUsefulValue(array $payload, string $field): bool
{
if (!array_key_exists($field, $payload)) {
return false;
}
if ($field === 'title' && (($payload['metadata']['title_source'] ?? null) === 'fallback')) {
return false;
}
$value = $payload[$field];
if (is_array($value)) {
return $value !== [];
}
if ($field === 'year') {
return is_numeric($value) && (int) $value > 0;
}
return is_string($value) ? trim($value) !== '' : $value !== null;
}
private function normalizeField(string $field, mixed $value): mixed
{
if ($field === 'year') {
return is_numeric($value) ? (int) $value : null;
}
if ($field === 'tags') {
if (!is_array($value)) {
return [];
}
return array_values(array_filter(array_map('strval', $value)));
}
return is_string($value) ? trim($value) : $value;
}
private function withAiMeta(array $payload, array $ai): array
{
$payload['metadata'] = is_array($payload['metadata'] ?? null) ? $payload['metadata'] : [];
$payload['metadata']['ai_enrichment'] = $ai;
return $payload;
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace app\service;
use support\Db;
class ArchiveRepository
{
public function saveImport(array $import): void
{
Db::transaction(function () use ($import): void {
$archive = $import['archive'];
$chunks = $import['chunks'];
$chunkUids = array_column($chunks, 'chunk_uid');
Db::table('archives')->updateOrInsert(
['archive_uid' => $archive['archive_uid']],
[
'title' => $archive['title'] ?? null,
'summary' => $archive['summary'] ?? null,
'year' => $archive['year'] ?? null,
'author' => $archive['author'] ?? null,
'source' => $archive['source'] ?? null,
'series' => $archive['series'] ?? null,
'tags' => json_encode($archive['tags'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'metadata' => json_encode($archive['metadata'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'content' => $archive['content'] ?? null,
'raw' => $archive['raw'] ?? null,
'chunks' => json_encode($chunkUids, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]
);
Db::table('chunks')->where('archive_uid', $archive['archive_uid'])->delete();
foreach ($chunks as $chunk) {
Db::table('chunks')->insert([
'chunk_uid' => $chunk['chunk_uid'],
'archive_uid' => $archive['archive_uid'],
'chunk_index' => $chunk['chunk_index'],
'page_start' => $chunk['page_start'],
'page_end' => $chunk['page_end'],
'text' => $chunk['text'],
'length' => $chunk['length'],
'embedding_status' => 0,
'embedding_ref' => null,
'embedding_model' => null,
]);
}
});
}
public function findArchive(string $archiveUid): ?array
{
$archive = Db::table('archives')->where('archive_uid', $archiveUid)->first();
if (!$archive) {
return null;
}
return $this->archiveToArray($archive);
}
public function findChunksText(string $archiveUid, int $limit = 20): string
{
$chunks = Db::table('chunks')
->where('archive_uid', $archiveUid)
->orderBy('chunk_index')
->limit($limit)
->get(['text'])
->all();
return implode("\n\n", array_map(fn ($chunk): string => (string) $chunk->text, $chunks));
}
public function updateMetadata(string $archiveUid, array $fields, array $aiMeta): void
{
$archive = $this->findArchive($archiveUid);
$metadata = $archive['metadata'] ?? [];
$metadata['ai_enrichment'] = $aiMeta;
$updates = [
'metadata' => json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
];
foreach (['title', 'summary', 'year', 'author', 'series', 'tags'] as $field) {
if (!array_key_exists($field, $fields)) {
continue;
}
$updates[$field] = $field === 'tags'
? json_encode($fields[$field] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
: $fields[$field];
}
Db::table('archives')->where('archive_uid', $archiveUid)->update($updates);
}
public function archiveNeedsMetadata(array $archive): bool
{
foreach (['title', 'year', 'author', 'tags', 'summary'] as $field) {
$value = $archive[$field] ?? null;
if ($field === 'title' && (($archive['metadata']['title_source'] ?? null) === 'fallback')) {
return true;
}
if (is_array($value) && $value === []) {
return true;
}
if ($field === 'year' && (!$value || (int) $value <= 0)) {
return true;
}
if (!is_array($value) && ($value === null || trim((string) $value) === '')) {
return true;
}
}
return false;
}
private function archiveToArray(object $archive): array
{
return [
'archive_uid' => $archive->archive_uid,
'title' => $archive->title,
'summary' => $archive->summary,
'year' => $archive->year,
'author' => $archive->author,
'source' => $archive->source,
'series' => $archive->series,
'tags' => json_decode($archive->tags ?? '[]', true) ?: [],
'metadata' => json_decode($archive->metadata ?? '{}', true) ?: [],
'content' => $archive->content,
'raw' => $archive->raw,
'chunks' => json_decode($archive->chunks ?? '[]', true) ?: [],
];
}
}

View File

@ -0,0 +1,775 @@
<?php
namespace app\service;
use RuntimeException;
use Symfony\Component\Uid\Ulid;
class ArticleImportService
{
private const DEFAULT_CHUNK_SIZE = 800;
private const DEFAULT_CHUNK_OVERLAP = 120;
private const MIN_CHUNK_SIZE = 100;
private const MAX_CHUNK_SIZE = 4000;
private const SENTENCE_BOUNDARY_PATTERN = '/(?<=[。!?;.!?;])\s*|\R+/u';
private const MARKDOWN_PAGE_PATTERN = '/<!--\s*DOCMASTER:PAGE\s+0*([0-9A-Za-z_-]+)\s*-->\s*(?:#+\s*Page\s+\S+\s*)?(.*?)(?=\n---\s*\n\s*<!--\s*DOCMASTER:PAGE|\z)/su';
public function import(array $payload): array
{
$payload = $this->normalizePayload($payload);
$payload = $this->applyMetadataFallbacks($payload);
$errors = $this->validate($payload);
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
$archiveUid = $this->archiveUid($payload);
$archive = $this->archive($payload, $archiveUid);
$pageBlocks = $this->pageBlocks($payload);
$chunkSize = $this->intOption($payload, 'chunk_size', self::DEFAULT_CHUNK_SIZE);
$chunkOverlap = $this->intOption($payload, 'chunk_overlap', self::DEFAULT_CHUNK_OVERLAP);
$chunks = $this->chunksFromPages($archiveUid, $pageBlocks, $chunkSize, $chunkOverlap);
$pages = $this->pagesSummary($pageBlocks, $chunks);
$needsAiMetadata = (new ArchiveRepository())->archiveNeedsMetadata($archive);
return [
'ok' => true,
'data' => [
'import_uid' => $archiveUid,
'archive' => $archive,
'chunks' => $chunks,
'pages' => $pages,
'stats' => [
'page_count' => count($pages),
'page_block_count' => count($pageBlocks),
'chunk_count' => count($chunks),
'chunk_size' => $chunkSize,
'chunk_overlap' => $chunkOverlap,
],
'queue' => [
'ai_metadata_enqueued' => false,
'needs_ai_metadata' => $needsAiMetadata,
],
],
];
}
public function persistSnapshot(array $import): void
{
$directory = runtime_path('proofdb/imports');
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException("Unable to create directory: {$directory}");
}
$path = $directory . DIRECTORY_SEPARATOR . $import['import_uid'] . '.json';
$json = json_encode($import, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
if (file_put_contents($path, $json) === false) {
throw new RuntimeException("Unable to write import snapshot: {$path}");
}
}
private function validate(array $payload): array
{
$errors = [];
if (isset($payload['file_error'])) {
$errors['file'][] = 'uploaded file is invalid.';
}
if (!isset($payload['title']) || !is_string($payload['title']) || trim($payload['title']) === '') {
$errors['title'][] = 'title is required.';
}
if (!isset($payload['source']) || !is_string($payload['source']) || trim($payload['source']) === '') {
$errors['source'][] = 'source is required.';
}
if (!isset($payload['content']) && !isset($payload['paragraphs']) && !isset($payload['pages'])) {
$errors['content'][] = 'content, file, pages, or paragraphs is required.';
}
if (isset($payload['paragraphs']) && !is_array($payload['paragraphs'])) {
$errors['paragraphs'][] = 'paragraphs must be an array.';
}
if (isset($payload['pages']) && !is_array($payload['pages'])) {
$errors['pages'][] = 'pages must be an array.';
}
if (isset($payload['metadata']) && !is_array($payload['metadata'])) {
$errors['metadata'][] = 'metadata must be an object.';
}
$chunkSize = $this->intOption($payload, 'chunk_size', self::DEFAULT_CHUNK_SIZE);
if ($chunkSize < self::MIN_CHUNK_SIZE || $chunkSize > self::MAX_CHUNK_SIZE) {
$errors['chunk_size'][] = 'chunk_size must be between 100 and 4000.';
}
$chunkOverlap = $this->intOption($payload, 'chunk_overlap', self::DEFAULT_CHUNK_OVERLAP);
if ($chunkOverlap < 0 || $chunkOverlap >= $chunkSize) {
$errors['chunk_overlap'][] = 'chunk_overlap must be greater than or equal to 0 and less than chunk_size.';
}
if (!isset($errors['paragraphs']) && isset($payload['paragraphs'])) {
$hasContent = false;
foreach ($payload['paragraphs'] as $index => $paragraph) {
$content = is_array($paragraph) ? ($paragraph['content'] ?? '') : $paragraph;
if (!is_string($content)) {
$errors["paragraphs.{$index}.content"][] = 'paragraph content must be a string.';
continue;
}
if (trim($content) !== '') {
$hasContent = true;
}
}
if (!$hasContent) {
$errors['paragraphs'][] = 'paragraphs must contain at least one non-empty paragraph.';
}
}
if (!isset($errors['pages']) && isset($payload['pages'])) {
$hasContent = false;
foreach ($payload['pages'] as $index => $page) {
if (!is_array($page)) {
$errors["pages.{$index}"][] = 'page must be an object.';
continue;
}
if (!$this->hasPageNumber($page)) {
$errors["pages.{$index}.page_number"][] = 'page_number is required.';
}
if (!isset($page['content']) || !is_string($page['content'])) {
$errors["pages.{$index}.content"][] = 'page content must be a string.';
continue;
}
if (trim($page['content']) !== '') {
$hasContent = true;
}
if (isset($page['metadata']) && !is_array($page['metadata'])) {
$errors["pages.{$index}.metadata"][] = 'page metadata must be an object.';
}
}
if (!$hasContent) {
$errors['pages'][] = 'pages must contain at least one non-empty page.';
}
}
if (isset($payload['content']) && (!is_string($payload['content']) || trim($payload['content']) === '')) {
$errors['content'][] = 'content must be a non-empty string.';
}
return $errors;
}
private function archive(array $payload, string $archiveUid): array
{
$title = $this->clean($payload['title']);
$source = $this->clean($payload['source']);
return [
'archive_uid' => $archiveUid,
'title' => $title,
'year' => isset($payload['year']) ? (int) $payload['year'] : null,
'author' => $this->nullableClean($payload['author'] ?? null),
'source' => $source,
'series' => $this->nullableClean($payload['series'] ?? null),
'tags' => is_array($payload['tags'] ?? null) ? array_values($payload['tags']) : [],
'summary' => $this->nullableClean($payload['summary'] ?? null),
'metadata' => $payload['metadata'] ?? [],
'content' => $this->nullableClean($payload['content_url'] ?? $payload['content_path'] ?? null),
'raw' => $this->nullableClean($payload['raw_url'] ?? $payload['raw_path'] ?? null),
];
}
private function pageBlocks(array $payload): array
{
if (isset($payload['pages'])) {
return $this->pageBlocksFromPages($payload);
}
if (isset($payload['paragraphs'])) {
return $this->pageBlocksFromItems($payload, $payload['paragraphs']);
}
return $this->pageBlocksFromItems($payload, preg_split('/\R{2,}/u', $payload['content']));
}
private function pageBlocksFromPages(array $payload): array
{
$pageBlocks = [];
foreach ($payload['pages'] as $pageIndex => $page) {
$pageNumber = $this->pageNumber($page);
$pageMetadata = $page['metadata'] ?? [];
$items = $this->markdownBlocksFromPage($page['content']);
foreach ($items as $itemIndex => $content) {
$pageBlock = $this->pageBlock($payload, $content, count($pageBlocks), $itemIndex, $pageNumber, [
'page_index' => $pageIndex,
'page_metadata' => $pageMetadata,
]);
if ($pageBlock !== null) {
$pageBlocks[] = $pageBlock;
}
}
}
return $pageBlocks;
}
private function chunksFromPages(string $archiveUid, array $pageBlocks, int $chunkSize, int $chunkOverlap): array
{
$chunks = [];
$chunkIndex = 1;
foreach ($this->groupBlocksByPage($pageBlocks) as $pageNumber => $blocks) {
$units = [];
foreach ($blocks as $block) {
$unit = $this->cleanEmbeddingText($block['content']);
if ($unit === '' || $this->isNoiseBlock($unit)) {
continue;
}
$units[] = $unit;
}
foreach ($this->packUnitsForEmbedding($units, $chunkSize, $chunkOverlap) as $text) {
$page = $this->restorePageNumber($pageNumber);
$chunks[] = $this->chunk($archiveUid, $chunkIndex, $page, $text);
$chunkIndex++;
}
}
return $chunks;
}
private function groupBlocksByPage(array $pageBlocks): array
{
$pages = [];
foreach ($pageBlocks as $block) {
$key = $block['page_number'] === null ? '' : (string) $block['page_number'];
$pages[$key][] = $block;
}
return $pages;
}
private function pagesSummary(array $pageBlocks, array $chunks): array
{
$pages = [];
foreach ($this->groupBlocksByPage($pageBlocks) as $pageNumber => $blocks) {
$page = $this->restorePageNumber($pageNumber);
$pageChunks = array_values(array_filter($chunks, fn (array $chunk): bool => $chunk['page_start'] === $page));
$contentLength = array_sum(array_map(fn (array $block): int => mb_strlen($block['content']), $blocks));
$pages[] = [
'page_number' => $page,
'block_count' => count($blocks),
'chunk_count' => count($pageChunks),
'content_length' => $contentLength,
'chunk_uids' => array_column($pageChunks, 'chunk_uid'),
];
}
return $pages;
}
private function packUnitsForEmbedding(array $units, int $chunkSize, int $chunkOverlap): array
{
$chunks = [];
$current = '';
foreach ($units as $unit) {
$unit = $this->clean($unit);
if ($unit === '') {
continue;
}
if (mb_strlen($unit) > $chunkSize) {
if ($current !== '') {
$chunks[] = $current;
$current = '';
}
array_push($chunks, ...$this->chunkLongUnit($unit, $chunkSize, $chunkOverlap));
continue;
}
$candidate = $current === '' ? $unit : $current . "\n\n" . $unit;
if (mb_strlen($candidate) <= $chunkSize) {
$current = $candidate;
continue;
}
if ($current !== '') {
$chunks[] = $current;
}
$current = $unit;
}
if ($current !== '') {
$chunks[] = $current;
}
return $chunks;
}
private function chunk(string $archiveUid, int $chunkIndex, int|string|null $pageNumber, string $text): array
{
$chunkUid = $this->chunkUid($archiveUid, $chunkIndex, implode('|', [
$chunkIndex,
(string) ($pageNumber ?? ''),
$text,
]));
return [
'chunk_uid' => $chunkUid,
'chunk_index' => $chunkIndex,
'page_start' => $pageNumber,
'page_end' => $pageNumber,
'pages' => $pageNumber === null ? [] : [$pageNumber],
'text' => $text,
'length' => mb_strlen($text),
'embedding_ref' => null,
];
}
private function normalizePayload(array $payload): array
{
if (isset($payload['content']) && is_string($payload['content']) && !isset($payload['pages']) && !isset($payload['paragraphs'])) {
$payload['pages'] = $this->pagesFromMarkdown($payload['content']);
}
if (!isset($payload['source']) || trim((string) $payload['source']) === '') {
$payload['source'] = 'raw-markdown';
}
return $payload;
}
private function applyMetadataFallbacks(array $payload): array
{
if ((!isset($payload['title']) || trim((string) $payload['title']) === '') && isset($payload['content']) && is_string($payload['content'])) {
$payload['title'] = $this->inferTitle($payload['content'], (string) ($payload['source'] ?? ''));
$payload['metadata'] = is_array($payload['metadata'] ?? null) ? $payload['metadata'] : [];
$payload['metadata']['title_source'] = 'fallback';
}
if (isset($payload['tags']) && is_string($payload['tags'])) {
$payload['tags'] = $this->tagsFromString($payload['tags']);
}
$payload['tags'] = is_array($payload['tags'] ?? null) ? $payload['tags'] : [];
if (isset($payload['year']) && is_numeric($payload['year'])) {
$payload['year'] = (int) $payload['year'];
}
$payload['metadata'] = is_array($payload['metadata'] ?? null) ? $payload['metadata'] : [];
return $payload;
}
private function pagesFromMarkdown(string $markdown): array
{
preg_match_all(self::MARKDOWN_PAGE_PATTERN, $markdown, $matches, PREG_SET_ORDER);
if ($matches === []) {
return [[
'page_number' => 1,
'content' => $this->cleanMarkdownPage($markdown),
'metadata' => ['parser' => 'markdown_single_page'],
]];
}
$pages = [];
foreach ($matches as $index => $match) {
$pageNumber = ctype_digit($match[1]) ? (int) $match[1] : $match[1];
$pages[] = [
'page_number' => $pageNumber,
'content' => $this->cleanMarkdownPage($match[2]),
'metadata' => [
'parser' => 'docmaster_markdown',
'page_index' => $index,
],
];
}
return $pages;
}
private function markdownBlocksFromPage(string $content): array
{
$content = $this->cleanMarkdownPage($content);
$blocks = preg_split('/\R{2,}/u', $content, -1, PREG_SPLIT_NO_EMPTY);
if ($blocks === false) {
return [$content];
}
$records = [];
foreach ($blocks as $block) {
$block = $this->cleanMarkdownBlock($block);
if ($block === '') {
continue;
}
$lastIndex = count($records) - 1;
if ($lastIndex >= 0 && $this->isCommentBlock($block) && $this->isPolicyRecordBlock($records[$lastIndex])) {
$records[$lastIndex] .= "\n" . $block;
continue;
}
$records[] = $block;
}
return $records;
}
private function cleanMarkdownPage(string $content): string
{
$content = preg_replace('/<!--\s*DOCMASTER:PAGE\s+[^>]+-->/iu', '', $content) ?? $content;
$content = preg_replace('/^\s*#+\s*Page\s+\S+\s*$/imu', '', $content) ?? $content;
$content = preg_replace('/^\s*---+\s*$/mu', '', $content) ?? $content;
$content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim($content);
}
private function cleanMarkdownBlock(string $block): string
{
$block = preg_replace('/[ \t]+/u', ' ', $block) ?? $block;
$block = preg_replace('/\R[ \t]+/u', "\n", $block) ?? $block;
return trim($block);
}
private function isCommentBlock(string $block): bool
{
return (bool) preg_match('/^\s*(?:[*#\s_~`>-])*COMMENT\b/iu', $this->plainBlock($block));
}
private function isPolicyRecordBlock(string $block): bool
{
return (bool) preg_match('/^\s*(?:NSAM|NSDM|NSDD|NSD|PD)\s+\d+/iu', $this->plainBlock($block));
}
private function isNoiseBlock(string $block): bool
{
$plain = strtoupper($this->plainBlock($block));
$plain = preg_replace('/\s+/u', ' ', $plain) ?? $plain;
if ($plain === '') {
return true;
}
if (preg_match('/^\d{1,6}$/', $plain)) {
return true;
}
$patterns = [
'/^(?:# )?UNCLASSIFIED$/',
'/^TOP SECRET$/',
'/^UNCLASSIFIED WITH TOP SECRET ATTACHMENTS$/',
'/^DECLASSIFY ON: OADR$/',
'/^\*? ?UNCLASSIFIED\*?$/',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $plain)) {
return true;
}
}
return false;
}
private function cleanEmbeddingText(string $text): string
{
$lines = preg_split('/\R/u', $text);
if ($lines === false) {
return $this->cleanMarkdownBlock($text);
}
$kept = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $this->isNoiseLine($line)) {
continue;
}
$kept[] = $line;
}
return $this->cleanMarkdownBlock(implode("\n", $kept));
}
private function isNoiseLine(string $line): bool
{
$plain = strtoupper($this->plainBlock($line));
$plain = preg_replace('/\s+/u', ' ', $plain) ?? $plain;
if ($plain === '' || preg_match('/^\d{1,6}$/', $plain)) {
return true;
}
$patterns = [
'/^(?:# )?UNCLASSIFIED$/',
'/^TOP SECRET$/',
'/^UNCLASSIFIED WITH TOP SECRET ATTACHMENTS$/',
'/^DECLASSIFY ON: OADR$/',
'/^PARTIALLY DECLASSIFIED\/RELEASED ON .+$/',
'/^UNDER PROVISIONS OF .+$/',
'/^BY .+ NATIONAL SECURITY COUNCIL$/',
'/^F \d{2}-\d+$/',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $plain)) {
return true;
}
}
return false;
}
private function plainBlock(string $block): string
{
$block = str_replace(['*', '_', '`', '~'], '', $block);
$block = preg_replace('/\s+/u', ' ', $block) ?? $block;
return trim($block);
}
private function inferTitle(string $markdown, string $source): string
{
if (preg_match_all('/^\s*#+\s*(.+?)\s*$/imu', $markdown, $matches)) {
foreach ($matches[1] as $heading) {
$heading = $this->clean($heading);
if (!preg_match('/^Page\s+\S+$/iu', $heading) && !$this->isNoiseLine($heading)) {
return $heading;
}
}
}
if ($source !== '') {
return pathinfo($source, PATHINFO_FILENAME) ?: $source;
}
return 'Untitled Markdown Import';
}
private function pageBlocksFromItems(array $payload, array $items): array
{
$pageBlocks = [];
foreach ($items as $index => $item) {
$content = is_array($item) ? ($item['content'] ?? '') : $item;
$pageNumber = is_array($item) && $this->hasPageNumber($item) ? $this->pageNumber($item) : null;
$metadata = is_array($item) ? ($item['metadata'] ?? []) : [];
$pageBlock = $this->pageBlock($payload, $content, count($pageBlocks), $index, $pageNumber, $metadata);
if ($pageBlock !== null) {
$pageBlocks[] = $pageBlock;
}
}
return $pageBlocks;
}
private function pageBlock(
array $payload,
mixed $content,
int $blockIndex,
int $sourceIndex,
int|string|null $pageNumber,
array $metadata
): ?array {
$content = $this->clean((string) $content);
if ($content === '') {
return null;
}
return [
'block_uid' => $this->uid('block', implode('|', [
$payload['source'],
$payload['title'],
(string) $pageNumber,
$sourceIndex,
$content,
])),
'index' => $blockIndex,
'page_number' => $pageNumber,
'content' => $content,
'metadata' => $metadata,
];
}
private function chunkLongUnit(string $text, int $chunkSize, int $chunkOverlap): array
{
if (mb_strlen($text) <= $chunkSize) {
return [$text];
}
$chunks = [];
$current = '';
foreach ($this->semanticUnits($text) as $unit) {
if (mb_strlen($unit) > $chunkSize) {
if ($current !== '') {
$chunks[] = $current;
$current = '';
}
array_push($chunks, ...$this->hardChunk($unit, $chunkSize, $chunkOverlap));
continue;
}
$candidate = $current === '' ? $unit : $current . ' ' . $unit;
if (mb_strlen($candidate) <= $chunkSize) {
$current = $candidate;
continue;
}
if ($current !== '') {
$chunks[] = $current;
}
$current = $unit;
}
if ($current !== '') {
$chunks[] = $current;
}
return $chunks === [] ? [$text] : $chunks;
}
private function semanticUnits(string $text): array
{
$units = preg_split(self::SENTENCE_BOUNDARY_PATTERN, $text, -1, PREG_SPLIT_NO_EMPTY);
if ($units === false || $units === []) {
return [$text];
}
return array_values(array_filter(array_map(fn (string $unit): string => $this->clean($unit), $units)));
}
private function hardChunk(string $text, int $chunkSize, int $chunkOverlap): array
{
$length = mb_strlen($text);
if ($length <= $chunkSize) {
return [$text];
}
$chunks = [];
$start = 0;
while ($start < $length) {
$chunk = mb_substr($text, $start, $chunkSize);
if ($chunk === '') {
break;
}
$chunks[] = $chunk;
if ($start + $chunkSize >= $length) {
break;
}
$start += $chunkSize - $chunkOverlap;
}
return $chunks;
}
private function hasPageNumber(array $item): bool
{
return array_key_exists('page_number', $item)
|| array_key_exists('page', $item)
|| array_key_exists('number', $item);
}
private function pageNumber(array $item): int|string
{
$pageNumber = $item['page_number'] ?? $item['page'] ?? $item['number'];
return is_int($pageNumber) ? $pageNumber : $this->clean((string) $pageNumber);
}
private function restorePageNumber(string $pageNumber): int|string|null
{
if ($pageNumber === '') {
return null;
}
return ctype_digit($pageNumber) ? (int) $pageNumber : $pageNumber;
}
private function intOption(array $payload, string $key, int $default): int
{
if (!isset($payload[$key]) || $payload[$key] === '') {
return $default;
}
return (int) $payload[$key];
}
private function clean(string $value): string
{
return trim(preg_replace('/[ \t]+/u', ' ', $value) ?? $value);
}
private function nullableClean(mixed $value): ?string
{
if (!is_string($value)) {
return null;
}
$value = $this->clean($value);
return $value === '' ? null : $value;
}
private function tagsFromString(string $value): array
{
$value = trim($value);
if ($value === '') {
return [];
}
$decoded = json_decode($value, true);
if (is_array($decoded)) {
return array_values(array_filter(array_map('strval', $decoded)));
}
return array_values(array_filter(array_map('trim', preg_split('/[,]/u', $value) ?: [])));
}
private function archiveUid(array $payload): string
{
if (isset($payload['archive_uid']) && is_string($payload['archive_uid']) && $this->isUlid($payload['archive_uid'])) {
return strtoupper($payload['archive_uid']);
}
return (string) new Ulid();
}
private function chunkUid(string $archiveUid, int $chunkIndex, string $value): string
{
return $archiveUid . '_' . $chunkIndex . '_' . $this->shortUid($value);
}
private function shortUid(string $value): string
{
$number = hexdec(substr(hash('crc32b', $value), 0, 8)) % 100000;
return str_pad((string) $number, 5, '0', STR_PAD_LEFT);
}
private function isUlid(string $value): bool
{
return (bool) preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/', strtoupper($value));
}
private function uid(string $prefix, string $value): string
{
return $prefix . '_' . substr(hash('sha256', $value), 0, 24);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace app\service\LLM;
use RuntimeException;
class LLMRequestException extends RuntimeException
{
public function __construct(
string $message,
private readonly ?int $statusCode = null,
private readonly ?string $providerCode = null,
private readonly ?array $payload = null
) {
parent::__construct($message, $statusCode ?? 0);
}
public function statusCode(): ?int
{
return $this->statusCode;
}
public function providerCode(): ?string
{
return $this->providerCode;
}
public function payload(): ?array
{
return $this->payload;
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace app\service\LLM;
use Throwable;
class LLMRetryQueue
{
public function run(callable $job, array $config = []): mixed
{
$enabled = (bool) ($config['enabled'] ?? true);
$maxAttempts = max(1, (int) ($config['max_attempts'] ?? 3));
$baseDelayMs = max(0, (int) ($config['base_delay_ms'] ?? 1500));
$maxDelayMs = max($baseDelayMs, (int) ($config['max_delay_ms'] ?? 10000));
$retryStatuses = array_map('intval', $config['retry_statuses'] ?? [429]);
$retryErrorCodes = array_map('strval', $config['retry_error_codes'] ?? ['1302', '1303', '1304', '1305', '1306', '1307', '1308']);
$attempt = 0;
while (true) {
$attempt++;
try {
return $job($attempt);
} catch (Throwable $exception) {
if (!$enabled || $attempt >= $maxAttempts || !$this->shouldRetry($exception, $retryStatuses, $retryErrorCodes)) {
throw $exception;
}
usleep($this->delayMs($attempt, $baseDelayMs, $maxDelayMs) * 1000);
}
}
}
private function shouldRetry(Throwable $exception, array $retryStatuses, array $retryErrorCodes): bool
{
if (!$exception instanceof LLMRequestException) {
return false;
}
if ($exception->statusCode() !== null && in_array($exception->statusCode(), $retryStatuses, true)) {
return true;
}
return $exception->providerCode() !== null && in_array($exception->providerCode(), $retryErrorCodes, true);
}
private function delayMs(int $attempt, int $baseDelayMs, int $maxDelayMs): int
{
$delay = min($maxDelayMs, $baseDelayMs * (2 ** max(0, $attempt - 1)));
$jitter = $delay > 0 ? random_int(0, min(500, $delay)) : 0;
return $delay + $jitter;
}
}

View File

@ -0,0 +1,152 @@
<?php
namespace app\service\LLM;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use RuntimeException;
use Throwable;
class OpenAICompatibleClient
{
private Client $client;
private array $config;
public function __construct(?array $config = null)
{
$this->config = $config ?? config('LLMapi.default', []);
$baseUrl = rtrim((string) ($this->config['base_url'] ?? ''), '/');
$this->client = new Client([
'base_uri' => $baseUrl . '/',
'timeout' => (int) ($this->config['timeout'] ?? 60),
'connect_timeout' => (int) ($this->config['connect_timeout'] ?? 10),
]);
}
public function isConfigured(): bool
{
return trim((string) ($this->config['api_key'] ?? '')) !== ''
&& trim((string) ($this->config['base_url'] ?? '')) !== '';
}
public function chatJson(array $messages, array $options = []): array
{
$content = $this->chat($messages, $options);
$decoded = json_decode($this->extractJson($content), true);
if (!is_array($decoded)) {
throw new RuntimeException('LLM response is not valid JSON.');
}
return $decoded;
}
public function chat(array $messages, array $options = []): string
{
if (!$this->isConfigured()) {
throw new RuntimeException('LLM API is not configured.');
}
$headers = [
'Authorization' => 'Bearer ' . $this->config['api_key'],
'Content-Type' => 'application/json',
];
if (!empty($this->config['organization'])) {
$headers['OpenAI-Organization'] = $this->config['organization'];
}
if (!empty($this->config['project'])) {
$headers['OpenAI-Project'] = $this->config['project'];
}
$body = [
'model' => $options['model'] ?? config('LLMapi.chat.model'),
'messages' => $messages,
'temperature' => $options['temperature'] ?? config('LLMapi.chat.temperature', 0.2),
'max_tokens' => $options['max_tokens'] ?? config('LLMapi.chat.max_tokens', 1200),
'stream' => (bool) ($options['stream'] ?? config('LLMapi.chat.stream', false)),
];
if (array_key_exists('response_format', $options) && is_array($options['response_format'])) {
$body['response_format'] = $options['response_format'];
}
if (array_key_exists('thinking', $options) && is_array($options['thinking'])) {
$body['thinking'] = $options['thinking'];
}
if (array_key_exists('request_id', $options) && is_string($options['request_id'])) {
$body['request_id'] = $options['request_id'];
}
if (array_key_exists('user_id', $options) && is_string($options['user_id'])) {
$body['user_id'] = $options['user_id'];
}
try {
$response = $this->client->post('chat/completions', [
'headers' => $headers,
'json' => $body,
]);
} catch (RequestException $exception) {
throw $this->requestException($exception);
} catch (Throwable $exception) {
throw new RuntimeException('LLM chat request failed: ' . $exception->getMessage(), 0, $exception);
}
$payload = json_decode((string) $response->getBody(), true);
$content = $payload['choices'][0]['message']['content'] ?? null;
if (!is_string($content) || trim($content) === '') {
throw new RuntimeException('LLM chat response is empty.');
}
return $content;
}
private function extractJson(string $content): string
{
$content = trim($content);
$content = preg_replace('/^```(?:json)?\s*/i', '', $content) ?? $content;
$content = preg_replace('/\s*```$/', '', $content) ?? $content;
$start = strpos($content, '{');
$end = strrpos($content, '}');
if ($start !== false && $end !== false && $end > $start) {
return substr($content, $start, $end - $start + 1);
}
return $content;
}
private function requestException(RequestException $exception): LLMRequestException
{
$statusCode = $exception->getResponse()?->getStatusCode();
$body = $exception->getResponse() ? (string) $exception->getResponse()->getBody() : '';
$payload = json_decode($body, true);
$providerCode = null;
$providerMessage = null;
if (is_array($payload)) {
$providerCode = isset($payload['error']['code']) ? (string) $payload['error']['code'] : null;
$providerMessage = isset($payload['error']['message']) ? (string) $payload['error']['message'] : null;
}
$message = 'LLM chat request failed';
if ($statusCode !== null) {
$message .= " with HTTP {$statusCode}";
}
if ($providerCode !== null) {
$message .= " and provider code {$providerCode}";
}
if ($providerMessage !== null) {
$message .= ": {$providerMessage}";
} else {
$message .= ': ' . $exception->getMessage();
}
return new LLMRequestException($message, $statusCode, $providerCode, is_array($payload) ? $payload : null);
}
}

14
app/view/index/view.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/favicon.ico"/>
<title>webman</title>
</head>
<body>
hello <?=htmlspecialchars($name)?>
</body>
</html>

29
ark.txt Normal file
View File

@ -0,0 +1,29 @@
archives
- id 主键、自增
- archive_uid 非空
- title 主题
- summary 档案摘要
- year 年份
- author 作者
- source 来源
- series 所属系列集
- tags JSON
- metadata JSON
- content OCR识别全文存放地址
- raw 原始pdf/图片存放地址
- chunks JSON
- created_time 入库时间
- updated_time 修改时间
chunks
- id 主键、自增
- chunk_uid 非空
- archive_uid 所属档案ID
- chunk_index chunk序号
- page_start
- page_end
- text chunk文本
- length
- embedding_status INT 0=none, 1=OpenSearch
- embedding_ref JSON
- embedding_model TEXT 格式例如 fastembedding/1.0

71
composer.json Normal file
View File

@ -0,0 +1,71 @@
{
"name": "workerman/webman",
"type": "project",
"keywords": [
"high performance",
"http service"
],
"homepage": "https://www.workerman.net",
"license": "MIT",
"description": "High performance HTTP Service Framework.",
"authors": [
{
"name": "walkor",
"email": "walkor@workerman.net",
"homepage": "https://www.workerman.net",
"role": "Developer"
}
],
"support": {
"email": "walkor@workerman.net",
"issues": "https://github.com/walkor/webman/issues",
"forum": "https://wenda.workerman.net/",
"wiki": "https://workerman.net/doc/webman",
"source": "https://github.com/walkor/webman"
},
"require": {
"php": ">=8.1",
"workerman/webman-framework": "^2.1",
"monolog/monolog": "^2.0",
"webman/console": "^2.2",
"webman/database": "^2.1",
"illuminate/pagination": "^13.7",
"illuminate/events": "^13.7",
"symfony/var-dumper": "^8.0",
"webman/redis": "^2.1",
"webman/validation": "^2.2",
"symfony/uid": "^8.0",
"vlucas/phpdotenv": "^5.6",
"guzzlehttp/guzzle": "^7.10"
},
"suggest": {
"ext-event": "For better performance. "
},
"autoload": {
"psr-4": {
"": "./",
"app\\": "./app",
"App\\": "./app",
"app\\View\\Components\\": "./app/view/components"
}
},
"scripts": {
"post-package-install": [
"support\\Plugin::install"
],
"post-package-update": [
"support\\Plugin::install"
],
"pre-package-uninstall": [
"support\\Plugin::uninstall"
],
"post-create-project-cmd": [
"support\\Setup::run"
],
"setup-webman": [
"support\\Setup::run"
]
},
"minimum-stability": "dev",
"prefer-stable": true
}

5398
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

57
config/LLMapi.php Normal file
View File

@ -0,0 +1,57 @@
<?php
return [
/*
* OpenAI-compatible API configuration.
*
* Environment variables are optional, but recommended in deployment:
* - LLM_API_BASE_URL
* - LLM_API_KEY
* - LLM_CHAT_MODEL
* - LLM_EMBEDDING_MODEL
*/
'default' => [
'base_url' => getenv('LLM_API_BASE_URL') ?: 'https://api.openai.com/v1',
'api_key' => getenv('LLM_API_KEY') ?: '',
'organization' => getenv('LLM_ORGANIZATION') ?: null,
'project' => getenv('LLM_PROJECT') ?: null,
'timeout' => (int) (getenv('LLM_API_TIMEOUT') ?: 60),
'connect_timeout' => (int) (getenv('LLM_API_CONNECT_TIMEOUT') ?: 10),
],
'chat' => [
'model' => getenv('LLM_CHAT_MODEL') ?: 'gpt-4.1-mini',
'temperature' => (float) (getenv('LLM_CHAT_TEMPERATURE') ?: 0.2),
'max_tokens' => (int) (getenv('LLM_CHAT_MAX_TOKENS') ?: 1200),
'stream' => false,
],
'metadata' => [
'enabled' => (getenv('LLM_METADATA_ENABLED') ?: 'true') !== 'false',
'model' => getenv('LLM_METADATA_MODEL') ?: (getenv('LLM_CHAT_MODEL') ?: 'gpt-4.1-mini'),
'max_input_chars' => (int) (getenv('LLM_METADATA_MAX_INPUT_CHARS') ?: 12000),
'max_tokens' => (int) (getenv('LLM_METADATA_MAX_TOKENS') ?: 1200),
'temperature' => (float) (getenv('LLM_METADATA_TEMPERATURE') ?: 0.1),
'stream' => false,
'response_format' => ['type' => 'json_object'],
'thinking' => [
'type' => getenv('LLM_METADATA_THINKING') ?: 'disabled',
],
'retry' => [
'enabled' => (getenv('LLM_METADATA_RETRY_ENABLED') ?: 'true') !== 'false',
'max_attempts' => (int) (getenv('LLM_METADATA_RETRY_MAX_ATTEMPTS') ?: 3),
'base_delay_ms' => (int) (getenv('LLM_METADATA_RETRY_BASE_DELAY_MS') ?: 1500),
'max_delay_ms' => (int) (getenv('LLM_METADATA_RETRY_MAX_DELAY_MS') ?: 10000),
'retry_statuses' => [429],
'retry_error_codes' => ['1302', '1303', '1304', '1305', '1306', '1307', '1308'],
],
],
'embedding' => [
'model' => getenv('LLM_EMBEDDING_MODEL') ?: 'text-embedding-3-small',
'batch_size' => (int) (getenv('LLM_EMBEDDING_BATCH_SIZE') ?: 64),
'dimensions' => getenv('LLM_EMBEDDING_DIMENSIONS') !== false
? (int) getenv('LLM_EMBEDDING_DIMENSIONS')
: null,
],
];

26
config/app.php Normal file
View File

@ -0,0 +1,26 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use support\Request;
return [
'debug' => true,
'error_reporting' => E_ALL,
'default_timezone' => 'Asia/Shanghai',
'request_class' => Request::class,
'public_path' => base_path() . DIRECTORY_SEPARATOR . 'public',
'runtime_path' => base_path(false) . DIRECTORY_SEPARATOR . 'runtime',
'controller_suffix' => 'Controller',
'controller_reuse' => false,
];

21
config/autoload.php Normal file
View File

@ -0,0 +1,21 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'files' => [
base_path() . '/app/functions.php',
base_path() . '/support/Request.php',
base_path() . '/support/Response.php',
]
];

17
config/bootstrap.php Normal file
View File

@ -0,0 +1,17 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
support\bootstrap\Session::class,
];

15
config/container.php Normal file
View File

@ -0,0 +1,15 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return new Webman\Container;

25
config/database.php Normal file
View File

@ -0,0 +1,25 @@
<?php
return [
'default' => 'postgre',
'connections' => [
'postgre' => [
'driver' => 'pgsql',
'host' => 'layfi.postgres.database.azure.com',
'port' => 5432,
'database' => 'proofdb',
'username' => 'proofdb',
'password' => 'proofdb',
'charset' => 'utf8',
'prefix' => '',
'schema' => 'public',
'sslmode' => 'prefer',
'pool' => [ // 连接池配置仅支持swoole/swow驱动
'max_connections' => 5, // 最大连接数
'min_connections' => 1, // 最小连接数
'wait_timeout' => 3, // 从连接池获取连接等待的最大时间,超时后会抛出异常
'idle_timeout' => 60, // 连接池中连接最大空闲时间超时后会关闭回收直到连接数为min_connections
'heartbeat_interval' => 50, // 连接池心跳检测时间单位秒建议小于60秒
],
],
],
];

15
config/dependence.php Normal file
View File

@ -0,0 +1,15 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [];

17
config/exception.php Normal file
View File

@ -0,0 +1,17 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'' => support\exception\Handler::class,
];

32
config/log.php Normal file
View File

@ -0,0 +1,32 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'default' => [
'handlers' => [
[
'class' => Monolog\Handler\RotatingFileHandler::class,
'constructor' => [
runtime_path() . '/logs/webman.log',
7, //$maxFiles
Monolog\Logger::DEBUG,
],
'formatter' => [
'class' => Monolog\Formatter\LineFormatter::class,
'constructor' => [null, 'Y-m-d H:i:s', true],
],
]
],
],
];

15
config/middleware.php Normal file
View File

@ -0,0 +1,15 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [];

View File

@ -0,0 +1,28 @@
<?php
return [
'enable' => true,
'build_dir' => BASE_PATH . DIRECTORY_SEPARATOR . 'build',
'phar_filename' => 'webman.phar',
'phar_format' => Phar::PHAR, // Phar archive format: Phar::PHAR, Phar::TAR, Phar::ZIP
'phar_compression' => Phar::NONE, // Compression method for Phar archive: Phar::NONE, Phar::GZ, Phar::BZ2
'bin_filename' => 'webman.bin',
'signature_algorithm'=> Phar::SHA256, //set the signature algorithm for a phar and apply it. The signature algorithm must be one of Phar::MD5, Phar::SHA1, Phar::SHA256, Phar::SHA512, or Phar::OPENSSL.
'private_key_file' => '', // The file path for certificate or OpenSSL private key file.
'exclude_pattern' => '#^(?!.*(composer.json|/.github/|/.idea/|/.git/|/.setting/|/runtime/|/vendor-bin/|/build/|/vendor/webman/admin/))(.*)$#',
'exclude_files' => [
'.env', 'LICENSE', 'composer.json', 'composer.lock', 'start.php', 'webman.phar', 'webman.bin'
],
'custom_ini' => '
memory_limit = 256M
',
];

View File

@ -0,0 +1,8 @@
<?php
use support\validation\ValidationException;
return [
'enable' => true,
'exception' => ValidationException::class,
];

View File

@ -0,0 +1,7 @@
<?php
use Webman\Validation\Command\MakeValidatorCommand;
return [
MakeValidatorCommand::class
];

View File

@ -0,0 +1,9 @@
<?php
use Webman\Validation\Middleware;
return [
'@' => [
Middleware::class,
],
];

68
config/process.php Normal file
View File

@ -0,0 +1,68 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use support\Log;
use support\Request;
use app\process\Http;
global $argv;
return [
'webman' => [
'handler' => Http::class,
'listen' => 'http://0.0.0.0:8787',
'count' => cpu_count() * 4,
'user' => '',
'group' => '',
'reusePort' => false,
'eventLoop' => '',
'context' => [],
'constructor' => [
'requestClass' => Request::class,
'logger' => Log::channel('default'),
'appPath' => app_path(),
'publicPath' => public_path()
]
],
// File update detection and automatic reload
'monitor' => [
'handler' => app\process\Monitor::class,
'reloadable' => false,
'constructor' => [
// Monitor these directories
'monitorDir' => array_merge([
app_path(),
config_path(),
base_path() . '/process',
base_path() . '/support',
base_path() . '/resource',
base_path() . '/.env',
], glob(base_path() . '/plugin/*/app'), glob(base_path() . '/plugin/*/config'), glob(base_path() . '/plugin/*/api')),
// Files with these suffixes will be monitored
'monitorExtensions' => [
'php', 'html', 'htm', 'env'
],
'options' => [
'enable_file_monitor' => !in_array('-d', $argv) && DIRECTORY_SEPARATOR === '/',
'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/',
]
]
],
'ai_metadata' => [
'handler' => app\process\AiMetadata::class,
'count' => 1,
'reloadable' => true,
'constructor' => []
]
];

14
config/queue.php Normal file
View File

@ -0,0 +1,14 @@
<?php
return [
'ai_metadata' => [
'pending' => 'proofdb:ai:metadata:pending',
'delayed' => 'proofdb:ai:metadata:delayed',
'failed' => 'proofdb:ai:metadata:failed',
'retry_prefix' => 'proofdb:ai:metadata:retry:',
'error_prefix' => 'proofdb:ai:metadata:error:',
'max_retries' => (int) (getenv('AI_METADATA_QUEUE_MAX_RETRIES') ?: 5),
'base_delay_seconds' => (int) (getenv('AI_METADATA_QUEUE_BASE_DELAY_SECONDS') ?: 60),
'block_timeout' => (int) (getenv('AI_METADATA_QUEUE_BLOCK_TIMEOUT') ?: 5),
],
];

29
config/redis.php Normal file
View File

@ -0,0 +1,29 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'default' => [
'password' => 'qi2005112',
'host' => '127.0.0.1',
'port' => 6379,
'database' => 0,
'pool' => [
'max_connections' => 5,
'min_connections' => 1,
'wait_timeout' => 3,
'idle_timeout' => 60,
'heartbeat_interval' => 50,
],
]
];

21
config/route.php Normal file
View File

@ -0,0 +1,21 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use Webman\Route;
Route::post('/api/articles/import', [app\controller\Api\ArticleImportController::class, 'import']);

23
config/server.php Normal file
View File

@ -0,0 +1,23 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'event_loop' => '',
'stop_timeout' => 2,
'pid_file' => runtime_path() . '/webman.pid',
'status_file' => runtime_path() . '/webman.status',
'stdout_file' => runtime_path() . '/logs/stdout.log',
'log_file' => runtime_path() . '/logs/workerman.log',
'max_package_size' => 10 * 1024 * 1024
];

65
config/session.php Normal file
View File

@ -0,0 +1,65 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use Webman\Session\FileSessionHandler;
use Webman\Session\RedisSessionHandler;
use Webman\Session\RedisClusterSessionHandler;
return [
'type' => 'file', // or redis or redis_cluster
'handler' => FileSessionHandler::class,
'config' => [
'file' => [
'save_path' => runtime_path() . '/sessions',
],
'redis' => [
'host' => '127.0.0.1',
'port' => 6379,
'auth' => '',
'timeout' => 2,
'database' => '',
'prefix' => 'redis_session_',
],
'redis_cluster' => [
'host' => ['127.0.0.1:7000', '127.0.0.1:7001', '127.0.0.1:7001'],
'timeout' => 2,
'auth' => '',
'prefix' => 'redis_session_',
]
],
'session_name' => 'PHPSID',
'auto_update_timestamp' => false,
'lifetime' => 7*24*60*60,
'cookie_lifetime' => 365*24*60*60,
'cookie_path' => '/',
'domain' => '',
'http_only' => true,
'secure' => false,
'same_site' => '',
'gc_probability' => [1, 1000],
];

23
config/static.php Normal file
View File

@ -0,0 +1,23 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
/**
* Static file settings
*/
return [
'enable' => true,
'middleware' => [ // Static file Middleware
//app\middleware\StaticFile::class,
],
];

25
config/translation.php Normal file
View File

@ -0,0 +1,25 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
/**
* Multilingual configuration
*/
return [
// Default language
'locale' => 'zh_CN',
// Fallback language
'fallback_locale' => ['zh_CN', 'en'],
// Folder where language files are stored
'path' => base_path() . '/resource/translations',
];

22
config/view.php Normal file
View File

@ -0,0 +1,22 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use support\view\Raw;
use support\view\Twig;
use support\view\Blade;
use support\view\ThinkPHP;
return [
'handler' => Raw::class
];

11
docker-compose.yml Normal file
View File

@ -0,0 +1,11 @@
version: "3"
services:
webman:
build: .
container_name: docker-webman
restart: unless-stopped
volumes:
- "./:/app"
ports:
- "8787:8787"
command: ["php", "start.php", "start" ]

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

214
readme.md Normal file
View File

@ -0,0 +1,214 @@
## 1. Project Overview
**Project Name:** Proof DB
**Type:** Historical Evidence Retrieval System (RAG-oriented backend)
This project is a **backend-centric system** designed to manage, index, and retrieve historical evidence (documents, archives, OCR text) with strong emphasis on:
* Evidence traceability
* Chunk-level retrieval
* Hybrid search (full-text + vector)
* Citation reconstruction
Unlike generic RAG systems, this project treats **"evidence" as first-class structured objects**, not just text.
---
## 2. Core Concept
The system is divided into three conceptual layers:
* **Proof DB** → Data layer (MySQL + OpenSearch + Vector)
* **Archive Cask** → Frontend interface (not part of this task)
* **Few-shot Engine** → OCR (external, not part of this task)
Current scope: **Proof DB only**
---
## 3. System Architecture (Backend Focus)
The backend follows a **modular service architecture** (not microservices yet, but clearly separated layers):
### Components:
1. **Ingestion Layer**
* Accepts raw Markdown archive documents
* Pre-processes Markdown page markers such as `<!-- DOCMASTER:PAGE 0001 -->`
* Splits documents into page-bounded vector chunks
* Keeps list-style archive records and their `COMMENT` blocks together where possible
* Extracts metadata, including page numbers
* Enqueues missing archive metadata such as title, year, author, tags, and summary for async LLM enrichment
2. **Storage Layer**
* MySQL → metadata, relations
* OpenSearch → full-text index
* Vector DB → embeddings (can be OpenSearch kNN or Qdrant)
3. **Retrieval Layer**
* Full-text search (BM25)
* Vector search (embedding similarity)
* Hybrid search (fusion)
4. **Evidence Layer**
* Maps chunk → page → article
* Provides page-level citation traceability
👉 这是典型 backend architecture 分层设计server + database + API协同 ([DEV Community][1])
---
## 4. Tech Stack
### Backend Framework
* PHP 8+
* Webman (HTTP API)
* Workerman (async workers / background jobs)
### Database
* MySQL (relational metadata)
### Search Engine
* OpenSearch
* Full-text search (BM25)
* Optional vector search (kNN)
### Vector Layer
* Option A: OpenSearch kNN
* Option B: Qdrant (preferred if scaling)
### Data Flow Tools
* Custom chunking logic (PHP)
* Embedding via external API / local model
* Metadata enrichment via Redis queue + OpenAI-compatible chat completion API
---
## 5. Data Model (CRITICAL)
### Core Entities
```text
Archive
├── archive_uid (ULID)
├── title
├── summary
├── source
└── metadata
Page
├── page_number
├── block_count
├── chunk_count
└── content_length
PageBlock (internal import structure)
├── block_uid
├── archive_uid
├── page_number
└── content
Chunk
├── chunk_uid (archive_uid + sequence + short uid)
├── page_start
├── page_end
├── text
├── embedding_ref
```
### Key Principle
* **archive_uid 是档案级核心 ID使用 ULID**
* **chunk_uid 是 chunk 级核心 ID格式为 `{archive_uid}_{chunk_index}_{short_uid}`**
* MySQL / OpenSearch / Vector DB 全部围绕 `archive_uid``chunk_uid`
* **page_number 是证据定位的关键字段**
* Chunk 是向量化和检索召回单位,不是精确 citation 单位
* 证据定位只需要定位到页码,因此 chunk 可以跨段落合并,但不能跨页
---
## 6. Search Design
### Full-text (OpenSearch)
* Indexed at chunk level
* Supports:
* keyword match
* phrase match
### Vector Search
* embedding similarity
### Hybrid Search
* BM25 + vector fusion
* rerank stage
---
## 7. API Design (First Phase)
### Ingestion
```http
POST /api/articles/import
```
---
### Retrieval
```http
POST /api/search/fulltext
POST /api/search/vector
POST /api/search/hybrid
```
---
### Evidence
```http
GET /api/chunks/{chunk_uid}
GET /api/evidence/{chunk_uid}
```
---
## 8. Design Philosophy (IMPORTANT)
* Evidence > Text
* Chunk > Document
* Traceability > Raw Retrieval
* Hybrid Search by default
---
## 9. Non-goals (IMPORTANT)
* No frontend (Archive Cask handled later)
* No OCR (Few-shot Engine external)
* No heavy microservices (keep simple modular architecture first)
* Proof DB ≠ storage
* Proof DB = retrieval + meaning + traceability
[1]: https://dev.to/tomjohnson3/understanding-backend-architecture-ljb?utm_source=chatgpt.com "Understanding Backend Architecture"
[2]: https://exodata.io/what-is-a-tech-stack-how-to-architect-a-modern-scalable-technology-stack/?utm_source=chatgpt.com "How to Build a Tech Stack That Scales [2026] | Exodata"
[3]: https://medium.com/%40hanxuyang0826/roadmap-to-backend-programming-master-architectural-patterns-c763c9194414?utm_source=chatgpt.com "Roadmap to Backend Programming Master: Architectural ..."

4
runtime/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*
!logs
!views
!.gitignore

2
runtime/logs/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

2
runtime/views/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -0,0 +1,89 @@
#!/usr/bin/env php
<?php
use support\Db;
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../support/bootstrap.php';
require __DIR__ . '/../vendor/webman/database/src/support/Db.php';
$statements = [
<<<SQL
CREATE TABLE IF NOT EXISTS archives (
id BIGSERIAL PRIMARY KEY,
archive_uid VARCHAR(26) NOT NULL UNIQUE,
title TEXT,
summary TEXT,
year INTEGER,
author TEXT,
source TEXT,
series TEXT,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
content TEXT,
raw TEXT,
chunks JSONB NOT NULL DEFAULT '[]'::jsonb,
created_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
SQL,
'ALTER TABLE archives ADD COLUMN IF NOT EXISTS summary TEXT',
<<<SQL
CREATE TABLE IF NOT EXISTS chunks (
id BIGSERIAL PRIMARY KEY,
chunk_uid VARCHAR(64) NOT NULL UNIQUE,
archive_uid VARCHAR(26) NOT NULL,
chunk_index INTEGER NOT NULL,
page_start INTEGER,
page_end INTEGER,
text TEXT,
length INTEGER,
embedding_status INTEGER NOT NULL DEFAULT 0,
embedding_ref JSONB,
embedding_model TEXT,
CONSTRAINT chunks_archive_uid_foreign
FOREIGN KEY (archive_uid)
REFERENCES archives (archive_uid)
ON DELETE CASCADE,
CONSTRAINT chunks_archive_index_unique
UNIQUE (archive_uid, chunk_index)
)
SQL,
'CREATE INDEX IF NOT EXISTS archives_year_index ON archives (year)',
'CREATE INDEX IF NOT EXISTS archives_series_index ON archives (series)',
'CREATE INDEX IF NOT EXISTS archives_tags_gin_index ON archives USING GIN (tags)',
'CREATE INDEX IF NOT EXISTS archives_metadata_gin_index ON archives USING GIN (metadata)',
'CREATE INDEX IF NOT EXISTS chunks_archive_uid_index ON chunks (archive_uid)',
'CREATE INDEX IF NOT EXISTS chunks_page_range_index ON chunks (archive_uid, page_start, page_end)',
'CREATE INDEX IF NOT EXISTS chunks_embedding_status_index ON chunks (embedding_status)',
<<<SQL
CREATE OR REPLACE FUNCTION set_updated_time()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_time = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
SQL,
'DROP TRIGGER IF EXISTS archives_set_updated_time ON archives',
<<<SQL
CREATE TRIGGER archives_set_updated_time
BEFORE UPDATE ON archives
FOR EACH ROW
EXECUTE FUNCTION set_updated_time()
SQL,
];
try {
Db::connection()->getPdo();
echo 'Database connection ok: ' . config('database.default') . PHP_EOL;
foreach ($statements as $statement) {
Db::statement($statement);
}
echo 'Tables initialized: archives, chunks' . PHP_EOL;
} catch (Throwable $exception) {
fwrite(STDERR, $exception::class . ': ' . $exception->getMessage() . PHP_EOL);
exit(1);
}

5
start.php Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env php
<?php
chdir(__DIR__);
require_once __DIR__ . '/vendor/autoload.php';
support\App::run();

24
support/Request.php Normal file
View File

@ -0,0 +1,24 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace support;
/**
* Class Request
* @package support
*/
class Request extends \Webman\Http\Request
{
}

24
support/Response.php Normal file
View File

@ -0,0 +1,24 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace support;
/**
* Class Response
* @package support
*/
class Response extends \Webman\Http\Response
{
}

1558
support/Setup.php Normal file

File diff suppressed because it is too large Load Diff

3
support/bootstrap.php Normal file
View File

@ -0,0 +1,3 @@
<?php
require_once __DIR__ . '/../vendor/workerman/webman-framework/src/support/bootstrap.php';

329
test/1.test.md Normal file
View File

@ -0,0 +1,329 @@
<!-- DOCMASTER:PAGE 0001 -->
## Page 1
UNCLASSIFIED with ~~TOP SECRET~~ Attachments
* *UNCLASSIFIED**
21220
THE WHITE HOUSE
WASHINGTON
January 3, 1992
NATIONAL SECURITY DIRECTIVE 76
MEMORANDUM FOR THE VICE PRESIDENT
THE SECRETARY OF STATE
THE SECRETARY OF THE TREASURY
THE SECRETARY OF DEFENSE
THE ATTORNEY GENERAL
THE DIRECTOR, OFFICE OF MANAGEMENT & BUDGET
THE DIRECTOR OF CENTRAL INTELLIGENCE
SUBJECT: Disposition of NSC Policy Documents
After an interagency review of active NSC Policy Documents, I direct that the following disposition be made:
1. NSC Policy Documents listed at Tab A are no longer in force and have been superseded by more recent policy directives.
2. NSC Policy Documents listed at Tab B have been completed and are no longer in force.
This directive supplements NSD 59.
Brent Scowcroft
Attachments
Tab A Listing of Superseded Policy Documents
Tab B Listing of Completed NSC Policy Documents
UNCLASSIFIED with ~~TOP SECRET~~ Attachments
* *UNCLASSIFIED**
Partially Declassified/Released on 10-17-96
under provisions of E.O. 12958
by O. Van Tassel, National Security Council
F 89-191
---
<!-- DOCMASTER:PAGE 0002 -->
## Page 2
~~TOP SECRET~~
# UNCLASSIFIED
21220
NO LONGER IN FORCE AND SUPERSEDED/RESCINDED
* *POLICY ID** &nbsp;&nbsp;&nbsp; **DATE** &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; **TITLE**
* *NSDM 222** &nbsp;&nbsp; 11 JUN 73 &nbsp;&nbsp;&nbsp;&nbsp; COCOM (S)
* *COMMENT** Earlier policy guidelines have been superseded by more recent guidance and have been implemented.
* *NSDD 11** &nbsp;&nbsp;&nbsp; 22 SEP 81 &nbsp;&nbsp;&nbsp;&nbsp; Munitions/Technology Transfer to the People's Republic of China (S)
* *COMMENT** Superseded by Presidential directive after Tiananmen with total ban on such exports still in force.
* *NSDD 138** &nbsp; 03 APR 84 &nbsp;&nbsp;&nbsp;&nbsp; Combatting Terrorism (U)
* *COMMENT** Superseded by NSDD 207.
* *NSD 15** &nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 22 JUN 89 &nbsp;&nbsp;&nbsp;&nbsp; Open Skies (U)
* *COMMENT** Superseded by NSD 73 and Open Skies Treaty.
* *NSD 29** &nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 30 OCT 89 &nbsp;&nbsp;&nbsp;&nbsp; FY 90 Aqueduct Nuclear Test Program (U)
* *COMMENT** Superseded by NSD 68.
* *NSD 31** &nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 14 NOV 89 &nbsp;&nbsp;&nbsp;&nbsp; Change to FY 1989 and FY 1990 Nuclear Weapons Deployment Plan (C)
* *COMMENT** Superseded by NSD 38.
* *NSD 32** &nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 30 NOV 89 &nbsp;&nbsp;&nbsp;&nbsp; Economic Sanctions against Panama (C)
* *COMMENT** Superseded by Operation Just Cause and NSD-34.
* *NSD 50** &nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 12 OCT 90 &nbsp;&nbsp;&nbsp;&nbsp; Decisions on START and CFE Issues (U)
* *COMMENT** Superseded by signed START and CFE treaties.
* *NSD 52** &nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 22 OCT 90 &nbsp;&nbsp;&nbsp;&nbsp; FY 1991 Sculpin Nuclear Test Program (U)
* *COMMENT** Superseded by NSD 68.
~~TOP SECRET~~
Declassify on: OADR
UNCLASSIFIED
---
<!-- DOCMASTER:PAGE 0003 -->
## Page 3
~~TOP SECRET~~
# UNCLASSIFIED
NSD 60 30 MAY 91 FY 1992 Nuclear Test Program - Julin (U)
* *COMMENT** Superseded by NSD 68.
~~TOP SECRET~~
* *UNCLASSIFIED**
---
<!-- DOCMASTER:PAGE 0004 -->
## Page 4
~~TOP SECRET~~
* *UNCLASSIFIED**
21220
* *COMPLETED AND NO LONGER IN FORCE**
* *POLICY ID** **DATE** **TITLE**
NSAM 299 12 MAY 64 Evacuation and Protection of U.S. Citizens in Danger Areas Abroad (C)
* *COMMENT** Responsibilities delegated to Secretaries of State and Defense.
NSDM 262 29 JUN 74 Use of U.S. Bases in Japan in the Event of Aggression against South Korea (C)
NSDM 275 10 OCT 74 COCOM Position on Return of Depleted Uranium Tails from USSR (C)
* *COMMENT** This is negotiating position for negotiations that are long concluded.
PD 9 30 MAR 77 Army Special Operations Field Office in Berlin (C)
* *COMMENT** Operation shut down in August 1991; files sent to Fort Meade; personnel reassigned.
NSDD 50 06 AUG 82 Space Assistance and Cooperation Policy (C)
* *COMMENT** National space policy and non-proliferation directives should guide this policy.
NSDD 52 20 AUG 82 Future Political Status of Micronesia Palau (S)
* *COMMENT** Compact negotiations were concluded.
NSDD 158 09 JAN 85 United States Policy in Southeast Asia - The Kampuchea Problem (U)
* *COMMENT** Largely completed by Cambodia settlement.
NSDD 173 10 JUN 85 Building an Interim Framework for Mutual Restraint (S)
~~TOP SECRET~~
Declassify on: OADR
* *UNCLASSIFIED**
---
<!-- DOCMASTER:PAGE 0005 -->
## Page 5
TOP SECRET
UNCLASSIFIED
2
NSDD 208 30 JAN 86 United States Policy towards the Southwest Indian Ocean (U)
* *COMMENT** Largely irrelevant due to changed circumstances in the region although some aspects remain valid.
NSDD 238 2 SEP 86 Revised National Security Strategy (TS)
NSDD 247 10 OCT 86 Ratification of Existing Treaties Limiting Nuclear Testing (U)
* *COMMENT** TTBT and PNET treaties signed, ratified and in force.
NSDD 251 24 DEC 86 Arms Control Discussions (U)
* *COMMENT** START treaty negotiated, signed and is in force.
NSDD 273 07 MAY 87 United States Policy towards South Africa (C)
* *COMMENT** Largely accomplished or rendered irrelevant by subsequent developments. However underlying policy remains valid but policy tactics and strategies must be updated.
NSDD 319 14 NOV 88 U.S. Policy towards Indochina (U)
* *COMMENT** Largely completed by Cambodia settlement.
NSD 3 13 FEB 89 U.S. Policy towards Afghanistan (C)
* *COMMENT** Afghan situation warrants fresh look given instability.
NSD 4 22 FEB 89 U.S. Policy towards May 7, 1989 Elections in Panama (TS/CO)
NSD 5 18 MAR 89 Legislation to Authorize the Transfer of Funds to the Agency for International Development (AID) for Humanitarian Assistance to Afghanistan (U)
* *COMMENT** Legislation submitted.
TOP SECRET
UNCLASSIFIED
---
<!-- DOCMASTER:PAGE 0006 -->
## Page 6
~~TOP SECRET~~
# UNCLASSIFIED
NSD 6 22 MAR 89 Security of U.S. Government Personnel
in Panama (C)
* *COMMENT** Specific to threat to US personnel at time of
1989 Panamanian election.
NSD 8 01 MAY 89 U.S. Policy towards Nicaragua and the
Nicaraguan Resistance (C)
* *COMMENT** Objectives attained with the election and
inauguration of democratic government in April 1990.
NSD 8 11 MAY 89 Sensitive Annex to NSD 8 re U.S. Policy
ANNEX towards Nicaragua and the Nicaraguan
Resistance (C)
NSD 9 08 MAY 89 Actions to Respond to the Polish
Roundtable Agreement (U)
NSD 12 06 JUN 89 Lifting the No-Exceptions Policy (U)
* *COMMENT** Final decision made by COCOM to eliminate
the "no exception policy".
NSD 13 07 JUN 89 Covert Action Annex to NSD-13 on Cocaine
ANNEX Trafficking (S)
NSD 17 22 JUL 89 U.S. Actions in Panama (C)
* *COMMENT** OBE by Operation Just Cause.
NSD 21 01 SEP 89 U.S. Policy towards Panama under Noriega
after 1 September 1989 (C)
* *COMMENT** OBE by Operation Just Cause.
NSD 22 20 SEP 89 United States Policy on Nuclear Testing
Arms Control (U)
* *COMMENT** TTBT and PNET treaties signed, ratified and
in force.
~~TOP SECRET~~
UNCLASSIFIED
---
<!-- DOCMASTER:PAGE 0007 -->
## Page 7
~~TOP SECRET~~
* *UNCLASSIFIED**
4
NSD 23 22 SEP 89 United States Relations with the Soviet
Union (C)
* *COMMENT** Replaced by policies toward each of the Newly
Independent States of the former Soviet Union.
NSD 25 22 SEP 89 U.S. Policy towards the February 1990
Nicaraguan Election (S)
* *COMMENT** Specific to 1990 election.
NSD 25 22 SEP 89 [unclear] NSD-25 on U.S. Policy
ANNEX towards the February 1990 Nicaraguan
Election (S)
1.5(c)
NSD 33 24 JAN 90 U.S. Policy towards Panama - Post
Noriega (U)
* *COMMENT** Most of the specific elements have been
undertaken and completed.
NSD 34 24 JAN 90 Partnership with Panama - Action Plan to
Foster Economic Recovery (U)
* *COMMENT** Most of the specific elements have been
undertaken and completed.
NSD 35 24 JAN 90 U.S. - Soviet Economic Initiatives (C)
* *COMMENT** Completed, policies toward each of the Newly
Independent States have subsequently been developed.
NSD 36 06 FEB 90 United States Arms Control Policy (U)
* *COMMENT** START treaty signed, ratified and in force.
NSD 39 01 MAY 90 COCOM Policy towards Eastern Europe and
Soviet Union (C)
* *COMMENT** Overtaken by events with the dissolution of
the former Soviet Union. Changes made by COCOM and
implemented in U.S. regulations.
NSD 40 14 MAY 90 Decisions on START Issues (U)
~~TOP SECRET~~
* *UNCLASSIFIED**
---
<!-- DOCMASTER:PAGE 0008 -->
## Page 8
~~TOP SECRET~~
# UNCLASSIFIED
NSD 45 20 AUG 90 U.S. Policy in Response to the Iraqi Invasion of Kuwait (C)
* *COMMENT** OBE by operations Desert Shield and Desert Storm.
NSD 54 15 JAN 91 Responding to Iraqi Aggression in the Gulf (U)
* *COMMENT** Accomplished by Desert Storm. Findings supersede references to Iraqi leadership.
NSD 55 26 MAR 91 Change 1 to NSD 48 - Nuclear Weapons Deployment Authorization for FY 1990 and FY 1991 (C)
* *COMMENT** All nuclear weapons authorized for withdrawal have been withdrawn.
NSD 59 14 MAY 91 Disposition of Reagan Administration Policy Papers (U)
~~TOP SECRET~~
# UNCLASSIFIED

22
vendor/autoload.php vendored Normal file
View File

@ -0,0 +1,22 @@
<?php
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
throw new RuntimeException($err);
}
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInit691f538563ac6695008ddc51b7722c80::getLoader();

119
vendor/bin/carbon vendored Executable file
View File

@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../nesbot/carbon/bin/carbon)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/nesbot/carbon/bin/carbon');
}
}
return include __DIR__ . '/..'.'/nesbot/carbon/bin/carbon';

119
vendor/bin/patch-type-declarations vendored Executable file
View File

@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../symfony/error-handler/Resources/bin/patch-type-declarations)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/symfony/error-handler/Resources/bin/patch-type-declarations');
}
}
return include __DIR__ . '/..'.'/symfony/error-handler/Resources/bin/patch-type-declarations';

119
vendor/bin/var-dump-server vendored Executable file
View File

@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../symfony/var-dumper/Resources/bin/var-dump-server)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/symfony/var-dumper/Resources/bin/var-dump-server');
}
}
return include __DIR__ . '/..'.'/symfony/var-dumper/Resources/bin/var-dump-server';

748
vendor/brick/math/CHANGELOG.md vendored Normal file
View File

@ -0,0 +1,748 @@
# Changelog
All notable changes to this project will be documented in this file.
## [0.17.1](https://github.com/brick/math/releases/tag/0.17.1) - 2026-04-19
👌 **Improvements**
- More precise `float` approximations in `BigRational::toFloat()`
## [0.17.0](https://github.com/brick/math/releases/tag/0.17.0) - 2026-03-17
💥 **Breaking changes**
- Deprecated method `BigDecimal::hasNonZeroFractionalPart()` has been removed, use `! $number->getFractionalPart()->isZero()` instead
- Exception constructors and factory methods are now `@internal`
✨ **Compatibility improvements**
- `BigDecimal::fromFloatExact()` now supports 32-bit PHP
## [0.16.2](https://github.com/brick/math/releases/tag/0.16.2) - 2026-03-15
✨ **New features**
New methods to create a `BigDecimal` from a `float` value:
- `BigDecimal::fromFloatExact()`
- `BigDecimal::fromFloatShortest()`
## [0.16.1](https://github.com/brick/math/releases/tag/0.16.1) - 2026-03-09
👌 **Improvements**
- Add `@return non-empty-string` to `toString()`, `jsonSerialize()` and `__toString()` (#111 by @vudaltsov)
## [0.16.0](https://github.com/brick/math/releases/tag/0.16.0) - 2026-03-06
💥 **Breaking changes**
- **`BigInteger::getLowestSetBit()` now returns `null` instead of `-1` when the number is zero**
- Deprecated method `BigRational::simplified()` has been removed, as it is now a no-op
✨ **New features**
- New method: `BigDecimal::getIntegralPart()` returns the integral part as `BigInteger` (this method existed with a different signature in version 0.14, and was removed in 0.15)
- New method: `BigDecimal::getFractionalPart()` returns the fractional part as `BigDecimal` (this method existed with a different signature and meaning in version 0.14, and was removed in 0.15)
🗑️ **Deprecations**
- Method `BigDecimal::hasNonZeroFractionalPart()` is deprecated, use `->getFractionalPart()->isZero()` instead
## [0.15.0](https://github.com/brick/math/releases/tag/0.15.0) - 2026-02-20
💥 **Breaking changes**
- **floating-point inputs are no longer accepted by `of()` and arithmetic methods**, use `of((string) $float)` to get the same behaviour as before (#105)
- **`BigRational` is now always simplified to lowest terms:** all operations, including `of()` and `ofFraction()`, now return a fraction in its simplest form (e.g. `2/3` instead of `4/6`)
- **`BigDecimal::dividedBy()` now requires the `$scale` parameter**
- **`BigInteger::sqrt()` and `BigDecimal::sqrt()` now default to `RoundingMode::Unnecessary`**, explicitly pass `RoundingMode::Down` to get the previous behaviour
- **`BigInteger::mod()` now uses Euclidean modulo semantics**: the modulus must be strictly positive, and the result is always non-negative; this change aligns with Java's `BigInteger.mod()` behaviour
- **`BigInteger::mod()`, `modInverse()` and `modPow()` now throw `InvalidArgumentException` (instead of `NegativeNumberException`) for negative modulus/exponent arguments**
- **`MathException` is now an interface** instead of a class
- **`BigDecimal::getPrecision()` now returns `1` for zero values**
- `BigNumber::min()`, `max()` and `sum()` now throw an `ArgumentCountError` when called with no arguments (previously threw `InvalidArgumentException`)
- `BigInteger::randomBits()` and `randomRange()` now throw `RandomSourceException` when random byte generation fails or returns invalid data
Deprecated API elements removed:
- deprecated method `BigInteger::testBit()` has been removed, use `isBitSet()` instead
- deprecated method `BigInteger::gcdMultiple()` has been removed, use `gcdAll()` instead
- deprecated method `BigDecimal::exactlyDividedBy()` has been removed, use `dividedByExact()` instead
- deprecated method `BigDecimal::getIntegralPart()` has been removed (will be re-introduced as returning `BigInteger` in 0.16)
- deprecated method `BigDecimal::getFractionalPart()` has been removed (will be re-introduced as returning `BigDecimal` with a different meaning in 0.16)
- deprecated method `BigDecimal::stripTrailingZeros()` has been removed, use `strippedOfTrailingZeros()` instead
- deprecated method `BigRational::nd()` has been removed, use `ofFraction()` instead
- deprecated method `BigRational::quotient()` has been removed, use `getIntegralPart()` instead
- deprecated method `BigRational::remainder()` has been removed, use `$number->getNumerator()->remainder($number->getDenominator())` instead
- deprecated method `BigRational::quotientAndRemainder()` has been removed, use `$number->getNumerator()->quotientAndRemainder($number->getDenominator())` instead
- deprecated `RoundingMode` upper snake case constants (e.g. `HALF_UP`) have been removed, use the pascal case version (e.g. `HalfUp`) instead
The following breaking changes only affect you if you're using named arguments:
- `BigInteger::mod()` now uses `$modulus` as the parameter name
- `BigInteger::modInverse()` now uses `$modulus` as the parameter name
- `BigInteger::modPow()` now uses `$exponent` and `$modulus` as parameter names
- `BigInteger::shiftedLeft()` now uses `$bits` as the parameter name
- `BigInteger::shiftedRight()` now uses `$bits` as the parameter name
- `BigInteger::isBitSet()` now uses `$bitIndex` as the parameter name
- `BigInteger::randomBits()` now uses `$bitCount` as the parameter name
- `BigDecimal::withPointMovedLeft()` now uses `$places` as the parameter name
- `BigDecimal::withPointMovedRight()` now uses `$places` as the parameter name
The following breaking changes are unlikely to affect you:
- `DivisionByZeroException::modulusMustNotBeZero()` has been renamed to `zeroModulus()`
- `DivisionByZeroException::denominatorMustNotBeZero()` has been renamed to `zeroDenominator()`
- `IntegerOverflowException::toIntOverflow()` has been renamed to `integerOutOfRange()`
- `RoundingNecessaryException::roundingNecessary()` has been removed
🗑️ **Deprecations**
- Method `BigRational::simplified()` is deprecated, as it is now a no-op
✨ **New features**
- `BigInteger::power()`, `BigDecimal::power()` and `BigRational::power()` no longer enforce an exponent limit
- `BigInteger::shiftedLeft()` and `BigInteger::shiftedRight()` no longer enforce a limit on the number of bits
- `BigRational::power()` now accepts negative exponents
- New exception: `InvalidArgumentException` for invalid argument errors
- New exception: `NoInverseException` for modular inverse errors
- New exception: `RandomSourceException` for random source errors
👌 **Improvements**
- Narrowed parameter and return types with static analysis annotations (#108 by @simPod)
## [0.14.8](https://github.com/brick/math/releases/tag/0.14.8) - 2026-02-10
🗑️ **Deprecations**
- Method `BigInteger::testBit()` is deprecated, use `isBitSet()` instead
✨ **New features**
- New method: `BigInteger::isBitSet()` (replaces `testBit()`)
- New method: `BigNumber::toString()` (alias of magic method `__toString()`)
👌 **Improvements**
- Performance optimization of `BigRational` comparison methods
- More exceptions have been documented with `@throws` annotations
## [0.14.7](https://github.com/brick/math/releases/tag/0.14.7) - 2026-02-07
✨ **New features**
- `clamp()` is now available on the base `BigNumber` class
👌 **Improvements**
- Improved `@throws` exception documentation
## [0.14.6](https://github.com/brick/math/releases/tag/0.14.6) - 2026-02-05
🗑️ **Deprecations**
- Not passing a `$scale` to `BigDecimal::dividedBy()` is deprecated; **`$scale` will be required in 0.15**
👌 **Improvements**
- `BigRational::toFloat()` never returns `NAN` anymore
## [0.14.5](https://github.com/brick/math/releases/tag/0.14.5) - 2026-02-03
🗑️ **Deprecations**
- Not passing a rounding mode to `BigInteger::sqrt()` and `BigDecimal::sqrt()` triggers a deprecation notice: **the default rounding mode will change from `Down` to `Unnecessary` in 0.15**
✨ **New features**
- `BigInteger::sqrt()` and `BigDecimal::sqrt()` now support rounding
- `abs()` and `negated()` methods are now available on the base `BigNumber` class
👌 **Improvements**
- Alphabet is now checked for duplicate characters in `BigInteger::(from|to)ArbitraryBase()`
- `BigNumber::ofNullable()` is now marked as `@pure`
## [0.14.4](https://github.com/brick/math/releases/tag/0.14.4) - 2026-02-02
🗑️ **Deprecations**
- Passing a negative modulus to `BigInteger::mod()` is deprecated to align with Euclidean modulo semantics; it will throw `InvalidArgumentException` in 0.15
- Method `BigDecimal::stripTrailingZeros()` is deprecated, use `strippedOfTrailingZeros()` instead
✨ **New features**
- `BigInteger::modPow()` now accepts negative bases
- New method: `BigDecimal::strippedOfTrailingZeros()` (replaces `stripTrailingZeros()`)
👌 **Improvements**
- `clamp()` methods are now marked as `@pure`
## [0.14.3](https://github.com/brick/math/releases/tag/0.14.3) - 2026-02-01
✨ **New features**
- New method: `BigInteger::lcm()`
- New method: `BigInteger::lcmAll()`
- New method: `BigRational::toRepeatingDecimalString()`
🐛 **Bug fixes**
- `BigInteger::gcdAll()` / `gcdMultiple()` could return a negative result when used with a single negative number
## [0.14.2](https://github.com/brick/math/releases/tag/0.14.2) - 2026-01-30
🗑️ **Deprecations**
- **Passing `float` values to `of()` or arithmetic methods is deprecated** and will be removed in 0.15; cast to string explicitly to preserve the previous behaviour (#105)
- **Accessing `RoundingMode` enum cases through upper snake case (e.g. `HALF_UP`) is deprecated**, use the pascal case version (e.g. `HalfUp`) instead
- Method `BigInteger::gcdMultiple()` is deprecated, use `gcdAll()` instead
- Method `BigDecimal::exactlyDividedBy()` is deprecated, use `dividedByExact()` instead
- Method `BigDecimal::getIntegralPart()` is deprecated (will be removed in 0.15, and re-introduced as returning `BigInteger` in 0.16)
- Method `BigDecimal::getFractionalPart()` is deprecated (will be removed in 0.15, and re-introduced as returning `BigDecimal` with a different meaning in 0.16)
- Method `BigRational::nd()` is deprecated, use `ofFraction()` instead
- Method `BigRational::quotient()` is deprecated, use `getIntegralPart()` instead
- Method `BigRational::remainder()` is deprecated, use `$number->getNumerator()->remainder($number->getDenominator())` instead
- Method `BigRational::quotientAndRemainder()` is deprecated, use `$number->getNumerator()->quotientAndRemainder($number->getDenominator())` instead
✨ **New features**
- New method: `BigInteger::gcdAll()` (replaces `gcdMultiple()`)
- New method: `BigRational::clamp()`
- New method: `BigRational::ofFraction()` (replaces `nd()`)
- New method: `BigRational::getIntegralPart()` (replaces `quotient()`)
- New method: `BigRational::getFractionalPart()`
👌 **Improvements**
- All exceptions thrown by the library now implement a common `MathException` interface
- `BigInteger::modInverse()` now accepts `BigNumber|int|float|string` instead of just `BigInteger`
- `BigInteger::gcdMultiple()` now accepts `BigNumber|int|float|string` instead of just `BigInteger`
🐛 **Bug fixes**
- `BigInteger::clamp()` and `BigDecimal::clamp()` now throw an exception on inverted bounds, instead of returning an incorrect result
## [0.14.1](https://github.com/brick/math/releases/tag/0.14.1) - 2025-11-24
✨ **New features**
- New method: `BigNumber::ofNullable()` (#94 by @mrkh995)
✨ **Compatibility fixes**
- Fixed warnings on PHP 8.5 (#101 and #102 by @julien-boudry)
## [0.14.0](https://github.com/brick/math/releases/tag/0.14.0) - 2025-08-29
✨ **New features**
- New methods: `BigInteger::clamp()` and `BigDecimal::clamp()` (#96 by @JesterIruka)
✨ **Improvements**
- All pure methods in `BigNumber` classes are now marked as `@pure` for better static analysis
💥 **Breaking changes**
- Minimum PHP version is now 8.2
- `BigNumber` classes are now `readonly`
- `BigNumber` is now marked as sealed: it must not be extended outside of this package
- Exception classes are now `final`
## [0.13.1](https://github.com/brick/math/releases/tag/0.13.1) - 2025-03-29
✨ **Improvements**
- `__toString()` methods of `BigInteger` and `BigDecimal` are now type-hinted as returning `numeric-string` instead of `string` (#90 by @vudaltsov)
## [0.13.0](https://github.com/brick/math/releases/tag/0.13.0) - 2025-03-03
💥 **Breaking changes**
- `BigDecimal::ofUnscaledValue()` no longer throws an exception if the scale is negative
- `MathException` now extends `RuntimeException` instead of `Exception`; this reverts the change introduced in version `0.11.0` (#82)
✨ **New features**
- `BigDecimal::ofUnscaledValue()` allows a negative scale (and converts the values to create a zero scale number)
## [0.12.3](https://github.com/brick/math/releases/tag/0.12.3) - 2025-02-28
✨ **New features**
- `BigDecimal::getPrecision()` Returns the number of significant digits in a decimal number
## [0.12.2](https://github.com/brick/math/releases/tag/0.12.2) - 2025-02-26
⚡️ **Performance improvements**
- Division in `NativeCalculator` is now faster for small divisors, thanks to [@Izumi-kun](https://github.com/Izumi-kun) in [#87](https://github.com/brick/math/pull/87).
👌 **Improvements**
- Add missing `RoundingNecessaryException` to the `@throws` annotation of `BigNumber::of()`
## [0.12.1](https://github.com/brick/math/releases/tag/0.12.1) - 2023-11-29
⚡️ **Performance improvements**
- `BigNumber::of()` is now faster, thanks to [@SebastienDug](https://github.com/SebastienDug) in [#77](https://github.com/brick/math/pull/77).
## [0.12.0](https://github.com/brick/math/releases/tag/0.12.0) - 2023-11-26
💥 **Breaking changes**
- Minimum PHP version is now 8.1
- `RoundingMode` is now an `enum`; if you're type-hinting rounding modes, you need to type-hint against `RoundingMode` instead of `int` now
- `BigNumber` classes do not implement the `Serializable` interface anymore (they use the [new custom object serialization mechanism](https://wiki.php.net/rfc/custom_object_serialization))
- The following breaking changes only affect you if you're creating your own `BigNumber` subclasses:
- the return type of `BigNumber::of()` is now `static`
- `BigNumber` has a new abstract method `from()`
- all `public` and `protected` functions of `BigNumber` are now `final`
## [0.11.0](https://github.com/brick/math/releases/tag/0.11.0) - 2023-01-16
💥 **Breaking changes**
- Minimum PHP version is now 8.0
- Methods accepting a union of types are now strongly typed<sup>*</sup>
- `MathException` now extends `Exception` instead of `RuntimeException`
<sup>* You may now run into type errors if you were passing `Stringable` objects to `of()` or any of the methods
internally calling `of()`, with `strict_types` enabled. You can fix this by casting `Stringable` objects to `string`
first.</sup>
## [0.10.2](https://github.com/brick/math/releases/tag/0.10.2) - 2022-08-11
👌 **Improvements**
- `BigRational::toFloat()` now simplifies the fraction before performing division (#73) thanks to @olsavmic
## [0.10.1](https://github.com/brick/math/releases/tag/0.10.1) - 2022-08-02
✨ **New features**
- `BigInteger::gcdMultiple()` returns the GCD of multiple `BigInteger` numbers
## [0.10.0](https://github.com/brick/math/releases/tag/0.10.0) - 2022-06-18
💥 **Breaking changes**
- Minimum PHP version is now 7.4
## [0.9.3](https://github.com/brick/math/releases/tag/0.9.3) - 2021-08-15
🚀 **Compatibility with PHP 8.1**
- Support for custom object serialization; this removes a warning on PHP 8.1 due to the `Serializable` interface being deprecated (#60) thanks @TRowbotham
## [0.9.2](https://github.com/brick/math/releases/tag/0.9.2) - 2021-01-20
🐛 **Bug fix**
- Incorrect results could be returned when using the BCMath calculator, with a default scale set with `bcscale()`, on PHP >= 7.2 (#55).
## [0.9.1](https://github.com/brick/math/releases/tag/0.9.1) - 2020-08-19
✨ **New features**
- `BigInteger::not()` returns the bitwise `NOT` value
🐛 **Bug fixes**
- `BigInteger::toBytes()` could return an incorrect binary representation for some numbers
- The bitwise operations `and()`, `or()`, `xor()` on `BigInteger` could return an incorrect result when the GMP extension is not available
## [0.9.0](https://github.com/brick/math/releases/tag/0.9.0) - 2020-08-18
👌 **Improvements**
- `BigNumber::of()` now accepts `.123` and `123.` formats, both of which return a `BigDecimal`
💥 **Breaking changes**
- Deprecated method `BigInteger::powerMod()` has been removed - use `modPow()` instead
- Deprecated method `BigInteger::parse()` has been removed - use `fromBase()` instead
## [0.8.17](https://github.com/brick/math/releases/tag/0.8.17) - 2020-08-19
🐛 **Bug fix**
- `BigInteger::toBytes()` could return an incorrect binary representation for some numbers
- The bitwise operations `and()`, `or()`, `xor()` on `BigInteger` could return an incorrect result when the GMP extension is not available
## [0.8.16](https://github.com/brick/math/releases/tag/0.8.16) - 2020-08-18
🚑 **Critical fix**
- This version reintroduces the deprecated `BigInteger::parse()` method, that has been removed by mistake in version `0.8.9` and should have lasted for the whole `0.8` release cycle.
✨ **New features**
- `BigInteger::modInverse()` calculates a modular multiplicative inverse
- `BigInteger::fromBytes()` creates a `BigInteger` from a byte string
- `BigInteger::toBytes()` converts a `BigInteger` to a byte string
- `BigInteger::randomBits()` creates a pseudo-random `BigInteger` of a given bit length
- `BigInteger::randomRange()` creates a pseudo-random `BigInteger` between two bounds
💩 **Deprecations**
- `BigInteger::powerMod()` is now deprecated in favour of `modPow()`
## [0.8.15](https://github.com/brick/math/releases/tag/0.8.15) - 2020-04-15
🐛 **Fixes**
- added missing `ext-json` requirement, due to `BigNumber` implementing `JsonSerializable`
⚡️ **Optimizations**
- additional optimization in `BigInteger::remainder()`
## [0.8.14](https://github.com/brick/math/releases/tag/0.8.14) - 2020-02-18
✨ **New features**
- `BigInteger::getLowestSetBit()` returns the index of the rightmost one bit
## [0.8.13](https://github.com/brick/math/releases/tag/0.8.13) - 2020-02-16
✨ **New features**
- `BigInteger::isEven()` tests whether the number is even
- `BigInteger::isOdd()` tests whether the number is odd
- `BigInteger::testBit()` tests if a bit is set
- `BigInteger::getBitLength()` returns the number of bits in the minimal representation of the number
## [0.8.12](https://github.com/brick/math/releases/tag/0.8.12) - 2020-02-03
🛠️ **Maintenance release**
Classes are now annotated for better static analysis with [psalm](https://psalm.dev/).
This is a maintenance release: no bug fixes, no new features, no breaking changes.
## [0.8.11](https://github.com/brick/math/releases/tag/0.8.11) - 2020-01-23
✨ **New feature**
`BigInteger::powerMod()` performs a power-with-modulo operation. Useful for crypto.
## [0.8.10](https://github.com/brick/math/releases/tag/0.8.10) - 2020-01-21
✨ **New feature**
`BigInteger::mod()` returns the **modulo** of two numbers. The *modulo* differs from the *remainder* when the signs of the operands are different.
## [0.8.9](https://github.com/brick/math/releases/tag/0.8.9) - 2020-01-08
⚡️ **Performance improvements**
A few additional optimizations in `BigInteger` and `BigDecimal` when one of the operands can be returned as is. Thanks to @tomtomsen in #24.
## [0.8.8](https://github.com/brick/math/releases/tag/0.8.8) - 2019-04-25
🐛 **Bug fixes**
- `BigInteger::toBase()` could return an empty string for zero values (BCMath & Native calculators only, GMP calculator unaffected)
✨ **New features**
- `BigInteger::toArbitraryBase()` converts a number to an arbitrary base, using a custom alphabet
- `BigInteger::fromArbitraryBase()` converts a string in an arbitrary base, using a custom alphabet, back to a number
These methods can be used as the foundation to convert strings between different bases/alphabets, using BigInteger as an intermediate representation.
💩 **Deprecations**
- `BigInteger::parse()` is now deprecated in favour of `fromBase()`
`BigInteger::fromBase()` works the same way as `parse()`, with 2 minor differences:
- the `$base` parameter is required, it does not default to `10`
- it throws a `NumberFormatException` instead of an `InvalidArgumentException` when the number is malformed
## [0.8.7](https://github.com/brick/math/releases/tag/0.8.7) - 2019-04-20
**Improvements**
- Safer conversion from `float` when using custom locales
- **Much faster** `NativeCalculator` implementation 🚀
You can expect **at least a 3x performance improvement** for common arithmetic operations when using the library on systems without GMP or BCMath; it gets exponentially faster on multiplications with a high number of digits. This is due to calculations now being performed on whole blocks of digits (the block size depending on the platform, 32-bit or 64-bit) instead of digit-by-digit as before.
## [0.8.6](https://github.com/brick/math/releases/tag/0.8.6) - 2019-04-11
**New method**
`BigNumber::sum()` returns the sum of one or more numbers.
## [0.8.5](https://github.com/brick/math/releases/tag/0.8.5) - 2019-02-12
**Bug fix**: `of()` factory methods could fail when passing a `float` in environments using a `LC_NUMERIC` locale with a decimal separator other than `'.'` (#20).
Thanks @manowark 👍
## [0.8.4](https://github.com/brick/math/releases/tag/0.8.4) - 2018-12-07
**New method**
`BigDecimal::sqrt()` calculates the square root of a decimal number, to a given scale.
## [0.8.3](https://github.com/brick/math/releases/tag/0.8.3) - 2018-12-06
**New method**
`BigInteger::sqrt()` calculates the square root of a number (thanks @peter279k).
**New exception**
`NegativeNumberException` is thrown when calling `sqrt()` on a negative number.
## [0.8.2](https://github.com/brick/math/releases/tag/0.8.2) - 2018-11-08
**Performance update**
- Further improvement of `toInt()` performance
- `NativeCalculator` can now perform some multiplications more efficiently
## [0.8.1](https://github.com/brick/math/releases/tag/0.8.1) - 2018-11-07
Performance optimization of `toInt()` methods.
## [0.8.0](https://github.com/brick/math/releases/tag/0.8.0) - 2018-10-13
**Breaking changes**
The following deprecated methods have been removed. Use the new method name instead:
| Method removed | Replacement method |
| --- | --- |
| `BigDecimal::getIntegral()` | `BigDecimal::getIntegralPart()` |
| `BigDecimal::getFraction()` | `BigDecimal::getFractionalPart()` |
---
**New features**
`BigInteger` has been augmented with 5 new methods for bitwise operations:
| New method | Description |
| --- | --- |
| `and()` | performs a bitwise `AND` operation on two numbers |
| `or()` | performs a bitwise `OR` operation on two numbers |
| `xor()` | performs a bitwise `XOR` operation on two numbers |
| `shiftedLeft()` | returns the number shifted left by a number of bits |
| `shiftedRight()` | returns the number shifted right by a number of bits |
Thanks to @DASPRiD 👍
## [0.7.3](https://github.com/brick/math/releases/tag/0.7.3) - 2018-08-20
**New method:** `BigDecimal::hasNonZeroFractionalPart()`
**Renamed/deprecated methods:**
- `BigDecimal::getIntegral()` has been renamed to `getIntegralPart()` and is now deprecated
- `BigDecimal::getFraction()` has been renamed to `getFractionalPart()` and is now deprecated
## [0.7.2](https://github.com/brick/math/releases/tag/0.7.2) - 2018-07-21
**Performance update**
`BigInteger::parse()` and `toBase()` now use GMP's built-in base conversion features when available.
## [0.7.1](https://github.com/brick/math/releases/tag/0.7.1) - 2018-03-01
This is a maintenance release, no code has been changed.
- When installed with `--no-dev`, the autoloader does not autoload tests anymore
- Tests and other files unnecessary for production are excluded from the dist package
This will help make installations more compact.
## [0.7.0](https://github.com/brick/math/releases/tag/0.7.0) - 2017-10-02
Methods renamed:
- `BigNumber:sign()` has been renamed to `getSign()`
- `BigDecimal::unscaledValue()` has been renamed to `getUnscaledValue()`
- `BigDecimal::scale()` has been renamed to `getScale()`
- `BigDecimal::integral()` has been renamed to `getIntegral()`
- `BigDecimal::fraction()` has been renamed to `getFraction()`
- `BigRational::numerator()` has been renamed to `getNumerator()`
- `BigRational::denominator()` has been renamed to `getDenominator()`
Classes renamed:
- `ArithmeticException` has been renamed to `MathException`
## [0.6.2](https://github.com/brick/math/releases/tag/0.6.2) - 2017-10-02
The base class for all exceptions is now `MathException`.
`ArithmeticException` has been deprecated, and will be removed in 0.7.0.
## [0.6.1](https://github.com/brick/math/releases/tag/0.6.1) - 2017-10-02
A number of methods have been renamed:
- `BigNumber:sign()` is deprecated; use `getSign()` instead
- `BigDecimal::unscaledValue()` is deprecated; use `getUnscaledValue()` instead
- `BigDecimal::scale()` is deprecated; use `getScale()` instead
- `BigDecimal::integral()` is deprecated; use `getIntegral()` instead
- `BigDecimal::fraction()` is deprecated; use `getFraction()` instead
- `BigRational::numerator()` is deprecated; use `getNumerator()` instead
- `BigRational::denominator()` is deprecated; use `getDenominator()` instead
The old methods will be removed in version 0.7.0.
## [0.6.0](https://github.com/brick/math/releases/tag/0.6.0) - 2017-08-25
- Minimum PHP version is now [7.1](https://gophp71.org/); for PHP 5.6 and PHP 7.0 support, use version `0.5`
- Deprecated method `BigDecimal::withScale()` has been removed; use `toScale()` instead
- Method `BigNumber::toInteger()` has been renamed to `toInt()`
## [0.5.4](https://github.com/brick/math/releases/tag/0.5.4) - 2016-10-17
`BigNumber` classes now implement [JsonSerializable](http://php.net/manual/en/class.jsonserializable.php).
The JSON output is always a string.
## [0.5.3](https://github.com/brick/math/releases/tag/0.5.3) - 2016-03-31
This is a bugfix release. Dividing by a negative power of 1 with the same scale as the dividend could trigger an incorrect optimization which resulted in a wrong result. See #6.
## [0.5.2](https://github.com/brick/math/releases/tag/0.5.2) - 2015-08-06
The `$scale` parameter of `BigDecimal::dividedBy()` is now optional again.
## [0.5.1](https://github.com/brick/math/releases/tag/0.5.1) - 2015-07-05
**New method: `BigNumber::toScale()`**
This allows to convert any `BigNumber` to a `BigDecimal` with a given scale, using rounding if necessary.
## [0.5.0](https://github.com/brick/math/releases/tag/0.5.0) - 2015-07-04
**New features**
- Common `BigNumber` interface for all classes, with the following methods:
- `sign()` and derived methods (`isZero()`, `isPositive()`, ...)
- `compareTo()` and derived methods (`isEqualTo()`, `isGreaterThan()`, ...) that work across different `BigNumber` types
- `toBigInteger()`, `toBigDecimal()`, `toBigRational`() conversion methods
- `toInteger()` and `toFloat()` conversion methods to native types
- Unified `of()` behaviour: every class now accepts any type of number, provided that it can be safely converted to the current type
- New method: `BigDecimal::exactlyDividedBy()`; this method automatically computes the scale of the result, provided that the division yields a finite number of digits
- New methods: `BigRational::quotient()` and `remainder()`
- Fine-grained exceptions: `DivisionByZeroException`, `RoundingNecessaryException`, `NumberFormatException`
- Factory methods `zero()`, `one()` and `ten()` available in all classes
- Rounding mode reintroduced in `BigInteger::dividedBy()`
This release also comes with many performance improvements.
---
**Breaking changes**
- `BigInteger`:
- `getSign()` is renamed to `sign()`
- `toString()` is renamed to `toBase()`
- `BigInteger::dividedBy()` now throws an exception by default if the remainder is not zero; use `quotient()` to get the previous behaviour
- `BigDecimal`:
- `getSign()` is renamed to `sign()`
- `getUnscaledValue()` is renamed to `unscaledValue()`
- `getScale()` is renamed to `scale()`
- `getIntegral()` is renamed to `integral()`
- `getFraction()` is renamed to `fraction()`
- `divideAndRemainder()` is renamed to `quotientAndRemainder()`
- `dividedBy()` now takes a **mandatory** `$scale` parameter **before** the rounding mode
- `toBigInteger()` does not accept a `$roundingMode` parameter anymore
- `toBigRational()` does not simplify the fraction anymore; explicitly add `->simplified()` to get the previous behaviour
- `BigRational`:
- `getSign()` is renamed to `sign()`
- `getNumerator()` is renamed to `numerator()`
- `getDenominator()` is renamed to `denominator()`
- `of()` is renamed to `nd()`, while `parse()` is renamed to `of()`
- Miscellaneous:
- `ArithmeticException` is moved to an `Exception\` sub-namespace
- `of()` factory methods now throw `NumberFormatException` instead of `InvalidArgumentException`
## [0.4.3](https://github.com/brick/math/releases/tag/0.4.3) - 2016-03-31
Backport of two bug fixes from the 0.5 branch:
- `BigInteger::parse()` did not always throw `InvalidArgumentException` as expected
- Dividing by a negative power of 1 with the same scale as the dividend could trigger an incorrect optimization which resulted in a wrong result. See #6.
## [0.4.2](https://github.com/brick/math/releases/tag/0.4.2) - 2015-06-16
New method: `BigDecimal::stripTrailingZeros()`
## [0.4.1](https://github.com/brick/math/releases/tag/0.4.1) - 2015-06-12
Introducing a `BigRational` class, to perform calculations on fractions of any size.
## [0.4.0](https://github.com/brick/math/releases/tag/0.4.0) - 2015-06-12
Rounding modes have been removed from `BigInteger`, and are now a concept specific to `BigDecimal`.
`BigInteger::dividedBy()` now always returns the quotient of the division.
## [0.3.5](https://github.com/brick/math/releases/tag/0.3.5) - 2016-03-31
Backport of two bug fixes from the 0.5 branch:
- `BigInteger::parse()` did not always throw `InvalidArgumentException` as expected
- Dividing by a negative power of 1 with the same scale as the dividend could trigger an incorrect optimization which resulted in a wrong result. See #6.
## [0.3.4](https://github.com/brick/math/releases/tag/0.3.4) - 2015-06-11
New methods:
- `BigInteger::remainder()` returns the remainder of a division only
- `BigInteger::gcd()` returns the greatest common divisor of two numbers
## [0.3.3](https://github.com/brick/math/releases/tag/0.3.3) - 2015-06-07
Fix `toString()` not handling negative numbers.
## [0.3.2](https://github.com/brick/math/releases/tag/0.3.2) - 2015-06-07
`BigInteger` and `BigDecimal` now have a `getSign()` method that returns:
- `-1` if the number is negative
- `0` if the number is zero
- `1` if the number is positive
## [0.3.1](https://github.com/brick/math/releases/tag/0.3.1) - 2015-06-05
Minor performance improvements
## [0.3.0](https://github.com/brick/math/releases/tag/0.3.0) - 2015-06-04
The `$roundingMode` and `$scale` parameters have been swapped in `BigDecimal::dividedBy()`.
## [0.2.2](https://github.com/brick/math/releases/tag/0.2.2) - 2015-06-04
Stronger immutability guarantee for `BigInteger` and `BigDecimal`.
So far, it would have been possible to break immutability of these classes by calling the `unserialize()` internal function. This release fixes that.
## [0.2.1](https://github.com/brick/math/releases/tag/0.2.1) - 2015-06-02
Added `BigDecimal::divideAndRemainder()`
## [0.2.0](https://github.com/brick/math/releases/tag/0.2.0) - 2015-05-22
- `min()` and `max()` do not accept an `array` anymore, but a variable number of parameters
- **minimum PHP version is now 5.6**
- continuous integration with PHP 7
## [0.1.1](https://github.com/brick/math/releases/tag/0.1.1) - 2014-09-01
- Added `BigInteger::power()`
- Added HHVM support
## [0.1.0](https://github.com/brick/math/releases/tag/0.1.0) - 2014-08-31
First beta release.

20
vendor/brick/math/LICENSE vendored Normal file
View File

@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2013-present Benjamin Morel
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

263
vendor/brick/math/README.md vendored Normal file
View File

@ -0,0 +1,263 @@
## Brick\Math
<img src="https://raw.githubusercontent.com/brick/brick/master/logo.png" alt="" align="left" height="64">
A PHP library to work with arbitrary precision numbers.
[![Build Status](https://github.com/brick/math/workflows/CI/badge.svg)](https://github.com/brick/math/actions)
[![Coverage Status](https://codecov.io/github/brick/math/graph/badge.svg)](https://codecov.io/github/brick/math)
[![Latest Stable Version](https://poser.pugx.org/brick/math/v/stable)](https://packagist.org/packages/brick/math)
[![Total Downloads](https://poser.pugx.org/brick/math/downloads)](https://packagist.org/packages/brick/math)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](http://opensource.org/licenses/MIT)
### Installation
This library is installable via [Composer](https://getcomposer.org/):
```bash
composer require brick/math
```
### Requirements
This library requires PHP 8.2 or later.
For PHP 8.1 compatibility, you can use version `0.13`. For PHP 8.0, you can use version `0.11`. For PHP 7.4, you can use version `0.10`. For PHP 7.1, 7.2 & 7.3, you can use version `0.9`. Note that [PHP versions < 8.1 are EOL](http://php.net/supported-versions.php) and not supported anymore. If you're still using one of these PHP versions, you should consider upgrading as soon as possible.
Although the library can work seamlessly on any PHP installation, it is highly recommended that you install the
[GMP](http://php.net/manual/en/book.gmp.php) or [BCMath](http://php.net/manual/en/book.bc.php) extension
to speed up calculations. The fastest available calculator implementation will be automatically selected at runtime.
### Project status & release process
While this library is still under development, it is well tested and considered stable enough to use in production
environments.
The current releases are numbered `0.x.y`. When a non-breaking change is introduced (adding new methods, optimizing
existing code, etc.), `y` is incremented.
**When a breaking change is introduced, a new `0.x` version cycle is always started.**
It is therefore safe to lock your project to a given release cycle, such as `^0.17`.
If you need to upgrade to a newer release cycle, check the [release history](https://github.com/brick/math/releases)
for a list of changes introduced by each further `0.x.0` version.
### Package contents
This library provides the following public classes in the `Brick\Math` namespace:
- [BigNumber](https://github.com/brick/math/blob/0.17.1/src/BigNumber.php): base class for `BigInteger`, `BigDecimal` and `BigRational`
- [BigInteger](https://github.com/brick/math/blob/0.17.1/src/BigInteger.php): represents an arbitrary-precision integer number.
- [BigDecimal](https://github.com/brick/math/blob/0.17.1/src/BigDecimal.php): represents an arbitrary-precision decimal number.
- [BigRational](https://github.com/brick/math/blob/0.17.1/src/BigRational.php): represents an arbitrary-precision rational number (fraction), always reduced to lowest terms.
- [RoundingMode](https://github.com/brick/math/blob/0.17.1/src/RoundingMode.php): enum representing all available rounding modes.
And [exceptions](#exceptions) in the `Brick\Math\Exception` namespace.
### Overview
#### Instantiation
The constructors of the classes are not public, you must use a factory method to obtain an instance.
All classes provide an `of()` factory method that accepts any of the following types:
- `BigNumber` instances
- `int` numbers
- `string` representations of integer, decimal and rational numbers
Example:
```php
BigInteger::of(123546);
BigInteger::of('9999999999999999999999999999999999999999999');
BigDecimal::of('9.99999999999999999999999999999999999999999999');
BigRational::of('2/3');
```
Note that all `of()` methods accept all the representations above, *as long as it can be safely converted to
the current type*:
```php
BigInteger::of('1.00'); // 1
BigInteger::of('1.01'); // RoundingNecessaryException
BigDecimal::of('1/8'); // 0.125
BigDecimal::of('1/3'); // RoundingNecessaryException
BigRational::of('1.1'); // 11/10
BigRational::of('1.15'); // 23/20 (reduced to lowest terms)
```
> [!NOTE]
> The `of()` factory method does not accept `float` values, because casting a float to string can be lossy.
> To convert a float to a `BigDecimal`, use one of the dedicated methods:
>
> ```php
> // Exact IEEE-754 representation — the value the float actually holds:
> BigDecimal::fromFloatExact(0.1); // 0.1000000000000000055511151231257827021181583404541015625
>
> // Shortest decimal that round-trips back to the same float:
> BigDecimal::fromFloatShortest(0.1); // 0.1
> ```
#### Immutability & chaining
The `BigInteger`, `BigDecimal` and `BigRational` classes are immutable: their value never changes,
so that they can be safely passed around. All methods that return a `BigInteger`, `BigDecimal` or `BigRational`
return a new object, leaving the original object unaffected:
```php
$ten = BigInteger::of(10);
echo $ten->plus(5); // 15
echo $ten->multipliedBy(3); // 30
```
The methods can be chained for better readability:
```php
echo BigInteger::of(10)->plus(5)->multipliedBy(3); // 45
```
#### Parameter types
All methods that accept a number: `plus()`, `minus()`, `multipliedBy()`, etc. accept the same types as `of()`.
For example, given the following number:
```php
$integer = BigInteger::of(123);
```
The following lines are equivalent:
```php
$integer->multipliedBy(123);
$integer->multipliedBy('123');
$integer->multipliedBy($integer);
```
Just like `of()`, other types of `BigNumber` are acceptable, as long as they can be safely converted to the current type:
```php
echo BigInteger::of(2)->multipliedBy(BigDecimal::of('2.0')); // 4
echo BigInteger::of(2)->multipliedBy(BigDecimal::of('2.5')); // RoundingNecessaryException
echo BigDecimal::of(2.5)->multipliedBy(BigInteger::of(2)); // 5.0
```
#### Division & rounding
##### BigInteger
By default, dividing a `BigInteger` returns the exact result of the division, or throws an exception if the remainder
of the division is not zero:
```php
echo BigInteger::of(999)->dividedBy(3); // 333
echo BigInteger::of(1000)->dividedBy(3); // RoundingNecessaryException
```
You can pass an optional [rounding mode](https://github.com/brick/math/blob/0.17.1/src/RoundingMode.php) to round the result, if necessary:
```php
echo BigInteger::of(1000)->dividedBy(3, RoundingMode::Down); // 333
echo BigInteger::of(1000)->dividedBy(3, RoundingMode::Up); // 334
```
If you're into quotients and remainders, there are methods for this, too:
```php
echo BigInteger::of(1000)->quotient(3); // 333
echo BigInteger::of(1000)->remainder(3); // 1
```
You can even get both at the same time:
```php
[$quotient, $remainder] = BigInteger::of(1000)->quotientAndRemainder(3);
```
##### BigDecimal
Dividing a `BigDecimal` always requires a scale to be specified. If the exact result of the division does not fit in
the given scale, a [rounding mode](https://github.com/brick/math/blob/0.17.1/src/RoundingMode.php) must be provided.
```php
echo BigDecimal::of(1)->dividedBy('8', 3); // 0.125
echo BigDecimal::of(1)->dividedBy('8', 2); // RoundingNecessaryException
echo BigDecimal::of(1)->dividedBy('8', 2, RoundingMode::HalfDown); // 0.12
echo BigDecimal::of(1)->dividedBy('8', 2, RoundingMode::HalfUp); // 0.13
```
If you know that the division yields a finite number of decimals places, you can use `dividedByExact()`, which will
automatically compute the required scale to fit the result, or throw an exception if the division yields an infinite
repeating decimal:
```php
echo BigDecimal::of(1)->dividedByExact(256); // 0.00390625
echo BigDecimal::of(1)->dividedByExact(11); // RoundingNecessaryException
```
##### BigRational
The result of the division of a `BigRational` can always be represented exactly:
```php
echo BigRational::of('13/99')->dividedBy('7'); // 13/693
echo BigRational::of('13/99')->dividedBy('9/8'); // 104/891
```
#### Bitwise operations
`BigInteger` supports bitwise operations:
- `and()`
- `or()`
- `xor()`
- `not()`
and bit shifting:
- `shiftedLeft()`
- `shiftedRight()`
#### Exceptions
All exceptions thrown by this library implement the `MathException` interface.
This means that you can safely catch all exceptions thrown by this library using a single catch clause:
```php
use Brick\Math\BigDecimal;
use Brick\Math\Exception\MathException;
try {
$number = BigInteger::of(1)->dividedBy(3);
} catch (MathException $e) {
// ...
}
```
If you need more granular control over the exceptions thrown, you can catch the specific exception classes:
- `DivisionByZeroException`
- `IntegerOverflowException`
- `InvalidArgumentException`
- `NegativeNumberException`
- `NoInverseException`
- `NumberFormatException`
- `RoundingNecessaryException`
#### Serialization
`BigInteger`, `BigDecimal` and `BigRational` can be safely serialized on a machine and unserialized on another,
even if these machines do not share the same set of PHP extensions.
For example, serializing on a machine with GMP support and unserializing on a machine that does not have this extension
installed will still work as expected.
#### PHPStan extension
A third-party [PHPStan extension](https://github.com/simPod/phpstan-brick-math) is available for this library. It provides more specific throw type narrowing for brick/math methods, so that PHPStan can infer the exact exception classes thrown. Note that this extension is not maintained by the author of brick/math.

14
vendor/brick/math/codecov.yml vendored Normal file
View File

@ -0,0 +1,14 @@
codecov:
require_ci_to_pass: true
notify:
after_n_builds: 6
wait_for_ci: true
coverage:
status:
project:
default:
informational: true
patch:
default:
informational: true

38
vendor/brick/math/composer.json vendored Normal file
View File

@ -0,0 +1,38 @@
{
"name": "brick/math",
"description": "Arbitrary-precision arithmetic library",
"type": "library",
"keywords": [
"Brick",
"Math",
"Mathematics",
"Arbitrary-precision",
"Arithmetic",
"BigInteger",
"BigDecimal",
"BigRational",
"BigNumber",
"Bignum",
"Decimal",
"Rational",
"Integer"
],
"license": "MIT",
"require": {
"php": "^8.2"
},
"require-dev": {
"phpunit/phpunit": "^11.5",
"phpstan/phpstan": "2.1.22"
},
"autoload": {
"psr-4": {
"Brick\\Math\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Brick\\Math\\Tests\\": "tests/"
}
}
}

1096
vendor/brick/math/src/BigDecimal.php vendored Normal file

File diff suppressed because it is too large Load Diff

1388
vendor/brick/math/src/BigInteger.php vendored Normal file

File diff suppressed because it is too large Load Diff

698
vendor/brick/math/src/BigNumber.php vendored Normal file
View File

@ -0,0 +1,698 @@
<?php
declare(strict_types=1);
namespace Brick\Math;
use Brick\Math\Exception\DivisionByZeroException;
use Brick\Math\Exception\IntegerOverflowException;
use Brick\Math\Exception\InvalidArgumentException;
use Brick\Math\Exception\MathException;
use Brick\Math\Exception\NumberFormatException;
use Brick\Math\Exception\RoundingNecessaryException;
use Brick\Math\Internal\Safe;
use JsonSerializable;
use Override;
use Stringable;
use function assert;
use function filter_var;
use function is_int;
use function is_null;
use function ltrim;
use function preg_match;
use function str_contains;
use function str_repeat;
use function strlen;
use function substr;
use const FILTER_VALIDATE_INT;
use const PREG_UNMATCHED_AS_NULL;
/**
* Base class for arbitrary-precision numbers.
*
* This class is sealed: it is part of the public API but should not be subclassed in userland.
* Protected methods may change in any version.
*
* @phpstan-sealed BigInteger|BigDecimal|BigRational
*/
abstract readonly class BigNumber implements JsonSerializable, Stringable
{
/**
* The regular expression used to parse integer or decimal numbers.
*/
private const PARSE_REGEXP_NUMERICAL =
'/^' .
'(?<sign>[\-\+])?' .
'(?<integral>[0-9]+)?' .
'(?<point>\.)?' .
'(?<fractional>[0-9]+)?' .
'(?:[eE](?<exponent>[\-\+]?[0-9]+))?' .
'$/';
/**
* The regular expression used to parse rational numbers.
*/
private const PARSE_REGEXP_RATIONAL =
'/^' .
'(?<sign>[\-\+])?' .
'(?<numerator>[0-9]+)' .
'\/' .
'(?<denominator>[0-9]+)' .
'$/';
/**
* Creates a BigNumber of the given value.
*
* When of() is called on BigNumber, the concrete return type is dependent on the given value, with the following
* rules:
*
* - BigNumber instances are returned as is
* - integer numbers are returned as BigInteger
* - strings containing a `/` character are returned as BigRational
* - strings containing a `.` character or using an exponential notation are returned as BigDecimal
* - strings containing only digits with an optional leading `+` or `-` sign are returned as BigInteger
*
* When of() is called on BigInteger, BigDecimal, or BigRational, the resulting number is converted to an instance
* of the subclass when possible; otherwise a RoundingNecessaryException exception is thrown.
*
* @throws NumberFormatException If the format of the number is not valid.
* @throws DivisionByZeroException If the value represents a rational number with a denominator of zero.
* @throws RoundingNecessaryException If the value cannot be converted to an instance of the subclass without rounding.
*
* @pure
*/
final public static function of(BigNumber|int|string $value): static
{
$value = self::_of($value);
if (static::class === BigNumber::class) {
assert($value instanceof static);
return $value;
}
return static::from($value);
}
/**
* Creates a BigNumber of the given value, or returns null if the input is null.
*
* Behaves like of() for non-null values.
*
* @see BigNumber::of()
*
* @throws NumberFormatException If the format of the number is not valid.
* @throws DivisionByZeroException If the value represents a rational number with a denominator of zero.
* @throws RoundingNecessaryException If the value cannot be converted to an instance of the subclass without rounding.
*
* @pure
*/
final public static function ofNullable(BigNumber|int|string|null $value): ?static
{
if (is_null($value)) {
return null;
}
return static::of($value);
}
/**
* Returns the minimum of the given values.
*
* If several values are equal and minimal, the first one is returned.
* This can affect the concrete return type when calling this method on BigNumber.
*
* @param BigNumber|int|string $a The first number. Must be convertible to an instance of the class this method
* is called on.
* @param BigNumber|int|string ...$n The additional numbers. Each number must be convertible to an instance of the
* class this method is called on.
*
* @throws MathException If a number is not valid, or is not convertible to an instance of the class this method is
* called on.
*
* @pure
*/
final public static function min(BigNumber|int|string $a, BigNumber|int|string ...$n): static
{
$min = static::of($a);
foreach ($n as $value) {
$value = static::of($value);
if ($value->isLessThan($min)) {
$min = $value;
}
}
return $min;
}
/**
* Returns the maximum of the given values.
*
* If several values are equal and maximal, the first one is returned.
* This can affect the concrete return type when calling this method on BigNumber.
*
* @param BigNumber|int|string $a The first number. Must be convertible to an instance of the class this method
* is called on.
* @param BigNumber|int|string ...$n The additional numbers. Each number must be convertible to an instance of the
* class this method is called on.
*
* @throws MathException If a number is not valid, or is not convertible to an instance of the class this method is
* called on.
*
* @pure
*/
final public static function max(BigNumber|int|string $a, BigNumber|int|string ...$n): static
{
$max = static::of($a);
foreach ($n as $value) {
$value = static::of($value);
if ($value->isGreaterThan($max)) {
$max = $value;
}
}
return $max;
}
/**
* Returns the sum of the given values.
*
* When called on BigNumber, sum() accepts any supported type and returns a result whose type is the widest among
* the given values (BigInteger < BigDecimal < BigRational).
*
* When called on BigInteger, BigDecimal, or BigRational, sum() requires that all values can be converted to that
* specific subclass, and returns a result of the same type.
*
* @param BigNumber|int|string $a The first number. Must be convertible to an instance of the class this method
* is called on.
* @param BigNumber|int|string ...$n The additional numbers. Each number must be convertible to an instance of the
* class this method is called on.
*
* @throws MathException If a number is not valid, or is not convertible to an instance of the class this method is
* called on.
*
* @pure
*/
final public static function sum(BigNumber|int|string $a, BigNumber|int|string ...$n): static
{
$sum = static::of($a);
foreach ($n as $value) {
$sum = self::add($sum, static::of($value));
}
assert($sum instanceof static);
return $sum;
}
/**
* Checks if this number is equal to the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isEqualTo(BigNumber|int|string $that): bool
{
return $this->compareTo($that) === 0;
}
/**
* Checks if this number is strictly less than the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isLessThan(BigNumber|int|string $that): bool
{
return $this->compareTo($that) < 0;
}
/**
* Checks if this number is less than or equal to the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isLessThanOrEqualTo(BigNumber|int|string $that): bool
{
return $this->compareTo($that) <= 0;
}
/**
* Checks if this number is strictly greater than the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isGreaterThan(BigNumber|int|string $that): bool
{
return $this->compareTo($that) > 0;
}
/**
* Checks if this number is greater than or equal to the given one.
*
* @throws MathException If the given number is not valid.
*
* @pure
*/
final public function isGreaterThanOrEqualTo(BigNumber|int|string $that): bool
{
return $this->compareTo($that) >= 0;
}
/**
* Checks if this number equals zero.
*
* @pure
*/
final public function isZero(): bool
{
return $this->getSign() === 0;
}
/**
* Checks if this number is strictly negative.
*
* @pure
*/
final public function isNegative(): bool
{
return $this->getSign() < 0;
}
/**
* Checks if this number is negative or zero.
*
* @pure
*/
final public function isNegativeOrZero(): bool
{
return $this->getSign() <= 0;
}
/**
* Checks if this number is strictly positive.
*
* @pure
*/
final public function isPositive(): bool
{
return $this->getSign() > 0;
}
/**
* Checks if this number is positive or zero.
*
* @pure
*/
final public function isPositiveOrZero(): bool
{
return $this->getSign() >= 0;
}
/**
* Returns the absolute value of this number.
*
* @pure
*/
final public function abs(): static
{
return $this->isNegative() ? $this->negated() : $this;
}
/**
* Returns the negated value of this number.
*
* @pure
*/
abstract public function negated(): static;
/**
* Returns the sign of this number.
*
* Returns -1 if the number is negative, 0 if zero, 1 if positive.
*
* @return -1|0|1
*
* @pure
*/
abstract public function getSign(): int;
/**
* Compares this number to the given one.
*
* Returns -1 if `$this` is lower than, 0 if equal to, 1 if greater than `$that`.
*
* @return -1|0|1
*
* @throws MathException If the number is not valid.
*
* @pure
*/
abstract public function compareTo(BigNumber|int|string $that): int;
/**
* Limits (clamps) this number between the given minimum and maximum values.
*
* If the number is lower than $min, returns $min.
* If the number is greater than $max, returns $max.
* Otherwise, returns this number unchanged.
*
* @param BigNumber|int|string $min The minimum. Must be convertible to an instance of the class this method is called on.
* @param BigNumber|int|string $max The maximum. Must be convertible to an instance of the class this method is called on.
*
* @throws MathException If min/max are not convertible to an instance of the class this method is called on.
* @throws InvalidArgumentException If min is greater than max.
*
* @pure
*/
final public function clamp(BigNumber|int|string $min, BigNumber|int|string $max): static
{
$min = static::of($min);
$max = static::of($max);
if ($min->isGreaterThan($max)) {
throw InvalidArgumentException::minGreaterThanMax();
}
if ($this->isLessThan($min)) {
return $min;
}
if ($this->isGreaterThan($max)) {
return $max;
}
return $this;
}
/**
* Converts this number to a BigInteger.
*
* @throws RoundingNecessaryException If this number cannot be converted to a BigInteger without rounding.
*
* @pure
*/
abstract public function toBigInteger(): BigInteger;
/**
* Converts this number to a BigDecimal.
*
* @throws RoundingNecessaryException If this number cannot be converted to a BigDecimal without rounding.
*
* @pure
*/
abstract public function toBigDecimal(): BigDecimal;
/**
* Converts this number to a BigRational.
*
* @pure
*/
abstract public function toBigRational(): BigRational;
/**
* Converts this number to a BigDecimal with the given scale, using rounding if necessary.
*
* @param non-negative-int $scale The scale of the resulting `BigDecimal`. Must be non-negative.
* @param RoundingMode $roundingMode An optional rounding mode, defaults to Unnecessary.
*
* @throws InvalidArgumentException If the scale is negative.
* @throws RoundingNecessaryException If RoundingMode::Unnecessary is used, and this number cannot be converted to
* the given scale without rounding.
*
* @pure
*/
abstract public function toScale(int $scale, RoundingMode $roundingMode = RoundingMode::Unnecessary): BigDecimal;
/**
* Returns the exact value of this number as a native integer.
*
* If this number cannot be converted to a native integer without losing precision, an exception is thrown.
* Note that the acceptable range for an integer depends on the platform and differs for 32-bit and 64-bit.
*
* @throws RoundingNecessaryException If this number cannot be converted to an integer without rounding.
* @throws IntegerOverflowException If this number is too large to fit in a native integer.
*
* @pure
*/
abstract public function toInt(): int;
/**
* Returns an approximation of this number as a floating-point value.
*
* Note that this method can discard information as the precision of a floating-point value
* is inherently limited.
*
* If the number is greater than the largest representable floating point number, positive infinity is returned.
* If the number is less than the smallest representable floating point number, negative infinity is returned.
* This method never returns NaN.
*
* @pure
*/
abstract public function toFloat(): float;
/**
* Returns a string representation of this number.
*
* The output of this method can be parsed by the `of()` factory method; this will yield an object equal to this
* one, but possibly of a different type if instantiated through `BigNumber::of()`.
*
* @return non-empty-string
*
* @pure
*/
abstract public function toString(): string;
/**
* @return non-empty-string
*/
#[Override]
final public function jsonSerialize(): string
{
return $this->toString();
}
/**
* @return non-empty-string
*
* @pure
*/
#[Override]
final public function __toString(): string
{
return $this->toString();
}
/**
* Overridden by subclasses to convert a BigNumber to an instance of the subclass.
*
* @throws RoundingNecessaryException If the value cannot be converted.
*
* @pure
*/
abstract protected static function from(BigNumber $number): static;
/**
* Proxy method to access BigInteger's protected constructor from sibling classes.
*
* @internal
*
* @pure
*/
final protected function newBigInteger(string $value): BigInteger
{
return new BigInteger($value);
}
/**
* Proxy method to access BigDecimal's protected constructor from sibling classes.
*
* @internal
*
* @param non-negative-int $scale
*
* @pure
*/
final protected function newBigDecimal(string $value, int $scale = 0): BigDecimal
{
return new BigDecimal($value, $scale);
}
/**
* Proxy method to access BigRational's protected constructor from sibling classes.
*
* @internal
*
* @pure
*/
final protected function newBigRational(BigInteger $numerator, BigInteger $denominator, bool $checkDenominator, bool $simplify): BigRational
{
return new BigRational($numerator, $denominator, $checkDenominator, $simplify);
}
/**
* @throws NumberFormatException If the format of the number is not valid.
* @throws DivisionByZeroException If the value represents a rational number with a denominator of zero.
*
* @pure
*/
private static function _of(BigNumber|int|string $value): BigNumber
{
if ($value instanceof BigNumber) {
return $value;
}
if (is_int($value)) {
return new BigInteger((string) $value);
}
if ($value === '') {
throw NumberFormatException::emptyNumber();
}
if (str_contains($value, '/')) {
// Rational number
if (preg_match(self::PARSE_REGEXP_RATIONAL, $value, $matches, PREG_UNMATCHED_AS_NULL) !== 1) {
throw NumberFormatException::invalidFormat($value);
}
$sign = $matches['sign'];
$numerator = $matches['numerator'];
$denominator = $matches['denominator'];
$numerator = self::cleanUp($sign, $numerator);
$denominator = self::cleanUp(null, $denominator);
if ($denominator === '0') {
throw DivisionByZeroException::zeroDenominator();
}
return new BigRational(
new BigInteger($numerator),
new BigInteger($denominator),
false,
true,
);
} else {
// Integer or decimal number
if (preg_match(self::PARSE_REGEXP_NUMERICAL, $value, $matches, PREG_UNMATCHED_AS_NULL) !== 1) {
throw NumberFormatException::invalidFormat($value);
}
$sign = $matches['sign'];
$point = $matches['point'];
$integral = $matches['integral'];
$fractional = $matches['fractional'];
$exponent = $matches['exponent'];
if ($integral === null && $fractional === null) {
throw NumberFormatException::invalidFormat($value);
}
if ($integral === null) {
$integral = '0';
}
if ($point !== null || $exponent !== null) {
$fractional ??= '';
if ($exponent !== null) {
if ($exponent[0] === '-') {
$exponent = ltrim(substr($exponent, 1), '0') ?: '0';
$exponent = filter_var($exponent, FILTER_VALIDATE_INT);
if ($exponent !== false) {
$exponent = -$exponent;
}
} else {
if ($exponent[0] === '+') {
$exponent = substr($exponent, 1);
}
$exponent = ltrim($exponent, '0') ?: '0';
$exponent = filter_var($exponent, FILTER_VALIDATE_INT);
}
} else {
$exponent = 0;
}
if ($exponent === false) {
throw NumberFormatException::exponentTooLarge();
}
$unscaledValue = self::cleanUp($sign, $integral . $fractional);
$scale = Safe::sub(strlen($fractional), $exponent);
if ($scale < 0) {
if ($unscaledValue !== '0') {
$unscaledValue .= str_repeat('0', Safe::neg($scale));
}
$scale = 0;
}
return new BigDecimal($unscaledValue, $scale);
}
$integral = self::cleanUp($sign, $integral);
return new BigInteger($integral);
}
}
/**
* Removes optional leading zeros and applies sign.
*
* @param '+'|'-'|null $sign The sign, optional. Null is allowed for convenience and treated as '+'.
* @param non-empty-string $number The number, validated as a string of digits.
*
* @pure
*/
private static function cleanUp(string|null $sign, string $number): string
{
$number = ltrim($number, '0');
if ($number === '') {
return '0';
}
return $sign === '-' ? '-' . $number : $number;
}
/**
* Adds two BigNumber instances in the correct order to avoid a RoundingNecessaryException.
*
* @pure
*/
private static function add(BigNumber $a, BigNumber $b): BigNumber
{
if ($a instanceof BigRational) {
return $a->plus($b);
}
if ($b instanceof BigRational) {
return $b->plus($a);
}
if ($a instanceof BigDecimal) {
return $a->plus($b);
}
if ($b instanceof BigDecimal) {
return $b->plus($a);
}
return $a->plus($b);
}
}

591
vendor/brick/math/src/BigRational.php vendored Normal file
View File

@ -0,0 +1,591 @@
<?php
declare(strict_types=1);
namespace Brick\Math;
use Brick\Math\Exception\DivisionByZeroException;
use Brick\Math\Exception\InvalidArgumentException;
use Brick\Math\Exception\MathException;
use Brick\Math\Exception\RoundingNecessaryException;
use Brick\Math\Internal\DecimalHelper;
use Brick\Math\Internal\Safe;
use LogicException;
use Override;
use function max;
use function min;
use function strlen;
use function substr;
/**
* An arbitrarily large rational number.
*
* This class is immutable.
*
* Fractions are automatically simplified to lowest terms. For example, `2/4` becomes `1/2`.
* The denominator is always strictly positive; the sign is carried by the numerator.
*/
final readonly class BigRational extends BigNumber
{
/**
* The numerator.
*/
private BigInteger $numerator;
/**
* The denominator. Always strictly positive.
*/
private BigInteger $denominator;
/**
* Protected constructor. Use a factory method to obtain an instance.
*
* @param BigInteger $numerator The numerator.
* @param BigInteger $denominator The denominator.
* @param bool $checkDenominator Whether to check the denominator for negative and zero.
*
* @throws DivisionByZeroException If the denominator is zero.
*
* @pure
*/
protected function __construct(BigInteger $numerator, BigInteger $denominator, bool $checkDenominator, bool $simplify)
{
if ($checkDenominator) {
if ($denominator->isZero()) {
throw DivisionByZeroException::zeroDenominator();
}
if ($denominator->isNegative()) {
$numerator = $numerator->negated();
$denominator = $denominator->negated();
}
}
if ($simplify) {
$gcd = $numerator->gcd($denominator);
$numerator = $numerator->quotient($gcd);
$denominator = $denominator->quotient($gcd);
}
$this->numerator = $numerator;
$this->denominator = $denominator;
}
/**
* Creates a BigRational out of a numerator and a denominator.
*
* If the denominator is negative, the signs of both the numerator and the denominator
* will be inverted to ensure that the denominator is always positive.
*
* @param BigNumber|int|string $numerator The numerator. Must be convertible to a BigInteger.
* @param BigNumber|int|string $denominator The denominator. Must be convertible to a BigInteger.
*
* @throws MathException If an argument is not valid, or is not convertible to a BigInteger.
* @throws DivisionByZeroException If the denominator is zero.
*
* @pure
*/
public static function ofFraction(
BigNumber|int|string $numerator,
BigNumber|int|string $denominator,
): BigRational {
$numerator = BigInteger::of($numerator);
$denominator = BigInteger::of($denominator);
return new BigRational($numerator, $denominator, true, true);
}
/**
* Returns a BigRational representing zero.
*
* @pure
*/
public static function zero(): BigRational
{
/** @var BigRational|null $zero */
static $zero;
if ($zero === null) {
$zero = new BigRational(BigInteger::zero(), BigInteger::one(), false, false);
}
return $zero;
}
/**
* Returns a BigRational representing one.
*
* @pure
*/
public static function one(): BigRational
{
/** @var BigRational|null $one */
static $one;
if ($one === null) {
$one = new BigRational(BigInteger::one(), BigInteger::one(), false, false);
}
return $one;
}
/**
* Returns a BigRational representing ten.
*
* @pure
*/
public static function ten(): BigRational
{
/** @var BigRational|null $ten */
static $ten;
if ($ten === null) {
$ten = new BigRational(BigInteger::ten(), BigInteger::one(), false, false);
}
return $ten;
}
/**
* Returns the numerator of this rational number.
*
* @pure
*/
public function getNumerator(): BigInteger
{
return $this->numerator;
}
/**
* Returns the denominator of this rational number.
*
* The denominator is always strictly positive.
*
* @pure
*/
public function getDenominator(): BigInteger
{
return $this->denominator;
}
/**
* Returns the integral part of this rational number.
*
* Examples:
*
* - `7/3` returns `2` (since 7/3 = 2 + 1/3)
* - `-7/3` returns `-2` (since -7/3 = -2 + (-1/3))
*
* The following identity holds: `$r->isEqualTo($r->getFractionalPart()->plus($r->getIntegralPart()))`. Note that in
* this identity, the operand order is significant: the reversed form throws when the fractional part is non-zero.
*
* @pure
*/
public function getIntegralPart(): BigInteger
{
return $this->numerator->quotient($this->denominator);
}
/**
* Returns the fractional part of this rational number.
*
* Examples:
*
* - `7/3` returns `1/3` (since 7/3 = 2 + 1/3)
* - `-7/3` returns `-1/3` (since -7/3 = -2 + (-1/3))
*
* The following identity holds: `$r->isEqualTo($r->getFractionalPart()->plus($r->getIntegralPart()))`. Note that in
* this identity, the operand order is significant: the reversed form throws when the fractional part is non-zero.
*
* @pure
*/
public function getFractionalPart(): BigRational
{
return new BigRational($this->numerator->remainder($this->denominator), $this->denominator, false, false);
}
/**
* Returns the sum of this number and the given one.
*
* @param BigNumber|int|string $that The number to add.
*
* @throws MathException If the number is not valid.
*
* @pure
*/
public function plus(BigNumber|int|string $that): BigRational
{
$that = BigRational::of($that);
if ($that->isZero()) {
return $this;
}
if ($this->isZero()) {
return $that;
}
$numerator = $this->numerator->multipliedBy($that->denominator);
$numerator = $numerator->plus($that->numerator->multipliedBy($this->denominator));
$denominator = $this->denominator->multipliedBy($that->denominator);
return new BigRational($numerator, $denominator, false, true);
}
/**
* Returns the difference of this number and the given one.
*
* @param BigNumber|int|string $that The number to subtract.
*
* @throws MathException If the number is not valid.
*
* @pure
*/
public function minus(BigNumber|int|string $that): BigRational
{
$that = BigRational::of($that);
if ($that->isZero()) {
return $this;
}
if ($this->isZero()) {
return $that->negated();
}
$numerator = $this->numerator->multipliedBy($that->denominator);
$numerator = $numerator->minus($that->numerator->multipliedBy($this->denominator));
$denominator = $this->denominator->multipliedBy($that->denominator);
return new BigRational($numerator, $denominator, false, true);
}
/**
* Returns the product of this number and the given one.
*
* @param BigNumber|int|string $that The multiplier.
*
* @throws MathException If the multiplier is not valid.
*
* @pure
*/
public function multipliedBy(BigNumber|int|string $that): BigRational
{
$that = BigRational::of($that);
if ($that->isZero() || $this->isZero()) {
return BigRational::zero();
}
$numerator = $this->numerator->multipliedBy($that->numerator);
$denominator = $this->denominator->multipliedBy($that->denominator);
return new BigRational($numerator, $denominator, false, true);
}
/**
* Returns the result of the division of this number by the given one.
*
* @param BigNumber|int|string $that The divisor.
*
* @throws MathException If the divisor is not valid.
* @throws DivisionByZeroException If the divisor is zero.
*
* @pure
*/
public function dividedBy(BigNumber|int|string $that): BigRational
{
$that = BigRational::of($that);
if ($that->isZero()) {
throw DivisionByZeroException::divisionByZero();
}
$numerator = $this->numerator->multipliedBy($that->denominator);
$denominator = $this->denominator->multipliedBy($that->numerator);
return new BigRational($numerator, $denominator, true, true);
}
/**
* Returns this number exponentiated to the given value.
*
* Unlike BigInteger and BigDecimal, BigRational supports negative exponents:
* the result is the reciprocal raised to the absolute value of the exponent.
*
* @throws DivisionByZeroException If the exponent is negative and this number is zero.
*
* @pure
*/
public function power(int $exponent): BigRational
{
if ($exponent === 0) {
return BigRational::one();
}
if ($exponent === 1) {
return $this;
}
if ($exponent < 0) {
if ($this->isZero()) {
throw DivisionByZeroException::zeroToNegativePower();
}
return $this->reciprocal()->power(Safe::neg($exponent));
}
return new BigRational(
$this->numerator->power($exponent),
$this->denominator->power($exponent),
false,
false,
);
}
/**
* Returns the reciprocal of this BigRational.
*
* The reciprocal has the numerator and denominator swapped.
*
* @throws DivisionByZeroException If this number is zero.
*
* @pure
*/
public function reciprocal(): BigRational
{
if ($this->isZero()) {
throw DivisionByZeroException::reciprocalOfZero();
}
return new BigRational($this->denominator, $this->numerator, true, false);
}
#[Override]
public function negated(): static
{
return new BigRational($this->numerator->negated(), $this->denominator, false, false);
}
#[Override]
public function compareTo(BigNumber|int|string $that): int
{
$that = BigRational::of($that);
if ($this->denominator->isEqualTo($that->denominator)) {
return $this->numerator->compareTo($that->numerator);
}
return $this->numerator
->multipliedBy($that->denominator)
->compareTo($that->numerator->multipliedBy($this->denominator));
}
#[Override]
public function getSign(): int
{
return $this->numerator->getSign();
}
#[Override]
public function toBigInteger(): BigInteger
{
if ($this->denominator->isEqualTo(1)) {
return $this->numerator;
}
throw RoundingNecessaryException::rationalNotConvertibleToInteger();
}
#[Override]
public function toBigDecimal(): BigDecimal
{
$scale = DecimalHelper::computeScaleFromReducedFractionDenominator($this->denominator->toString());
if ($scale === null) {
throw RoundingNecessaryException::rationalNotConvertibleToDecimal();
}
return $this->numerator->toBigDecimal()->dividedBy($this->denominator, $scale)->strippedOfTrailingZeros();
}
#[Override]
public function toBigRational(): BigRational
{
return $this;
}
#[Override]
public function toScale(int $scale, RoundingMode $roundingMode = RoundingMode::Unnecessary): BigDecimal
{
if ($scale < 0) { // @phpstan-ignore smaller.alwaysFalse
throw InvalidArgumentException::negativeScale();
}
if ($roundingMode === RoundingMode::Unnecessary) {
$requiredScale = DecimalHelper::computeScaleFromReducedFractionDenominator($this->denominator->toString());
if ($requiredScale === null) {
throw RoundingNecessaryException::rationalNotConvertibleToDecimal();
}
if ($requiredScale > $scale) {
throw RoundingNecessaryException::rationalScaleTooSmall();
}
}
return $this->numerator->toBigDecimal()->dividedBy($this->denominator, $scale, $roundingMode);
}
#[Override]
public function toInt(): int
{
return $this->toBigInteger()->toInt();
}
#[Override]
public function toFloat(): float
{
if ($this->denominator->isEqualTo(1)) {
return $this->numerator->toFloat();
}
// Avoid $this->numerator->toFloat() / $this->denominator->toFloat(): converting both operands to float first
// adds an extra rounding step before the division and can change the final float. Instead, divide in decimal
// first and convert the resulting decimal approximation to float once.
// We need ~17 significant digits for double precision (we use 20 for some margin). Since $scale controls
// decimal places (not significant digits), we subtract the estimated order of magnitude so that large results
// use fewer decimal places and small results use more (to look past leading zeros). Clamped to [0, 350] as
// doubles range from e-324 to e308 (350 ≈ 324 + 20 significant digits + margin).
$magnitude = strlen($this->numerator->abs()->toString()) - strlen($this->denominator->toString());
$scale = min(350, max(0, 20 - $magnitude));
$result = $this->numerator
->toBigDecimal()
->dividedBy($this->denominator, $scale, RoundingMode::HalfEven)
->toFloat();
// Preserve the sign when the decimal approximation underflows to zero.
if ($result === 0.0 && $this->numerator->isNegative()) {
return -0.0;
}
return $result;
}
#[Override]
public function toString(): string
{
$numerator = $this->numerator->toString();
$denominator = $this->denominator->toString();
if ($denominator === '1') {
return $numerator;
}
return $numerator . '/' . $denominator;
}
/**
* Returns the decimal representation of this rational number, with repeating decimals in parentheses.
*
* WARNING: This method is unbounded.
* The length of the repeating decimal period can be as large as `denominator - 1`.
* For fractions with large denominators, this method can use excessive memory and CPU time.
* For example, `1/100019` has a repeating period of 100,018 digits.
*
* Examples:
*
* - `10/3` returns `3.(3)`
* - `171/70` returns `2.4(428571)`
* - `1/2` returns `0.5`
*
* @pure
*/
public function toRepeatingDecimalString(): string
{
if ($this->isZero()) {
return '0';
}
$sign = $this->numerator->isNegative() ? '-' : '';
$numerator = $this->numerator->abs();
$denominator = $this->denominator;
$integral = $numerator->quotient($denominator);
$remainder = $numerator->remainder($denominator);
$integralString = $integral->toString();
if ($remainder->isZero()) {
return $sign . $integralString;
}
$digits = '';
$remainderPositions = [];
$index = 0;
while (! $remainder->isZero()) {
$remainderString = $remainder->toString();
if (isset($remainderPositions[$remainderString])) {
$repeatIndex = $remainderPositions[$remainderString];
$nonRepeating = substr($digits, 0, $repeatIndex);
$repeating = substr($digits, $repeatIndex);
return $sign . $integralString . '.' . $nonRepeating . '(' . $repeating . ')';
}
$remainderPositions[$remainderString] = $index;
$remainder = $remainder->multipliedBy(10);
$digits .= $remainder->quotient($denominator)->toString();
$remainder = $remainder->remainder($denominator);
$index++;
}
return $sign . $integralString . '.' . $digits;
}
/**
* This method is required for serializing the object and SHOULD NOT be accessed directly.
*
* @internal
*
* @return array{numerator: BigInteger, denominator: BigInteger}
*/
public function __serialize(): array
{
return ['numerator' => $this->numerator, 'denominator' => $this->denominator];
}
/**
* This method is only here to allow unserializing the object and cannot be accessed directly.
*
* @internal
*
* @param array{numerator: BigInteger, denominator: BigInteger} $data
*
* @throws LogicException
*/
public function __unserialize(array $data): void
{
/** @phpstan-ignore isset.initializedProperty */
if (isset($this->numerator)) {
throw new LogicException('__unserialize() is an internal function, it must not be called directly.');
}
/** @phpstan-ignore deadCode.unreachable */
$this->numerator = $data['numerator'];
$this->denominator = $data['denominator'];
}
#[Override]
protected static function from(BigNumber $number): static
{
return $number->toBigRational();
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use RuntimeException;
/**
* Exception thrown when a division by zero occurs.
*/
final class DivisionByZeroException extends RuntimeException implements MathException
{
/**
* @internal
*
* @pure
*/
public function __construct(string $message)
{
parent::__construct($message);
}
/**
* @internal
*
* @pure
*/
public static function divisionByZero(): self
{
return new self('Division by zero.');
}
/**
* @internal
*
* @pure
*/
public static function zeroModulus(): self
{
return new self('The modulus must not be zero.');
}
/**
* @internal
*
* @pure
*/
public static function zeroDenominator(): self
{
return new self('The denominator of a rational number must not be zero.');
}
/**
* @internal
*
* @pure
*/
public static function reciprocalOfZero(): self
{
return new self('The reciprocal of zero is undefined.');
}
/**
* @internal
*
* @pure
*/
public static function zeroToNegativePower(): self
{
return new self('Cannot raise zero to a negative power.');
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use Brick\Math\BigInteger;
use RuntimeException;
use function sprintf;
use const PHP_INT_MAX;
use const PHP_INT_MIN;
/**
* Exception thrown when a native integer overflow occurs.
*/
final class IntegerOverflowException extends RuntimeException implements MathException
{
/**
* @internal
*
* @pure
*/
public function __construct(string $message)
{
parent::__construct($message);
}
/**
* @internal
*
* @pure
*/
public static function integerOutOfRange(BigInteger $value): self
{
$message = '%s is out of range [%d, %d] and cannot be represented as an integer.';
return new self(sprintf($message, $value->toString(), PHP_INT_MIN, PHP_INT_MAX));
}
/**
* @internal
*
* @pure
*/
public static function nativeIntegerOverflow(string $expression): self
{
return new self(sprintf(
'Cannot compute %s because the result is outside the native integer range [%d, %d].',
$expression,
PHP_INT_MIN,
PHP_INT_MAX,
));
}
}

View File

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use function sprintf;
/**
* Exception thrown when an invalid argument is provided.
*/
final class InvalidArgumentException extends \InvalidArgumentException implements MathException
{
/**
* @internal
*
* @pure
*/
public function __construct(string $message)
{
parent::__construct($message);
}
/**
* @internal
*
* @pure
*/
public static function baseOutOfRange(int $base): self
{
return new self(sprintf('Base %d is out of range [2, 36].', $base));
}
/**
* @internal
*
* @pure
*/
public static function negativeScale(): self
{
return new self('The scale must not be negative.');
}
/**
* @internal
*
* @pure
*/
public static function negativeBitIndex(): self
{
return new self('The bit index must not be negative.');
}
/**
* @internal
*
* @pure
*/
public static function negativeBitCount(): self
{
return new self('The bit count must not be negative.');
}
/**
* @internal
*
* @pure
*/
public static function alphabetTooShort(): self
{
return new self('The alphabet must contain at least 2 characters.');
}
/**
* @internal
*
* @pure
*/
public static function duplicateCharsInAlphabet(): self
{
return new self('The alphabet must not contain duplicate characters.');
}
/**
* @internal
*
* @pure
*/
public static function minGreaterThanMax(): self
{
return new self('The minimum value must be less than or equal to the maximum value.');
}
/**
* @internal
*
* @pure
*/
public static function cannotConvertFloat(string $type): self
{
return new self(sprintf('Cannot convert %s to a BigDecimal.', $type));
}
/**
* @internal
*
* @pure
*/
public static function negativeExponent(): self
{
return new self('The exponent must not be negative.');
}
/**
* @internal
*
* @pure
*/
public static function negativeModulus(): self
{
return new self('The modulus must not be negative.');
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use Throwable;
/**
* Base interface for all math exceptions.
*/
interface MathException extends Throwable
{
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use RuntimeException;
/**
* Exception thrown when attempting to perform an unsupported operation, such as a square root, on a negative number.
*/
final class NegativeNumberException extends RuntimeException implements MathException
{
/**
* @internal
*
* @pure
*/
public function __construct(string $message)
{
parent::__construct($message);
}
/**
* @internal
*
* @pure
*/
public static function squareRootOfNegativeNumber(): self
{
return new self('Cannot calculate the square root of a negative number.');
}
/**
* @internal
*
* @pure
*/
public static function toArbitraryBaseOfNegativeNumber(): self
{
return new self('Cannot convert a negative number to an arbitrary base.');
}
/**
* @internal
*
* @pure
*/
public static function unsignedBytesOfNegativeNumber(): self
{
return new self('Cannot convert a negative number to a byte string in unsigned mode.');
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use RuntimeException;
/**
* Exception thrown when attempting to compute a modular inverse that does not exist.
*/
final class NoInverseException extends RuntimeException implements MathException
{
/**
* @internal
*
* @pure
*/
public function __construct(string $message)
{
parent::__construct($message);
}
/**
* @internal
*
* @pure
*/
public static function noModularInverse(): self
{
return new self('This number has no multiplicative inverse modulo the given modulus (they are not coprime).');
}
}

View File

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use RuntimeException;
use function dechex;
use function ord;
use function sprintf;
use function strtoupper;
/**
* Exception thrown when attempting to create a number from a string with an invalid format.
*/
final class NumberFormatException extends RuntimeException implements MathException
{
/**
* @internal
*
* @pure
*/
public function __construct(string $message)
{
parent::__construct($message);
}
/**
* @internal
*
* @pure
*/
public static function invalidFormat(string $value): self
{
return new self(sprintf(
'Value "%s" does not represent a valid number.',
$value,
));
}
/**
* @internal
*
* @param string $char The failing character.
*
* @pure
*/
public static function charNotInAlphabet(string $char): self
{
return new self(sprintf(
'Character %s is not valid in the given alphabet.',
self::charToString($char),
));
}
/**
* @internal
*
* @pure
*/
public static function charNotValidInBase(string $char, int $base): self
{
return new self(sprintf(
'Character %s is not valid in base %d.',
self::charToString($char),
$base,
));
}
/**
* @internal
*
* @pure
*/
public static function emptyNumber(): self
{
return new self('The number must not be empty.');
}
/**
* @internal
*
* @pure
*/
public static function emptyByteString(): self
{
return new self('The byte string must not be empty.');
}
/**
* @internal
*
* @pure
*/
public static function exponentTooLarge(): self
{
return new self('The exponent is too large to be represented as an integer.');
}
/**
* @pure
*/
private static function charToString(string $char): string
{
$ord = ord($char);
if ($ord < 32 || $ord > 126) {
$char = strtoupper(dechex($ord));
if ($ord < 16) {
$char = '0' . $char;
}
return '0x' . $char;
}
return '"' . $char . '"';
}
}

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use RuntimeException;
use Throwable;
use function get_debug_type;
use function sprintf;
/**
* Exception thrown when random byte generation fails.
*/
final class RandomSourceException extends RuntimeException implements MathException
{
/**
* @internal
*
* @pure
*/
public function __construct(string $message, ?Throwable $previous = null)
{
parent::__construct($message, 0, $previous);
}
/**
* @internal
*
* @pure
*/
public static function randomSourceFailure(Throwable $previous): self
{
return new self('Random byte generation failed.', $previous);
}
/**
* @internal
*
* @pure
*/
public static function invalidRandomBytesType(mixed $value): self
{
return new self(sprintf(
'The random bytes generator must return a string, got %s.',
get_debug_type($value),
));
}
/**
* @internal
*
* @pure
*/
public static function invalidRandomBytesLength(int $expectedLength, int $actualLength): self
{
return new self(sprintf(
'The random bytes generator returned %d byte(s), expected %d.',
$actualLength,
$expectedLength,
));
}
}

View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use RuntimeException;
/**
* Exception thrown when a number cannot be represented at the requested scale without rounding.
*/
final class RoundingNecessaryException extends RuntimeException implements MathException
{
/**
* @internal
*
* @pure
*/
public function __construct(string $message)
{
parent::__construct($message);
}
/**
* @internal
*
* @pure
*/
public static function decimalScaleTooSmall(): self
{
return new self('This decimal number cannot be represented at the requested scale without rounding.');
}
/**
* @internal
*
* @pure
*/
public static function rationalScaleTooSmall(): self
{
return new self('This rational number cannot be represented at the requested scale without rounding.');
}
/**
* @internal
*
* @pure
*/
public static function integerDivisionNotExact(): self
{
return new self('The division has a non-zero remainder and cannot be represented as an integer without rounding.');
}
/**
* @internal
*
* @pure
*/
public static function decimalDivisionNotExact(): self
{
return new self('The division yields a non-terminating decimal expansion and cannot be represented as a decimal without rounding.');
}
/**
* @internal
*
* @pure
*/
public static function decimalDivisionScaleTooSmall(): self
{
return new self('The division result is exact but cannot be represented at the requested scale without rounding.');
}
/**
* @internal
*
* @pure
*/
public static function integerSquareRootNotExact(): self
{
return new self('The square root is not exact and cannot be represented as an integer without rounding.');
}
/**
* @internal
*
* @pure
*/
public static function decimalSquareRootNotExact(): self
{
return new self('The square root is not exact and cannot be represented as a decimal without rounding.');
}
/**
* @internal
*
* @pure
*/
public static function decimalSquareRootScaleTooSmall(): self
{
return new self('The square root is exact but cannot be represented at the requested scale without rounding.');
}
/**
* @internal
*
* @pure
*/
public static function decimalNotConvertibleToInteger(): self
{
return new self('This decimal number cannot be represented as an integer without rounding.');
}
/**
* @internal
*
* @pure
*/
public static function rationalNotConvertibleToInteger(): self
{
return new self('This rational number cannot be represented as an integer without rounding.');
}
/**
* @internal
*
* @pure
*/
public static function rationalNotConvertibleToDecimal(): self
{
return new self('This rational number has a non-terminating decimal expansion and cannot be represented as a decimal without rounding.');
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Exception;
use RuntimeException;
/**
* Exception thrown when the current PHP platform does not support a required feature.
*/
final class UnsupportedPlatformException extends RuntimeException implements MathException
{
/**
* @internal
*
* @pure
*/
public function __construct(string $message)
{
parent::__construct($message);
}
/**
* @internal
*
* @pure
*/
public static function unsupportedFloatFormat(): self
{
return new self('Unsupported float format: expected IEEE-754 double.');
}
}

View File

@ -0,0 +1,697 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal;
use Brick\Math\RoundingMode;
use function chr;
use function ltrim;
use function ord;
use function str_repeat;
use function strlen;
use function strpos;
use function strrev;
use function strtolower;
use function substr;
/**
* Performs basic operations on arbitrary size integers.
*
* Unless otherwise specified, all parameters must be validated as non-empty strings of digits,
* without leading zero, and with an optional leading minus sign if the number is not zero.
*
* Any other parameter format will lead to undefined behaviour.
* All methods must return strings respecting this format, unless specified otherwise.
*
* @internal
*/
abstract readonly class Calculator
{
/**
* The alphabet for converting from and to base 2 to 36, lowercase.
*/
public const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
/**
* Returns the absolute value of a number.
*
* @pure
*/
final public function abs(string $n): string
{
return ($n[0] === '-') ? substr($n, 1) : $n;
}
/**
* Negates a number.
*
* @pure
*/
final public function neg(string $n): string
{
if ($n === '0') {
return '0';
}
if ($n[0] === '-') {
return substr($n, 1);
}
return '-' . $n;
}
/**
* Compares two numbers.
*
* Returns -1 if the first number is less than, 0 if equal to, 1 if greater than the second number.
*
* @return -1|0|1
*
* @pure
*/
final public function cmp(string $a, string $b): int
{
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
if ($aNeg && ! $bNeg) {
return -1;
}
if ($bNeg && ! $aNeg) {
return 1;
}
$aLen = strlen($aDig);
$bLen = strlen($bDig);
if ($aLen < $bLen) {
$result = -1;
} elseif ($aLen > $bLen) {
$result = 1;
} else {
$result = $aDig <=> $bDig;
}
return $aNeg ? -$result : $result;
}
/**
* Adds two numbers.
*
* @pure
*/
abstract public function add(string $a, string $b): string;
/**
* Subtracts two numbers.
*
* @pure
*/
abstract public function sub(string $a, string $b): string;
/**
* Multiplies two numbers.
*
* @pure
*/
abstract public function mul(string $a, string $b): string;
/**
* Returns the quotient of the division of two numbers.
*
* @param string $a The dividend.
* @param string $b The divisor, must not be zero.
*
* @return string The quotient.
*
* @pure
*/
abstract public function divQ(string $a, string $b): string;
/**
* Returns the remainder of the division of two numbers.
*
* @param string $a The dividend.
* @param string $b The divisor, must not be zero.
*
* @return string The remainder.
*
* @pure
*/
abstract public function divR(string $a, string $b): string;
/**
* Returns the quotient and remainder of the division of two numbers.
*
* @param string $a The dividend.
* @param string $b The divisor, must not be zero.
*
* @return array{string, string} An array containing the quotient and remainder.
*
* @pure
*/
abstract public function divQR(string $a, string $b): array;
/**
* Exponentiates a number.
*
* @param string $a The base number.
* @param int $e The exponent, validated as a non-negative integer.
*
* @return string The power.
*
* @pure
*/
abstract public function pow(string $a, int $e): string;
/**
* @param string $b The modulus; must not be zero.
*
* @pure
*/
public function mod(string $a, string $b): string
{
return $this->divR($this->add($this->divR($a, $b), $b), $b);
}
/**
* Returns the modular multiplicative inverse of $x modulo $m.
*
* If $x has no multiplicative inverse mod m, this method must return null.
*
* This method can be overridden by the concrete implementation if the underlying library has built-in support.
*
* @param string $m The modulus; must not be negative or zero.
*
* @pure
*/
public function modInverse(string $x, string $m): ?string
{
if ($m === '1') {
return '0';
}
$modVal = $x;
if ($x[0] === '-' || ($this->cmp($this->abs($x), $m) >= 0)) {
$modVal = $this->mod($x, $m);
}
[$g, $x] = $this->gcdExtended($modVal, $m);
if ($g !== '1') {
return null;
}
return $this->mod($this->add($this->mod($x, $m), $m), $m);
}
/**
* Raises a number into power with modulo.
*
* @param string $base The base number.
* @param string $exp The exponent; must be positive or zero.
* @param string $mod The modulus; must be strictly positive.
*
* @pure
*/
abstract public function modPow(string $base, string $exp, string $mod): string;
/**
* Returns the greatest common divisor of the two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for GCD calculations.
*
* @return string The GCD, always positive, or zero if both arguments are zero.
*
* @pure
*/
public function gcd(string $a, string $b): string
{
if ($a === '0') {
return $this->abs($b);
}
if ($b === '0') {
return $this->abs($a);
}
return $this->gcd($b, $this->divR($a, $b));
}
/**
* Returns the least common multiple of the two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for LCM calculations.
*
* @return string The LCM, always positive, or zero if at least one argument is zero.
*
* @pure
*/
public function lcm(string $a, string $b): string
{
if ($a === '0' || $b === '0') {
return '0';
}
return $this->divQ($this->abs($this->mul($a, $b)), $this->gcd($a, $b));
}
/**
* Returns the square root of the given number, rounded down.
*
* The result is the largest x such that n.
* The input MUST NOT be negative.
*
* @pure
*/
abstract public function sqrt(string $n): string;
/**
* Converts a number from an arbitrary base.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for base conversion.
*
* @param string $number The number, positive or zero, non-empty, case-insensitively validated for the given base.
* @param int $base The base of the number, validated from 2 to 36.
*
* @return string The converted number, following the Calculator conventions.
*
* @pure
*/
public function fromBase(string $number, int $base): string
{
return $this->fromArbitraryBase(strtolower($number), self::ALPHABET, $base);
}
/**
* Converts a number to an arbitrary base.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for base conversion.
*
* @param string $number The number to convert, following the Calculator conventions.
* @param int $base The base to convert to, validated from 2 to 36.
*
* @return string The converted number, lowercase.
*
* @pure
*/
public function toBase(string $number, int $base): string
{
$negative = ($number[0] === '-');
if ($negative) {
$number = substr($number, 1);
}
$number = $this->toArbitraryBase($number, self::ALPHABET, $base);
if ($negative) {
return '-' . $number;
}
return $number;
}
/**
* Converts a non-negative number in an arbitrary base using a custom alphabet, to base 10.
*
* @param string $number The number to convert, validated as a non-empty string,
* containing only chars in the given alphabet/base.
* @param string $alphabet The alphabet that contains every digit, validated as 2 chars minimum.
* @param int $base The base of the number, validated from 2 to alphabet length.
*
* @return string The number in base 10, following the Calculator conventions.
*
* @pure
*/
final public function fromArbitraryBase(string $number, string $alphabet, int $base): string
{
// remove leading "zeros"
$number = ltrim($number, $alphabet[0]);
if ($number === '') {
return '0';
}
// optimize for "one"
if ($number === $alphabet[1]) {
return '1';
}
$result = '0';
$power = '1';
$base = (string) $base;
for ($i = strlen($number) - 1; $i >= 0; $i--) {
$index = strpos($alphabet, $number[$i]);
if ($index !== 0) {
$result = $this->add(
$result,
($index === 1) ? $power : $this->mul($power, (string) $index),
);
}
if ($i !== 0) {
$power = $this->mul($power, $base);
}
}
return $result;
}
/**
* Converts a non-negative number to an arbitrary base using a custom alphabet.
*
* @param string $number The number to convert, positive or zero, following the Calculator conventions.
* @param string $alphabet The alphabet that contains every digit, validated as 2 chars minimum.
* @param int $base The base to convert to, validated from 2 to alphabet length.
*
* @return string The converted number in the given alphabet.
*
* @pure
*/
final public function toArbitraryBase(string $number, string $alphabet, int $base): string
{
if ($number === '0') {
return $alphabet[0];
}
$base = (string) $base;
$result = '';
while ($number !== '0') {
[$number, $remainder] = $this->divQR($number, $base);
$remainder = (int) $remainder;
$result .= $alphabet[$remainder];
}
return strrev($result);
}
/**
* Performs a rounded division.
*
* When the remainder of the division is not zero, rounding is performed according to the rounding mode provided,
* unless RoundingMode::Unnecessary is used, in which case the method returns null.
*
* @param string $a The dividend.
* @param string $b The divisor, must not be zero.
* @param RoundingMode $roundingMode The rounding mode.
*
* @pure
*/
final public function divRound(string $a, string $b, RoundingMode $roundingMode): ?string
{
[$quotient, $remainder] = $this->divQR($a, $b);
$hasDiscardedFraction = ($remainder !== '0');
$isPositiveOrZero = ($a[0] === '-') === ($b[0] === '-');
$discardedFractionSign = function () use ($remainder, $b): int {
$r = $this->abs($this->mul($remainder, '2'));
$b = $this->abs($b);
return $this->cmp($r, $b);
};
$increment = false;
switch ($roundingMode) {
case RoundingMode::Unnecessary:
if ($hasDiscardedFraction) {
return null;
}
break;
case RoundingMode::Up:
$increment = $hasDiscardedFraction;
break;
case RoundingMode::Down:
break;
case RoundingMode::Ceiling:
$increment = $hasDiscardedFraction && $isPositiveOrZero;
break;
case RoundingMode::Floor:
$increment = $hasDiscardedFraction && ! $isPositiveOrZero;
break;
case RoundingMode::HalfUp:
$increment = $discardedFractionSign() >= 0;
break;
case RoundingMode::HalfDown:
$increment = $discardedFractionSign() > 0;
break;
case RoundingMode::HalfCeiling:
$increment = $isPositiveOrZero ? $discardedFractionSign() >= 0 : $discardedFractionSign() > 0;
break;
case RoundingMode::HalfFloor:
$increment = $isPositiveOrZero ? $discardedFractionSign() > 0 : $discardedFractionSign() >= 0;
break;
case RoundingMode::HalfEven:
$lastDigit = (int) $quotient[-1];
$lastDigitIsEven = ($lastDigit % 2 === 0);
$increment = $lastDigitIsEven ? $discardedFractionSign() > 0 : $discardedFractionSign() >= 0;
break;
}
if ($increment) {
return $this->add($quotient, $isPositiveOrZero ? '1' : '-1');
}
return $quotient;
}
/**
* Calculates bitwise AND of two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for bitwise operations.
*
* @pure
*/
public function and(string $a, string $b): string
{
return $this->bitwise('and', $a, $b);
}
/**
* Calculates bitwise OR of two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for bitwise operations.
*
* @pure
*/
public function or(string $a, string $b): string
{
return $this->bitwise('or', $a, $b);
}
/**
* Calculates bitwise XOR of two numbers.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for bitwise operations.
*
* @pure
*/
public function xor(string $a, string $b): string
{
return $this->bitwise('xor', $a, $b);
}
/**
* Extracts the sign & digits of the operands.
*
* @return array{bool, bool, string, string} Whether $a and $b are negative, followed by their digits.
*
* @pure
*/
final protected function init(string $a, string $b): array
{
return [
$aNeg = ($a[0] === '-'),
$bNeg = ($b[0] === '-'),
$aNeg ? substr($a, 1) : $a,
$bNeg ? substr($b, 1) : $b,
];
}
/**
* @return array{string, string, string} GCD, X, Y
*
* @pure
*/
private function gcdExtended(string $a, string $b): array
{
if ($a === '0') {
return [$b, '0', '1'];
}
[$gcd, $x1, $y1] = $this->gcdExtended($this->mod($b, $a), $a);
$x = $this->sub($y1, $this->mul($this->divQ($b, $a), $x1));
$y = $x1;
return [$gcd, $x, $y];
}
/**
* Performs a bitwise operation on a decimal number.
*
* @param 'and'|'or'|'xor' $operator The operator to use.
* @param string $a The left operand.
* @param string $b The right operand.
*
* @pure
*/
private function bitwise(string $operator, string $a, string $b): string
{
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
$aBin = $this->toBinary($aDig);
$bBin = $this->toBinary($bDig);
$aLen = strlen($aBin);
$bLen = strlen($bBin);
if ($aLen > $bLen) {
$bBin = str_repeat("\x00", $aLen - $bLen) . $bBin;
} elseif ($bLen > $aLen) {
$aBin = str_repeat("\x00", $bLen - $aLen) . $aBin;
}
if ($aNeg) {
$aBin = $this->twosComplement($aBin);
}
if ($bNeg) {
$bBin = $this->twosComplement($bBin);
}
$value = match ($operator) {
'and' => $aBin & $bBin,
'or' => $aBin | $bBin,
'xor' => $aBin ^ $bBin,
};
$negative = match ($operator) {
'and' => $aNeg and $bNeg,
'or' => $aNeg or $bNeg,
'xor' => $aNeg xor $bNeg,
};
if ($negative) {
$value = $this->twosComplement($value);
}
$result = $this->toDecimal($value);
return $negative ? $this->neg($result) : $result;
}
/**
* @param string $number A positive, binary number.
*
* @pure
*/
private function twosComplement(string $number): string
{
$xor = str_repeat("\xff", strlen($number));
$number ^= $xor;
for ($i = strlen($number) - 1; $i >= 0; $i--) {
$byte = ord($number[$i]);
if (++$byte !== 256) {
$number[$i] = chr($byte);
break;
}
$number[$i] = "\x00";
if ($i === 0) {
$number = "\x01" . $number;
}
}
return $number;
}
/**
* Converts a decimal number to a binary string.
*
* @param string $number The number to convert, positive or zero, only digits.
*
* @pure
*/
private function toBinary(string $number): string
{
$result = '';
while ($number !== '0') {
[$number, $remainder] = $this->divQR($number, '256');
$result .= chr((int) $remainder);
}
return strrev($result);
}
/**
* Returns the positive decimal representation of a binary number.
*
* @param string $bytes The bytes representing the number.
*
* @pure
*/
private function toDecimal(string $bytes): string
{
$result = '0';
$power = '1';
for ($i = strlen($bytes) - 1; $i >= 0; $i--) {
$index = ord($bytes[$i]);
if ($index !== 0) {
$result = $this->add(
$result,
($index === 1) ? $power : $this->mul($power, (string) $index),
);
}
if ($i !== 0) {
$power = $this->mul($power, '256');
}
}
return $result;
}
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal\Calculator;
use Brick\Math\Internal\Calculator;
use Override;
use function bcadd;
use function bcdiv;
use function bcmod;
use function bcmul;
use function bcpow;
use function bcpowmod;
use function bcsqrt;
use function bcsub;
/**
* Calculator implementation built around the bcmath library.
*
* @internal
*/
final readonly class BcMathCalculator extends Calculator
{
#[Override]
public function add(string $a, string $b): string
{
return bcadd($a, $b, 0);
}
#[Override]
public function sub(string $a, string $b): string
{
return bcsub($a, $b, 0);
}
#[Override]
public function mul(string $a, string $b): string
{
return bcmul($a, $b, 0);
}
#[Override]
public function divQ(string $a, string $b): string
{
return bcdiv($a, $b, 0);
}
#[Override]
public function divR(string $a, string $b): string
{
return bcmod($a, $b, 0);
}
#[Override]
public function divQR(string $a, string $b): array
{
$q = bcdiv($a, $b, 0);
$r = bcmod($a, $b, 0);
return [$q, $r];
}
#[Override]
public function pow(string $a, int $e): string
{
return bcpow($a, (string) $e, 0);
}
#[Override]
public function modPow(string $base, string $exp, string $mod): string
{
// normalize to Euclidean representative so modPow() stays consistent with mod()
$base = $this->mod($base, $mod);
return bcpowmod($base, $exp, $mod, 0);
}
#[Override]
public function sqrt(string $n): string
{
return bcsqrt($n, 0);
}
}

View File

@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal\Calculator;
use Brick\Math\Internal\Calculator;
use GMP;
use Override;
use function gmp_add;
use function gmp_and;
use function gmp_div_q;
use function gmp_div_qr;
use function gmp_div_r;
use function gmp_gcd;
use function gmp_init;
use function gmp_invert;
use function gmp_lcm;
use function gmp_mul;
use function gmp_or;
use function gmp_pow;
use function gmp_powm;
use function gmp_sqrt;
use function gmp_strval;
use function gmp_sub;
use function gmp_xor;
/**
* Calculator implementation built around the GMP library.
*
* @internal
*/
final readonly class GmpCalculator extends Calculator
{
#[Override]
public function add(string $a, string $b): string
{
return gmp_strval(gmp_add($a, $b));
}
#[Override]
public function sub(string $a, string $b): string
{
return gmp_strval(gmp_sub($a, $b));
}
#[Override]
public function mul(string $a, string $b): string
{
return gmp_strval(gmp_mul($a, $b));
}
#[Override]
public function divQ(string $a, string $b): string
{
return gmp_strval(gmp_div_q($a, $b));
}
#[Override]
public function divR(string $a, string $b): string
{
return gmp_strval(gmp_div_r($a, $b));
}
#[Override]
public function divQR(string $a, string $b): array
{
[$q, $r] = gmp_div_qr($a, $b);
/**
* @var GMP $q
* @var GMP $r
*/
return [
gmp_strval($q),
gmp_strval($r),
];
}
#[Override]
public function pow(string $a, int $e): string
{
return gmp_strval(gmp_pow($a, $e));
}
#[Override]
public function modInverse(string $x, string $m): ?string
{
$result = gmp_invert($x, $m);
if ($result === false) {
return null;
}
return gmp_strval($result);
}
#[Override]
public function modPow(string $base, string $exp, string $mod): string
{
return gmp_strval(gmp_powm($base, $exp, $mod));
}
#[Override]
public function gcd(string $a, string $b): string
{
return gmp_strval(gmp_gcd($a, $b));
}
#[Override]
public function lcm(string $a, string $b): string
{
return gmp_strval(gmp_lcm($a, $b));
}
#[Override]
public function fromBase(string $number, int $base): string
{
return gmp_strval(gmp_init($number, $base));
}
#[Override]
public function toBase(string $number, int $base): string
{
return gmp_strval($number, $base);
}
#[Override]
public function and(string $a, string $b): string
{
return gmp_strval(gmp_and($a, $b));
}
#[Override]
public function or(string $a, string $b): string
{
return gmp_strval(gmp_or($a, $b));
}
#[Override]
public function xor(string $a, string $b): string
{
return gmp_strval(gmp_xor($a, $b));
}
#[Override]
public function sqrt(string $n): string
{
return gmp_strval(gmp_sqrt($n));
}
}

View File

@ -0,0 +1,616 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal\Calculator;
use Brick\Math\Internal\Calculator;
use Override;
use function assert;
use function in_array;
use function intdiv;
use function is_int;
use function ltrim;
use function str_pad;
use function str_repeat;
use function strcmp;
use function strlen;
use function substr;
use const PHP_INT_SIZE;
use const STR_PAD_LEFT;
/**
* Calculator implementation using only native PHP code.
*
* @internal
*/
final readonly class NativeCalculator extends Calculator
{
/**
* The max number of digits the platform can natively add, subtract, multiply or divide without overflow.
* For multiplication, this represents the max sum of the lengths of both operands.
*
* In addition, it is assumed that an extra digit can hold a carry (1) without overflowing.
* Example: 32-bit: max number 1,999,999,999 (9 digits + carry)
* 64-bit: max number 1,999,999,999,999,999,999 (18 digits + carry)
*/
private int $maxDigits;
/**
* @pure
*
* @codeCoverageIgnore
*/
public function __construct()
{
$this->maxDigits = match (PHP_INT_SIZE) {
4 => 9,
8 => 18,
};
}
#[Override]
public function add(string $a, string $b): string
{
/**
* @var numeric-string $a
* @var numeric-string $b
*/
$result = $a + $b;
if (is_int($result)) {
return (string) $result;
}
if ($a === '0') {
return $b;
}
if ($b === '0') {
return $a;
}
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
$result = $aNeg === $bNeg ? $this->doAdd($aDig, $bDig) : $this->doSub($aDig, $bDig);
if ($aNeg) {
$result = $this->neg($result);
}
return $result;
}
#[Override]
public function sub(string $a, string $b): string
{
return $this->add($a, $this->neg($b));
}
#[Override]
public function mul(string $a, string $b): string
{
/**
* @var numeric-string $a
* @var numeric-string $b
*/
$result = $a * $b;
if (is_int($result)) {
return (string) $result;
}
if ($a === '0' || $b === '0') {
return '0';
}
if ($a === '1') {
return $b;
}
if ($b === '1') {
return $a;
}
if ($a === '-1') {
return $this->neg($b);
}
if ($b === '-1') {
return $this->neg($a);
}
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
$result = $this->doMul($aDig, $bDig);
if ($aNeg !== $bNeg) {
$result = $this->neg($result);
}
return $result;
}
#[Override]
public function divQ(string $a, string $b): string
{
return $this->divQR($a, $b)[0];
}
#[Override]
public function divR(string $a, string $b): string
{
return $this->divQR($a, $b)[1];
}
#[Override]
public function divQR(string $a, string $b): array
{
if ($a === '0') {
return ['0', '0'];
}
if ($a === $b) {
return ['1', '0'];
}
if ($b === '1') {
return [$a, '0'];
}
if ($b === '-1') {
return [$this->neg($a), '0'];
}
/** @var numeric-string $a */
$na = $a * 1; // cast to number
if (is_int($na)) {
/** @var numeric-string $b */
$nb = $b * 1;
if (is_int($nb)) {
// the only division that may overflow is PHP_INT_MIN / -1,
// which cannot happen here as we've already handled a divisor of -1 above.
$q = intdiv($na, $nb);
$r = $na % $nb;
return [
(string) $q,
(string) $r,
];
}
}
[$aNeg, $bNeg, $aDig, $bDig] = $this->init($a, $b);
[$q, $r] = $this->doDiv($aDig, $bDig);
if ($aNeg !== $bNeg) {
$q = $this->neg($q);
}
if ($aNeg) {
$r = $this->neg($r);
}
return [$q, $r];
}
#[Override]
public function pow(string $a, int $e): string
{
if ($e === 0) {
return '1';
}
if ($e === 1) {
return $a;
}
$odd = $e % 2;
$e -= $odd;
$aa = $this->mul($a, $a);
$result = $this->pow($aa, $e / 2);
if ($odd === 1) {
$result = $this->mul($result, $a);
}
return $result;
}
/**
* Algorithm from: https://www.geeksforgeeks.org/modular-exponentiation-power-in-modular-arithmetic/.
*/
#[Override]
public function modPow(string $base, string $exp, string $mod): string
{
// normalize to Euclidean representative so modPow() stays consistent with mod()
$base = $this->mod($base, $mod);
// special case: the algorithm below fails with power 0 mod 1 (returns 1 instead of 0)
if ($exp === '0' && $mod === '1') {
return '0';
}
$x = $base;
$res = '1';
// numbers are positive, so we can use remainder instead of modulo
$x = $this->divR($x, $mod);
while ($exp !== '0') {
if (in_array($exp[-1], ['1', '3', '5', '7', '9'])) { // odd
$res = $this->divR($this->mul($res, $x), $mod);
}
$exp = $this->divQ($exp, '2');
$x = $this->divR($this->mul($x, $x), $mod);
}
return $res;
}
/**
* Adapted from https://cp-algorithms.com/num_methods/roots_newton.html.
*/
#[Override]
public function sqrt(string $n): string
{
if ($n === '0') {
return '0';
}
// initial approximation
$x = str_repeat('9', intdiv(strlen($n), 2) ?: 1);
$decreased = false;
for (; ;) {
$nx = $this->divQ($this->add($x, $this->divQ($n, $x)), '2');
if ($x === $nx || $this->cmp($nx, $x) > 0 && $decreased) {
break;
}
$decreased = $this->cmp($nx, $x) < 0;
$x = $nx;
}
return $x;
}
/**
* Performs the addition of two non-signed large integers.
*
* @pure
*/
private function doAdd(string $a, string $b): string
{
[$a, $b, $length] = $this->pad($a, $b);
$carry = 0;
$result = '';
for ($i = $length - $this->maxDigits; ; $i -= $this->maxDigits) {
$blockLength = $this->maxDigits;
if ($i < 0) {
$blockLength += $i;
$i = 0;
}
/** @var numeric-string $blockA */
$blockA = substr($a, $i, $blockLength);
/** @var numeric-string $blockB */
$blockB = substr($b, $i, $blockLength);
$sum = (string) ($blockA + $blockB + $carry);
$sumLength = strlen($sum);
if ($sumLength > $blockLength) {
$sum = substr($sum, 1);
$carry = 1;
} else {
if ($sumLength < $blockLength) {
$sum = str_repeat('0', $blockLength - $sumLength) . $sum;
}
$carry = 0;
}
$result = $sum . $result;
if ($i === 0) {
break;
}
}
if ($carry === 1) {
$result = '1' . $result;
}
return $result;
}
/**
* Performs the subtraction of two non-signed large integers.
*
* @pure
*/
private function doSub(string $a, string $b): string
{
if ($a === $b) {
return '0';
}
// Ensure that we always subtract to a positive result: biggest minus smallest.
$cmp = $this->doCmp($a, $b);
$invert = ($cmp === -1);
if ($invert) {
$c = $a;
$a = $b;
$b = $c;
}
[$a, $b, $length] = $this->pad($a, $b);
$carry = 0;
$result = '';
$complement = 10 ** $this->maxDigits;
for ($i = $length - $this->maxDigits; ; $i -= $this->maxDigits) {
$blockLength = $this->maxDigits;
if ($i < 0) {
$blockLength += $i;
$i = 0;
}
/** @var numeric-string $blockA */
$blockA = substr($a, $i, $blockLength);
/** @var numeric-string $blockB */
$blockB = substr($b, $i, $blockLength);
$sum = $blockA - $blockB - $carry;
if ($sum < 0) {
$sum += $complement;
$carry = 1;
} else {
$carry = 0;
}
$sum = (string) $sum;
$sumLength = strlen($sum);
if ($sumLength < $blockLength) {
$sum = str_repeat('0', $blockLength - $sumLength) . $sum;
}
$result = $sum . $result;
if ($i === 0) {
break;
}
}
// Carry cannot be 1 when the loop ends, as a > b
assert($carry === 0);
$result = ltrim($result, '0');
if ($invert) {
$result = $this->neg($result);
}
return $result;
}
/**
* Performs the multiplication of two non-signed large integers.
*
* @pure
*/
private function doMul(string $a, string $b): string
{
$x = strlen($a);
$y = strlen($b);
$maxDigits = intdiv($this->maxDigits, 2);
$complement = 10 ** $maxDigits;
$result = '0';
for ($i = $x - $maxDigits; ; $i -= $maxDigits) {
$blockALength = $maxDigits;
if ($i < 0) {
$blockALength += $i;
$i = 0;
}
$blockA = (int) substr($a, $i, $blockALength);
$line = '';
$carry = 0;
for ($j = $y - $maxDigits; ; $j -= $maxDigits) {
$blockBLength = $maxDigits;
if ($j < 0) {
$blockBLength += $j;
$j = 0;
}
$blockB = (int) substr($b, $j, $blockBLength);
$mul = $blockA * $blockB + $carry;
$value = $mul % $complement;
$carry = ($mul - $value) / $complement;
$value = (string) $value;
$value = str_pad($value, $maxDigits, '0', STR_PAD_LEFT);
$line = $value . $line;
if ($j === 0) {
break;
}
}
if ($carry !== 0) {
$line = $carry . $line;
}
$line = ltrim($line, '0');
if ($line !== '') {
$line .= str_repeat('0', $x - $blockALength - $i);
$result = $this->add($result, $line);
}
if ($i === 0) {
break;
}
}
return $result;
}
/**
* Performs the division of two non-signed large integers.
*
* @return string[] The quotient and remainder.
*
* @pure
*/
private function doDiv(string $a, string $b): array
{
$cmp = $this->doCmp($a, $b);
if ($cmp === -1) {
return ['0', $a];
}
$x = strlen($a);
$y = strlen($b);
// we now know that a >= b && x >= y
$q = '0'; // quotient
$r = $a; // remainder
$z = $y; // focus length, always $y or $y+1
/** @var numeric-string $b */
$nb = $b * 1; // cast to number
// performance optimization in cases where the remainder will never cause int overflow
if (is_int(($nb - 1) * 10 + 9)) {
$r = (int) substr($a, 0, $z - 1);
for ($i = $z - 1; $i < $x; $i++) {
$n = $r * 10 + (int) $a[$i];
/** @var int $nb */
$q .= intdiv($n, $nb);
$r = $n % $nb;
}
return [ltrim($q, '0') ?: '0', (string) $r];
}
for (; ;) {
$focus = substr($a, 0, $z);
$cmp = $this->doCmp($focus, $b);
if ($cmp === -1) {
if ($z === $x) { // remainder < dividend
break;
}
$z++;
}
$zeros = str_repeat('0', $x - $z);
$q = $this->add($q, '1' . $zeros);
$a = $this->sub($a, $b . $zeros);
$r = $a;
if ($r === '0') { // remainder == 0
break;
}
$x = strlen($a);
if ($x < $y) { // remainder < dividend
break;
}
$z = $y;
}
return [$q, $r];
}
/**
* Compares two non-signed large numbers.
*
* @return -1|0|1
*
* @pure
*/
private function doCmp(string $a, string $b): int
{
$x = strlen($a);
$y = strlen($b);
$cmp = $x <=> $y;
if ($cmp !== 0) {
return $cmp;
}
return strcmp($a, $b) <=> 0; // enforce -1|0|1
}
/**
* Pads the left of one of the given numbers with zeros if necessary to make both numbers the same length.
*
* The numbers must only consist of digits, without leading minus sign.
*
* @return array{string, string, int}
*
* @pure
*/
private function pad(string $a, string $b): array
{
$x = strlen($a);
$y = strlen($b);
if ($x > $y) {
$b = str_repeat('0', $x - $y) . $b;
return [$a, $b, $x];
}
if ($x < $y) {
$a = str_repeat('0', $y - $x) . $a;
return [$a, $b, $y];
}
return [$a, $b, $x];
}
}

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal;
use function extension_loaded;
/**
* Stores the current Calculator instance used by BigNumber classes.
*
* @internal
*/
final class CalculatorRegistry
{
/**
* The Calculator instance in use.
*/
private static ?Calculator $instance = null;
/**
* Sets the Calculator instance to use.
*
* An instance is typically set only in unit tests: autodetect is usually the best option.
*
* @param Calculator|null $calculator The calculator instance, or null to revert to autodetect.
*/
final public static function set(?Calculator $calculator): void
{
self::$instance = $calculator;
}
/**
* Returns the Calculator instance to use.
*
* If none has been explicitly set, the fastest available implementation will be returned.
*
* Note: even though this method is not technically pure, it is considered pure when used in a normal context, when
* only relying on autodetect.
*
* @pure
*/
final public static function get(): Calculator
{
/** @phpstan-ignore impure.staticPropertyAccess */
if (self::$instance === null) {
/** @phpstan-ignore impure.propertyAssign */
self::$instance = self::detect();
}
/** @phpstan-ignore impure.staticPropertyAccess */
return self::$instance;
}
/**
* Returns the fastest available Calculator implementation.
*
* @pure
*
* @codeCoverageIgnore
*/
private static function detect(): Calculator
{
if (extension_loaded('gmp')) {
return new Calculator\GmpCalculator();
}
if (extension_loaded('bcmath')) {
return new Calculator\BcMathCalculator();
}
return new Calculator\NativeCalculator();
}
}

View File

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal;
use Brick\Math\RoundingMode;
use function ltrim;
use function rtrim;
use function str_pad;
use function str_repeat;
use function strlen;
use function substr;
use const STR_PAD_LEFT;
/**
* Shared helper for decimal operations.
*
* @internal
*/
final class DecimalHelper
{
private function __construct()
{
}
/**
* Computes the scale needed to represent the exact decimal result of a reduced fraction.
*
* Returns null if the denominator has prime factors other than 2 or 5.
*
* @param string $denominator The denominator of the reduced fraction. Must be strictly positive.
*
* @return non-negative-int|null
*
* @pure
*/
public static function computeScaleFromReducedFractionDenominator(string $denominator): ?int
{
$calculator = CalculatorRegistry::get();
$d = rtrim($denominator, '0');
/** @var non-negative-int $scale rtrim can only shorten a string */
$scale = strlen($denominator) - strlen($d);
foreach ([5, 2] as $prime) {
for (; ;) {
$lastDigit = (int) $d[-1];
if ($lastDigit % $prime !== 0) {
break;
}
$d = $calculator->divQ($d, (string) $prime);
$scale++;
}
}
return $d === '1' ? $scale : null;
}
/**
* Scales an unscaled decimal value to the requested scale.
*
* Returns null when rounding is necessary and the rounding mode is Unnecessary.
*
* @param string $value The unscaled value.
* @param int $currentScale The current scale.
* @param int $targetScale The target scale.
* @param RoundingMode $roundingMode The rounding mode.
*
* @return string|null The unscaled value at the target scale, or null if RoundingMode::Unnecessary is used and rounding is necessary.
*
* @pure
*/
public static function scale(string $value, int $currentScale, int $targetScale, RoundingMode $roundingMode): ?string
{
$scaled = self::tryScaleExactly($value, $currentScale, $targetScale);
if ($scaled !== null) {
return $scaled;
}
if ($roundingMode === RoundingMode::Unnecessary) {
return null;
}
$divisor = '1' . str_repeat('0', $currentScale - $targetScale);
return CalculatorRegistry::get()->divRound($value, $divisor, $roundingMode);
}
/**
* Adds leading zeros if necessary to represent the full decimal number.
*
* @param string $value The unscaled value.
* @param int $scale The current scale.
*
* @pure
*/
public static function padUnscaledValue(string $value, int $scale): string
{
$targetLength = $scale + 1;
$negative = ($value[0] === '-');
$length = strlen($value);
if ($negative) {
$length--;
}
if ($length >= $targetLength) {
return $value;
}
if ($negative) {
$value = substr($value, 1);
}
$value = str_pad($value, $targetLength, '0', STR_PAD_LEFT);
if ($negative) {
$value = '-' . $value;
}
return $value;
}
/**
* Tries to scale exactly without rounding, returning null when rounding would be required.
*
* @param string $value The unscaled value.
* @param int $currentScale The current scale.
* @param int $targetScale The target scale.
*
* @return string|null The unscaled value at the target scale, or null if rounding would be required.
*
* @pure
*/
public static function tryScaleExactly(string $value, int $currentScale, int $targetScale): ?string
{
if ($value === '0' || $targetScale === $currentScale) {
return $value;
}
if ($targetScale > $currentScale) {
return $value . str_repeat('0', $targetScale - $currentScale);
}
$negative = ($value[0] === '-');
if ($negative) {
$value = substr($value, 1);
}
$value = self::padUnscaledValue($value, $currentScale);
$discardedDigits = $currentScale - $targetScale;
if (substr($value, -$discardedDigits) !== str_repeat('0', $discardedDigits)) {
return null;
}
$value = substr($value, 0, -$discardedDigits);
$value = ltrim($value, '0');
if ($value === '') {
return '0';
}
if ($negative) {
$value = '-' . $value;
}
return $value;
}
}

81
vendor/brick/math/src/Internal/Safe.php vendored Normal file
View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Brick\Math\Internal;
use Brick\Math\Exception\IntegerOverflowException;
use function is_int;
use function sprintf;
use const PHP_INT_MIN;
/**
* Helpers for arithmetic operations that throw on native integer overflow.
*
* @internal
*/
final class Safe
{
private function __construct()
{
}
/**
* @pure
*/
public static function add(int $a, int $b): int
{
$result = $a + $b;
if (is_int($result)) {
return $result;
}
// @phpstan-ignore deadCode.unreachable
throw IntegerOverflowException::nativeIntegerOverflow(sprintf('%d + %d', $a, $b));
}
/**
* @pure
*/
public static function sub(int $a, int $b): int
{
$result = $a - $b;
if (is_int($result)) {
return $result;
}
// @phpstan-ignore deadCode.unreachable
throw IntegerOverflowException::nativeIntegerOverflow(sprintf('%d - %d', $a, $b));
}
/**
* @pure
*/
public static function mul(int $a, int $b): int
{
$result = $a * $b;
if (is_int($result)) {
return $result;
}
// @phpstan-ignore deadCode.unreachable
throw IntegerOverflowException::nativeIntegerOverflow(sprintf('%d * %d', $a, $b));
}
/**
* @pure
*/
public static function neg(int $value): int
{
if ($value === PHP_INT_MIN) {
throw IntegerOverflowException::nativeIntegerOverflow(sprintf('-(%d)', $value));
}
return -$value;
}
}

93
vendor/brick/math/src/RoundingMode.php vendored Normal file
View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Brick\Math;
/**
* Specifies rounding behavior by defining how discarded digits affect the returned result when an exact value cannot
* be represented at the requested scale.
*/
enum RoundingMode
{
/**
* Asserts that the requested operation has an exact result, hence no rounding is necessary.
*
* If this rounding mode is specified on an operation that yields a result that
* cannot be represented at the requested scale, a RoundingNecessaryException is thrown.
*/
case Unnecessary;
/**
* Rounds away from zero.
*
* Always increments the digit prior to a nonzero discarded fraction.
* Note that this rounding mode never decreases the magnitude of the calculated value.
*/
case Up;
/**
* Rounds towards zero.
*
* Never increments the digit prior to a discarded fraction (i.e., truncates).
* Note that this rounding mode never increases the magnitude of the calculated value.
*/
case Down;
/**
* Rounds towards positive infinity.
*
* If the result is positive, behaves as for Up; if negative, behaves as for Down.
* Note that this rounding mode never decreases the calculated value.
*/
case Ceiling;
/**
* Rounds towards negative infinity.
*
* If the result is positive, behaves as for Down; if negative, behaves as for Up.
* Note that this rounding mode never increases the calculated value.
*/
case Floor;
/**
* Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round up.
*
* Behaves as for Up if the discarded fraction is >= 0.5; otherwise, behaves as for Down.
* Note that this is the rounding mode commonly taught at school.
*/
case HalfUp;
/**
* Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round down.
*
* Behaves as for Up if the discarded fraction is > 0.5; otherwise, behaves as for Down.
*/
case HalfDown;
/**
* Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round towards positive infinity.
*
* If the result is positive, behaves as for HalfUp; if negative, behaves as for HalfDown.
*/
case HalfCeiling;
/**
* Rounds towards "nearest neighbor" unless both neighbors are equidistant, in which case round towards negative infinity.
*
* If the result is positive, behaves as for HalfDown; if negative, behaves as for HalfUp.
*/
case HalfFloor;
/**
* Rounds towards the "nearest neighbor" unless both neighbors are equidistant, in which case rounds towards the even neighbor.
*
* Behaves as for HalfUp if the digit to the left of the discarded fraction is odd;
* behaves as for HalfDown if it's even.
*
* Note that this is the rounding mode that statistically minimizes
* cumulative error when applied repeatedly over a sequence of calculations.
* It is sometimes known as "Banker's rounding", and is chiefly used in the USA.
*/
case HalfEven;
}

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Carbon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,14 @@
# carbonphp/carbon-doctrine-types
Types to use Carbon in Doctrine
## Documentation
[Check how to use in the official Carbon documentation](https://carbon.nesbot.com/symfony/)
This package is an externalization of [src/Carbon/Doctrine](https://github.com/briannesbitt/Carbon/tree/2.71.0/src/Carbon/Doctrine)
from `nestbot/carbon` package.
Externalization allows to better deal with different versions of dbal. With
version 4.0 of dbal, it no longer sustainable to be compatible with all version
using a single code.

View File

@ -0,0 +1,36 @@
{
"name": "carbonphp/carbon-doctrine-types",
"description": "Types to use Carbon in Doctrine",
"type": "library",
"keywords": [
"date",
"time",
"DateTime",
"Carbon",
"Doctrine"
],
"require": {
"php": "^8.1"
},
"require-dev": {
"doctrine/dbal": "^4.0.0",
"nesbot/carbon": "^2.71.0 || ^3.0.0",
"phpunit/phpunit": "^10.3"
},
"conflict": {
"doctrine/dbal": "<4.0.0 || >=5.0.0"
},
"license": "MIT",
"autoload": {
"psr-4": {
"Carbon\\Doctrine\\": "src/Carbon/Doctrine/"
}
},
"authors": [
{
"name": "KyleKatarn",
"email": "kylekatarnls@gmail.com"
}
],
"minimum-stability": "dev"
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
use Doctrine\DBAL\Platforms\AbstractPlatform;
interface CarbonDoctrineType
{
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform);
public function convertToPHPValue(mixed $value, AbstractPlatform $platform);
public function convertToDatabaseValue($value, AbstractPlatform $platform);
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
class CarbonImmutableType extends DateTimeImmutableType implements CarbonDoctrineType
{
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
class CarbonType extends DateTimeType implements CarbonDoctrineType
{
}

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use DateTimeInterface;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\DB2Platform;
use Doctrine\DBAL\Platforms\OraclePlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\DBAL\Platforms\SQLServerPlatform;
use Doctrine\DBAL\Types\Exception\InvalidType;
use Doctrine\DBAL\Types\Exception\ValueNotConvertible;
use Exception;
/**
* @template T of CarbonInterface
*/
trait CarbonTypeConverter
{
/**
* This property differentiates types installed by carbonphp/carbon-doctrine-types
* from the ones embedded previously in nesbot/carbon source directly.
*
* @readonly
*/
public bool $external = true;
/**
* @return class-string<T>
*/
protected function getCarbonClassName(): string
{
return Carbon::class;
}
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform): string
{
$precision = min(
$fieldDeclaration['precision'] ?? DateTimeDefaultPrecision::get(),
$this->getMaximumPrecision($platform),
);
$type = parent::getSQLDeclaration($fieldDeclaration, $platform);
if (!$precision) {
return $type;
}
if (str_contains($type, '(')) {
return preg_replace('/\(\d+\)/', "($precision)", $type);
}
[$before, $after] = explode(' ', "$type ");
return trim("$before($precision) $after");
}
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if ($value === null) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format('Y-m-d H:i:s.u');
}
throw InvalidType::new(
$value,
static::class,
['null', 'DateTime', 'Carbon']
);
}
private function doConvertToPHPValue(mixed $value)
{
$class = $this->getCarbonClassName();
if ($value === null || is_a($value, $class)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $class::instance($value);
}
$date = null;
$error = null;
try {
$date = $class::parse($value);
} catch (Exception $exception) {
$error = $exception;
}
if (!$date) {
throw ValueNotConvertible::new(
$value,
static::class,
'Y-m-d H:i:s.u or any format supported by '.$class.'::parse()',
$error
);
}
return $date;
}
private function getMaximumPrecision(AbstractPlatform $platform): int
{
if ($platform instanceof DB2Platform) {
return 12;
}
if ($platform instanceof OraclePlatform) {
return 9;
}
if ($platform instanceof SQLServerPlatform || $platform instanceof SQLitePlatform) {
return 3;
}
return 6;
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
class DateTimeDefaultPrecision
{
private static $precision = 6;
/**
* Change the default Doctrine datetime and datetime_immutable precision.
*
* @param int $precision
*/
public static function set(int $precision): void
{
self::$precision = $precision;
}
/**
* Get the default Doctrine datetime and datetime_immutable precision.
*
* @return int
*/
public static function get(): int
{
return self::$precision;
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Carbon\Doctrine;
use Carbon\CarbonImmutable;
use DateTimeImmutable;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\VarDateTimeImmutableType;
class DateTimeImmutableType extends VarDateTimeImmutableType implements CarbonDoctrineType
{
/** @use CarbonTypeConverter<CarbonImmutable> */
use CarbonTypeConverter;
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?CarbonImmutable
{
return $this->doConvertToPHPValue($value);
}
/**
* @return class-string<CarbonImmutable>
*/
protected function getCarbonClassName(): string
{
return CarbonImmutable::class;
}
}

Some files were not shown because too many files have changed in this diff Show More