206 lines
8.6 KiB
PHP
206 lines
8.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use Brick\Math\BigRational;
|
|
use Brick\Math\RoundingMode;
|
|
use InvalidArgumentException;
|
|
|
|
/** A deliberately small, data-only expression language for reviewed tax-rule drafts. */
|
|
final class SimulationTaxRuleExpression
|
|
{
|
|
private const LANGUAGE = 'investmoz.tax.v1';
|
|
|
|
private const ROUNDING = [
|
|
'UNNECESSARY' => RoundingMode::Unnecessary,
|
|
'UP' => RoundingMode::Up,
|
|
'DOWN' => RoundingMode::Down,
|
|
'CEILING' => RoundingMode::Ceiling,
|
|
'FLOOR' => RoundingMode::Floor,
|
|
'HALF_UP' => RoundingMode::HalfUp,
|
|
'HALF_DOWN' => RoundingMode::HalfDown,
|
|
'HALF_CEILING' => RoundingMode::HalfCeiling,
|
|
'HALF_FLOOR' => RoundingMode::HalfFloor,
|
|
'HALF_EVEN' => RoundingMode::HalfEven,
|
|
];
|
|
|
|
/** @param array<string,mixed> $definition */
|
|
public function validate(array $definition): void
|
|
{
|
|
$this->topologicalOrder($definition);
|
|
}
|
|
|
|
/**
|
|
* Evaluate a validated expression. Missing values are reported as indeterminate;
|
|
* callers must supply versioned parameters and provenance explicitly.
|
|
*
|
|
* @param array<string,mixed> $definition
|
|
* @param array<string,string|int> $inputs
|
|
* @param array<string,string|int> $parameters
|
|
* @return array{status:string,result:?string,missing:list<string>,steps:list<array<string,mixed>>}
|
|
*/
|
|
public function evaluate(array $definition, array $inputs, array $parameters): array
|
|
{
|
|
$order = $this->topologicalOrder($definition);
|
|
$nodes = $definition['nodes'];
|
|
$values = [];
|
|
$missing = [];
|
|
$steps = [];
|
|
|
|
foreach ($order as $id) {
|
|
$node = $nodes[$id];
|
|
$op = $node['op'];
|
|
if ($op === 'input' || $op === 'parameter') {
|
|
$key = $node['key'];
|
|
$source = $op === 'input' ? $inputs : $parameters;
|
|
if (! array_key_exists($key, $source)) {
|
|
$missing[] = $op.':'.$key;
|
|
$steps[] = ['id' => $id, 'op' => $op, 'key' => $key, 'value' => null];
|
|
|
|
continue;
|
|
}
|
|
$value = $this->number($source[$key]);
|
|
} elseif ($op === 'literal') {
|
|
$value = $this->number($node['value']);
|
|
} else {
|
|
$args = array_values(array_map(static fn (string $ref): ?BigRational => $values[$ref] ?? null, $node['args'] ?? []));
|
|
if (in_array(null, $args, true)) {
|
|
$values[$id] = null;
|
|
$steps[] = ['id' => $id, 'op' => $op, 'args' => $node['args'] ?? [], 'value' => null];
|
|
|
|
continue;
|
|
}
|
|
$value = $this->operate($op, $args, $node);
|
|
}
|
|
$values[$id] = $value;
|
|
$steps[] = ['id' => $id, 'op' => $op, 'value' => (string) $value];
|
|
}
|
|
|
|
$result = $values[$definition['result']] ?? null;
|
|
|
|
return [
|
|
'status' => $missing === [] && $result !== null ? 'determinate' : 'indeterminate',
|
|
'result' => $result === null ? null : (string) $result,
|
|
'missing' => array_values(array_unique($missing)),
|
|
'steps' => $steps,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $definition
|
|
* @return list<string>
|
|
*/
|
|
private function topologicalOrder(array $definition): array
|
|
{
|
|
if (($definition['language'] ?? null) !== self::LANGUAGE || ! is_array($definition['nodes'] ?? null) || ! is_string($definition['result'] ?? null)) {
|
|
throw new InvalidArgumentException('Unsupported expression definition.');
|
|
}
|
|
$nodes = $definition['nodes'];
|
|
if ($nodes === [] || count($nodes) > 100) {
|
|
throw new InvalidArgumentException('Expression must contain between 1 and 100 nodes.');
|
|
}
|
|
|
|
foreach ($nodes as $id => $node) {
|
|
if (! is_string($id) || ! preg_match('/^[a-z][a-z0-9_]{0,47}$/', $id) || ! is_array($node) || ! is_string($node['op'] ?? null)) {
|
|
throw new InvalidArgumentException('Invalid expression node.');
|
|
}
|
|
$op = $node['op'];
|
|
$allowedFields = match ($op) {
|
|
'input', 'parameter' => ['op', 'key'],
|
|
'literal' => ['op', 'value'],
|
|
'add', 'subtract', 'multiply', 'divide', 'min', 'max' => ['op', 'args'],
|
|
'round' => ['op', 'args', 'scale', 'mode'],
|
|
default => throw new InvalidArgumentException('Expression operation is not allowed.'),
|
|
};
|
|
if (array_diff(array_keys($node), $allowedFields) !== [] || array_diff($allowedFields, array_keys($node)) !== []) {
|
|
throw new InvalidArgumentException('Expression node has missing or unsupported fields.');
|
|
}
|
|
if (in_array($op, ['input', 'parameter'], true) && (! is_string($node['key']) || ! preg_match('/^[a-z][a-z0-9_.-]{0,79}$/', $node['key']))) {
|
|
throw new InvalidArgumentException('Invalid input or parameter key.');
|
|
}
|
|
if ($op === 'literal') {
|
|
$this->number($node['value']);
|
|
}
|
|
if (isset($node['args'])) {
|
|
if (! is_array($node['args'])) {
|
|
throw new InvalidArgumentException('Invalid expression arguments.');
|
|
}
|
|
$count = count($node['args']);
|
|
$validCount = $op === 'round' ? $count === 1 : ($op === 'subtract' || $op === 'divide' ? $count === 2 : $count >= 2 && $count <= 20);
|
|
if (! $validCount || count(array_filter($node['args'], 'is_string')) !== $count) {
|
|
throw new InvalidArgumentException('Invalid expression arguments.');
|
|
}
|
|
}
|
|
if ($op === 'round' && (! is_int($node['scale']) || $node['scale'] < 0 || $node['scale'] > 12 || ! isset(self::ROUNDING[$node['mode']]))) {
|
|
throw new InvalidArgumentException('Invalid explicit rounding configuration.');
|
|
}
|
|
}
|
|
|
|
if (! array_key_exists($definition['result'], $nodes)) {
|
|
throw new InvalidArgumentException('Expression result references an unknown node.');
|
|
}
|
|
$state = [];
|
|
$order = [];
|
|
$visit = function (string $id) use (&$visit, &$state, &$order, $nodes): void {
|
|
if (($state[$id] ?? 0) === 1) {
|
|
throw new InvalidArgumentException('Expression dependency graph contains a cycle.');
|
|
}
|
|
if (($state[$id] ?? 0) === 2) {
|
|
return;
|
|
}
|
|
$state[$id] = 1;
|
|
foreach ($nodes[$id]['args'] ?? [] as $dependency) {
|
|
if (! is_string($dependency) || ! array_key_exists($dependency, $nodes)) {
|
|
throw new InvalidArgumentException('Expression references an unknown node.');
|
|
}
|
|
$visit($dependency);
|
|
}
|
|
$state[$id] = 2;
|
|
$order[] = $id;
|
|
};
|
|
foreach (array_keys($nodes) as $id) {
|
|
$visit($id);
|
|
}
|
|
|
|
return $order;
|
|
}
|
|
|
|
/**
|
|
* @param list<BigRational> $args
|
|
* @param array<string,mixed> $node
|
|
*/
|
|
private function operate(string $op, array $args, array $node): BigRational
|
|
{
|
|
if ($op === 'round') {
|
|
return BigRational::of((string) $args[0]->toScale($node['scale'], self::ROUNDING[$node['mode']]));
|
|
}
|
|
if ($op === 'add') {
|
|
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => $carry->plus($value), $args[0]);
|
|
}
|
|
if ($op === 'subtract') {
|
|
return $args[0]->minus($args[1]);
|
|
}
|
|
if ($op === 'multiply') {
|
|
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => $carry->multipliedBy($value), $args[0]);
|
|
}
|
|
if ($op === 'divide') {
|
|
return $args[0]->dividedBy($args[1]);
|
|
}
|
|
if ($op === 'min' || $op === 'max') {
|
|
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => ($op === 'min' ? $carry->compareTo($value) <= 0 : $carry->compareTo($value) >= 0) ? $carry : $value, $args[0]);
|
|
}
|
|
throw new InvalidArgumentException('Expression operation is not allowed.');
|
|
}
|
|
|
|
private function number(mixed $value): BigRational
|
|
{
|
|
if ((! is_string($value) && ! is_int($value)) || ! preg_match('/^-?\d+(?:\.\d+)?$/', (string) $value)) {
|
|
throw new InvalidArgumentException('Numbers must be plain decimal strings or integers.');
|
|
}
|
|
|
|
return BigRational::of((string) $value);
|
|
}
|
|
}
|