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 $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 $definition * @param array $inputs * @param array $parameters * @return array{status:string,result:?string,missing:list,steps:list>} */ 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 $definition * @return list */ 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('O formato da expressão não é suportado.'); } $nodes = $definition['nodes']; if ($nodes === [] || count($nodes) > 100) { throw new InvalidArgumentException('A expressão deve conter entre 1 e 100 elementos.'); } 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('Reveja o elemento indicado na expressão.'); } $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('Esta operação não é suportada na expressão.'), }; if (array_diff(array_keys($node), $allowedFields) !== [] || array_diff($allowedFields, array_keys($node)) !== []) { throw new InvalidArgumentException('Há campos em falta ou não suportados num elemento da expressão.'); } 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('Reveja o identificador da entrada ou do parâmetro.'); } if ($op === 'literal') { $this->number($node['value']); } if (isset($node['args'])) { if (! is_array($node['args'])) { throw new InvalidArgumentException('Reveja os argumentos da expressão.'); } $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('Reveja os argumentos da expressão.'); } } if ($op === 'round' && (! is_int($node['scale']) || $node['scale'] < 0 || $node['scale'] > 12 || ! isset(self::ROUNDING[$node['mode']]))) { throw new InvalidArgumentException('Reveja a configuração de arredondamento.'); } } if (! array_key_exists($definition['result'], $nodes)) { throw new InvalidArgumentException('O resultado da expressão refere um elemento inexistente.'); } $state = []; $order = []; $visit = function (string $id) use (&$visit, &$state, &$order, $nodes): void { if (($state[$id] ?? 0) === 1) { throw new InvalidArgumentException('A expressão contém uma dependência circular.'); } 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('A expressão refere um elemento inexistente.'); } $visit($dependency); } $state[$id] = 2; $order[] = $id; }; foreach (array_keys($nodes) as $id) { $visit($id); } return $order; } /** * @param list $args * @param array $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('Esta operação não é suportada na expressão.'); } private function number(mixed $value): BigRational { if ((! is_string($value) && ! is_int($value)) || ! preg_match('/^-?\d+(?:\.\d+)?$/', (string) $value)) { throw new InvalidArgumentException('Introduza valores numéricos decimais ou inteiros.'); } return BigRational::of((string) $value); } }