From 0efd2f503dae13a262e41a22dcf2af4d31284eb5 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 26 Sep 2026 14:20:25 +0900 Subject: [PATCH 1/2] Add per-chain semantic-variable validation cache (#81) Skip re-running #[Validate] methods for a semantic-variable value that is carried through a metamorphosis chain unchanged. Measured in the issue at ~12/18 validated args per real chain being exact repeats (~17-18% of chain time spent in validation, ~10% theoretical ceiling reclaimable). Lifecycle (open question #3): the cache is owned by SemanticValidator and gated by an explicit "inside active chain" flag. `$chainCache` is null (inactive) by default; Becoming::__invoke() calls beginChain()/endChain() around the metamorphosis loop in a try/finally, so the cache lives exactly one chain and is always discarded. Every direct caller (validateProps(), validate(), validateLegacy(), validateAndThrow(), validateObject()) leaves the flag off, so caching is fully bypassed and nothing accumulates outside a chain. Hooks are threaded via BecomingArgumentsInterface; NullValidator implements them as no-ops. Key = (parameterName, sort(attributes), valueKey): - scalar/null: gettype().':'.var_export() (no 1/"1"/true collisions) - object: spl_object_id() (safe only under the public-readonly convention, documented at the call site) - array values: never cached - only cached when every resolved #[Validate] method has zero #[Inject] parameters (an injected validator may consult mutable state) Tests pin the two subtle traps and the lifecycle contract: attribute-keyed method selection (validateAge vs #[Teen] validateTeen with the same value), #[Inject]-consuming validators never served from cache, direct calls never accumulating entries, and end-to-end single-validation across chain hops. --- src/Becoming.php | 8 ++ src/BecomingArguments.php | 12 ++ src/BecomingArgumentsInterface.php | 10 ++ src/SemanticVariable/NullValidator.php | 18 +++ src/SemanticVariable/SemanticValidator.php | 110 ++++++++++++++++++ .../SemanticValidatorInterface.php | 18 +++ tests/BecomingTest.php | 67 +++++++++++ tests/FakeApp/SemanticVariables/Counted.php | 22 ++++ tests/FakeApp/SemanticVariables/Tally.php | 27 +++++ .../SemanticValidatorCacheTest.php | 94 +++++++++++++++ 10 files changed, 386 insertions(+) create mode 100644 tests/FakeApp/SemanticVariables/Counted.php create mode 100644 tests/FakeApp/SemanticVariables/Tally.php create mode 100644 tests/SemanticVariable/SemanticValidatorCacheTest.php diff --git a/src/Becoming.php b/src/Becoming.php index 27b89b17..09883bfa 100644 --- a/src/Becoming.php +++ b/src/Becoming.php @@ -26,6 +26,7 @@ final class Becoming implements BecomingInterface { private Being $being; private LoggerInterface $logger; + private BecomingArgumentsInterface $becomingArguments; public function __construct( InjectorInterface $injector, @@ -37,6 +38,7 @@ public function __construct( $becomingArguments ??= new BecomingArguments($injector, new SemanticValidator($semanticNamespace)); $logger ??= new Logger(new SemanticLogger(), $becomingArguments); $this->logger = $logger; + $this->becomingArguments = $becomingArguments; $this->being = new Being($logger, $becomingArguments, new BecomingType()); } @@ -57,6 +59,10 @@ public function __invoke(object $input): object $current = $input; $isFirst = true; + // Activate the semantic validator's per-chain cache; the finally below + // guarantees it is discarded whether the chain succeeds or throws. + $this->becomingArguments->beginChain(); + try { // Being reveals its becoming, then becomes it while ($nextForm = $this->being->willBe($current)) { @@ -90,6 +96,8 @@ public function __invoke(object $input): object } throw $e; + } finally { + $this->becomingArguments->endChain(); } // Success close is outside the try so a logging failure here is not diff --git a/src/BecomingArguments.php b/src/BecomingArguments.php index a9576a7c..043b5710 100644 --- a/src/BecomingArguments.php +++ b/src/BecomingArguments.php @@ -82,6 +82,18 @@ public function be(object $current, string $becoming): array return $args; } + #[Override] + public function beginChain(): void + { + $this->semanticValidator->beginChain(); + } + + #[Override] + public function endChain(): void + { + $this->semanticValidator->endChain(); + } + /** * Resolves #[Inject] parameters from DI container * diff --git a/src/BecomingArgumentsInterface.php b/src/BecomingArgumentsInterface.php index 0ac2d39b..0f60c977 100644 --- a/src/BecomingArgumentsInterface.php +++ b/src/BecomingArgumentsInterface.php @@ -25,4 +25,14 @@ interface BecomingArgumentsInterface * @phpstan-return array */ public function be(object $current, string $becoming): array; + + /** + * Begin a metamorphosis chain (activates the semantic validator's per-chain cache). + */ + public function beginChain(): void; + + /** + * End a metamorphosis chain (discards the per-chain cache; must run even on failure). + */ + public function endChain(): void; } diff --git a/src/SemanticVariable/NullValidator.php b/src/SemanticVariable/NullValidator.php index 68dabb75..5da2a56e 100644 --- a/src/SemanticVariable/NullValidator.php +++ b/src/SemanticVariable/NullValidator.php @@ -73,4 +73,22 @@ public function validateObject(object $object): Errors { return new NullErrors(); } + + /** + * No-op: the null validator has no cache to activate. + */ + #[Override] + public function beginChain(): void + { + // No-op + } + + /** + * No-op: the null validator has no cache to discard. + */ + #[Override] + public function endChain(): void + { + // No-op + } } diff --git a/src/SemanticVariable/SemanticValidator.php b/src/SemanticVariable/SemanticValidator.php index cdfaed62..9e5e4f8c 100644 --- a/src/SemanticVariable/SemanticValidator.php +++ b/src/SemanticVariable/SemanticValidator.php @@ -14,14 +14,24 @@ use ReflectionMethod; use ReflectionParameter; +use function array_filter; use function array_key_exists; +use function array_values; use function class_exists; +use function count; use function get_object_vars; +use function gettype; +use function implode; use function in_array; use function is_array; +use function is_object; +use function reset; +use function sort; +use function spl_object_id; use function str_replace; use function trigger_error; use function ucwords; +use function var_export; use const E_USER_NOTICE; @@ -43,6 +53,19 @@ final class SemanticValidator implements SemanticValidatorInterface private readonly array $classMap; private readonly SemanticValidationMethodResolver $validationMethodResolver; + /** @var array Full class names already confirmed missing — avoids re-checking class_exists() and re-emitting the notice on every call. */ + private array $missingSemanticClasses = []; + + /** + * Per-chain value-validation cache. null = inactive (outside a chain): every + * call re-validates and nothing accumulates. An array (set by beginChain()) + * remembers successfully validated (name, attributes, value) triples for the + * lifetime of one Becoming::__invoke() chain. + * + * @var array|null + */ + private array|null $chainCache = null; + /** @param array|SemanticValidationMethodResolver|null $classMapOrValidationMethodResolver */ public function __construct( #[Named('semantic_namespace')] @@ -255,6 +278,12 @@ public function validateWithAttributes(string $variableName, array $parameterAtt return new NullErrors(); } + $cacheKey = $this->chainCacheKey($variableName, $parameterAttributes, $args, $validationMethods); + if ($cacheKey !== null && isset($this->chainCache[$cacheKey])) { + // Same value carried unchanged through this chain — already validated. + return new NullErrors(); + } + $exceptions = []; foreach ($validationMethods as $method) { @@ -266,9 +295,85 @@ public function validateWithAttributes(string $variableName, array $parameterAtt } } + if ($cacheKey !== null && empty($exceptions)) { + $this->chainCache[$cacheKey] = true; + } + return empty($exceptions) ? new NullErrors() : new Errors($exceptions); } + /** + * Activate a fresh per-chain cache. See {@see SemanticValidatorInterface::beginChain()}. + */ + #[Override] + public function beginChain(): void + { + $this->chainCache = []; + } + + /** + * Discard the per-chain cache. See {@see SemanticValidatorInterface::endChain()}. + */ + #[Override] + public function endChain(): void + { + $this->chainCache = null; + } + + /** + * Cache key for a single-value validation, or null when caching must be bypassed. + * + * Bypassed when: no active chain; not a single-value call; the value is an + * array (normalizing it costs more than re-validating); or any resolved + * #[Validate] method takes an #[Inject] parameter (it may consult mutable + * injected state and legitimately return a different answer for the same + * value, so its result must never be cached). + * + * The key combines the variable name, the SORTED attribute set (so a later + * #[Teen] int $age never reuses an earlier plain int $age result — they + * select different #[Validate] methods), and a value key. Scalars/null are + * type-tagged to avoid 1/"1"/true collisions; objects use spl_object_id(), + * which is a safe stand-in for value identity ONLY because Be Framework's + * public-readonly convention means an object's identity implies its state + * is unchanged since construction. + * + * @param ParameterAttributes $parameterAttributes + * @param ValidationArguments $args + * @param ReflectionMethods $validationMethods + * @phpstan-param array $parameterAttributes + * @phpstan-param array $args + * @phpstan-param array $validationMethods + */ + private function chainCacheKey(string $variableName, array $parameterAttributes, array $args, array $validationMethods): string|null + { + if ($this->chainCache === null || count($args) !== 1) { + return null; + } + + /** @var mixed $value */ + $value = reset($args); + if (is_array($value)) { + return null; + } + + foreach ($validationMethods as $method) { + foreach ($method->getParameters() as $parameter) { + if ($this->validationMethodResolver->hasInjectAttribute($parameter)) { + return null; + } + } + } + + $valueKey = is_object($value) + ? 'obj:' . spl_object_id($value) + : gettype($value) . ':' . var_export($value, true); + + $attributes = array_values(array_filter($parameterAttributes, 'is_string')); + sort($attributes); + + return $variableName . '|' . implode(',', $attributes) . '|' . $valueKey; + } + /** * Resolve semantic class from variable name */ @@ -277,7 +382,12 @@ private function resolveSemanticClass(string $variableName): object|null $className = $this->convertToClassName($variableName); $fullClassName = $this->classMap[$className] ?? "{$this->semanticNamespace}\\$className"; + if (isset($this->missingSemanticClasses[$fullClassName])) { + return null; + } + if (! class_exists($fullClassName)) { + $this->missingSemanticClasses[$fullClassName] = true; trigger_error("Semantic variable '{$className}' not registered in ontology namespace {$this->semanticNamespace}", E_USER_NOTICE); return null; diff --git a/src/SemanticVariable/SemanticValidatorInterface.php b/src/SemanticVariable/SemanticValidatorInterface.php index c752e89e..42ea381b 100644 --- a/src/SemanticVariable/SemanticValidatorInterface.php +++ b/src/SemanticVariable/SemanticValidatorInterface.php @@ -39,4 +39,22 @@ public function validateArgs(ReflectionMethod $method, array $args): Errors; * @return Errors Validation errors (empty if validation passes) */ public function validateArg(ReflectionParameter $parameter, mixed $value): Errors; + + /** + * Begin a metamorphosis chain: activate the per-chain value-validation cache. + * + * Between beginChain() and endChain(), a successfully validated + * (parameterName, attributes, value) triple is remembered so an unchanged + * value carried through several hops of one chain is validated once. Outside + * this window the cache is inactive and every call re-validates. + */ + public function beginChain(): void; + + /** + * End a metamorphosis chain: discard the per-chain cache. + * + * Must be called (even on failure) so cache entries never outlive their + * chain — the unbounded-growth failure mode documented for SemanticLogger. + */ + public function endChain(): void; } diff --git a/tests/BecomingTest.php b/tests/BecomingTest.php index ceedb26f..34a62b7f 100644 --- a/tests/BecomingTest.php +++ b/tests/BecomingTest.php @@ -18,7 +18,9 @@ use Be\Framework\SemanticVariable\Errors; use Be\Framework\SemanticVariable\SemanticValidator; use InvalidArgumentException; +use Koriym\SemanticLogger\Exception\NoLogSessionException; use Koriym\SemanticLogger\SemanticLogger; +use MyVendor\MyApp\SemanticVariables\Counted; use PHPUnit\Framework\TestCase; use Ray\Di\AbstractModule; use Ray\Di\Di\Inject; @@ -291,6 +293,27 @@ public function testChainCloseLogRecordsRuntimeOrigin(): void $this->assertSame(BecomingCloseContext::ORIGIN_RUNTIME, $context['origin']); } + public function testFlushReturnsAccumulatedSessionAndResetsState(): void + { + // BeModule binds SemanticLoggerInterface as a singleton so one instance + // can accumulate several chains before the caller reads it out (see + // docs/semantic-log-architecture.md#log-lifecycle-flush-ownership). + // flush() must return everything accumulated so far AND reset the + // logger's internal state so a later read finds no session. + $semanticLogger = new SemanticLogger(); + $becoming = $this->becomingWithLogger($semanticLogger); + + $becoming(new BecomingTestInput('first')); + $becoming(new BecomingTestInput('second')); + + $session = $semanticLogger->flush()->toArray(); + assert(is_array($session['open'])); + $this->assertCount(2, $session['open'], 'flush() must return both accumulated chains'); + + $this->expectException(NoLogSessionException::class); + $semanticLogger->toArray(); + } + private function becomingWithLogger(SemanticLogger $semanticLogger): Becoming { $injector = new Injector(new BecomingTestModule()); @@ -349,6 +372,50 @@ public function testInfrastructureExceptionPropagatesImmediately(): void $input = new BecomingTestInfrastructureErrorInput('test'); ($this->becoming)($input); } + + public function testUnchangedValueValidatedOncePerChainThenReValidatedNextChain(): void + { + // Issue #81: a value carried unchanged through a chain's hops is + // validated once, but each new Becoming::__invoke() starts fresh. + Counted::$count = 0; + + $result = ($this->becoming)(new BecomingTestCacheStart(5)); + $this->assertSame(5, $result->counted); + $this->assertSame(1, Counted::$count, 'Unchanged value validated once within one chain'); + + ($this->becoming)(new BecomingTestCacheStart(5)); + $this->assertSame(2, Counted::$count, 'A new chain re-validates (cache is per-chain)'); + } +} + +// Cache-per-chain fixtures (issue #81): `counted` carried unchanged through hops. +#[Be(BecomingTestCacheMid::class)] +final class BecomingTestCacheStart +{ + public function __construct( + #[Input] + public readonly int $counted, + ) { + } +} + +#[Be(BecomingTestCacheEnd::class)] +final class BecomingTestCacheMid +{ + public function __construct( + #[Input] + public readonly int $counted, + ) { + } +} + +final class BecomingTestCacheEnd +{ + public function __construct( + #[Input] + public readonly int $counted, + ) { + } } // Test fixtures for coverage testing diff --git a/tests/FakeApp/SemanticVariables/Counted.php b/tests/FakeApp/SemanticVariables/Counted.php new file mode 100644 index 00000000..2d421d71 --- /dev/null +++ b/tests/FakeApp/SemanticVariables/Counted.php @@ -0,0 +1,22 @@ +validator = new SemanticValidator('MyVendor\\MyApp\\SemanticVariables'); + Counted::$count = 0; + Tally::$count = 0; + } + + public function testUnchangedValueIsValidatedOncePerChain(): void + { + $this->validator->beginChain(); + $this->validator->validateWithAttributes('counted', [], 5); + $this->validator->validateWithAttributes('counted', [], 5); + $this->validator->endChain(); + + $this->assertSame(1, Counted::$count, 'Same value in one chain must validate once'); + } + + public function testAttributeSetIsPartOfCacheKey(): void + { + // Trap #2: an earlier plain `int $age` check must not let a later + // `#[Teen] int $age` with the same value skip validation. age=25 passes + // validateAge but fails validateTeen (>19), so a wrong cache hit would + // hide the Teen error. + $this->validator->beginChain(); + + $plain = $this->validator->validateWithAttributes('age', [], 25); + $this->assertFalse($plain->hasErrors(), 'Plain age=25 is valid'); + + $teen = $this->validator->validateWithAttributes('age', ['Teen'], 25); + $this->validator->endChain(); + + $this->assertTrue($teen->hasErrors(), 'Teen validation must run despite same value/name'); + } + + public function testInjectConsumingValidatorIsNeverCached(): void + { + // Trap #1: a #[Validate] method with an #[Inject] parameter may depend on + // mutable injected state and must re-run for the same value. + $this->validator->beginChain(); + $this->validator->validateWithAttributes('tally', [], 7); + $this->validator->validateWithAttributes('tally', [], 7); + $this->validator->endChain(); + + $this->assertSame(2, Tally::$count, 'Inject-consuming validator must not be cached'); + } + + public function testDirectCallsOutsideChainNeverAccumulate(): void + { + // Trap #3: calling the validator directly (no active chain) must leave the + // cache inactive so entries never accumulate or serve stale results + // across unrelated calls. + $cache = new ReflectionProperty(SemanticValidator::class, 'chainCache'); + + $this->assertNull($cache->getValue($this->validator), 'Cache starts inactive'); + + $this->validator->validate('counted', 5); + $this->validator->validate('counted', 5); + $this->validator->validateWithAttributes('age', [], 25); + + $this->assertNull($cache->getValue($this->validator), 'Direct calls must not activate the cache'); + $this->assertSame(2, Counted::$count, 'Without an active chain every call re-validates'); + } + + public function testEndChainDiscardsCache(): void + { + $cache = new ReflectionProperty(SemanticValidator::class, 'chainCache'); + + $this->validator->beginChain(); + $this->validator->validateWithAttributes('counted', [], 5); + $this->assertIsArray($cache->getValue($this->validator)); + + $this->validator->endChain(); + $this->assertNull($cache->getValue($this->validator), 'endChain() must discard the cache'); + } +} From 9848b076786acdf6fa50c69fd56f4982336a493c Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 26 Sep 2026 14:23:32 +0900 Subject: [PATCH 2/2] Pin array-value and multi-arg cache bypass branches (#81) --- tests/FakeApp/SemanticVariables/Tags.php | 23 ++++++++++++++++ .../SemanticValidatorCacheTest.php | 26 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 tests/FakeApp/SemanticVariables/Tags.php diff --git a/tests/FakeApp/SemanticVariables/Tags.php b/tests/FakeApp/SemanticVariables/Tags.php new file mode 100644 index 00000000..480b1ba1 --- /dev/null +++ b/tests/FakeApp/SemanticVariables/Tags.php @@ -0,0 +1,23 @@ + $tags */ + #[Validate] + public function validateTags(array $tags): void + { + self::$count++; + } +} diff --git a/tests/SemanticVariable/SemanticValidatorCacheTest.php b/tests/SemanticVariable/SemanticValidatorCacheTest.php index c87ba58b..4ce8bb2c 100644 --- a/tests/SemanticVariable/SemanticValidatorCacheTest.php +++ b/tests/SemanticVariable/SemanticValidatorCacheTest.php @@ -5,6 +5,7 @@ namespace Be\Framework\SemanticVariable; use MyVendor\MyApp\SemanticVariables\Counted; +use MyVendor\MyApp\SemanticVariables\Tags; use MyVendor\MyApp\SemanticVariables\Tally; use PHPUnit\Framework\TestCase; use ReflectionProperty; @@ -22,6 +23,7 @@ protected function setUp(): void $this->validator = new SemanticValidator('MyVendor\\MyApp\\SemanticVariables'); Counted::$count = 0; Tally::$count = 0; + Tags::$count = 0; } public function testUnchangedValueIsValidatedOncePerChain(): void @@ -91,4 +93,28 @@ public function testEndChainDiscardsCache(): void $this->validator->endChain(); $this->assertNull($cache->getValue($this->validator), 'endChain() must discard the cache'); } + + public function testArrayValuesAreNeverCached(): void + { + // Array values are excluded from the cache (normalizing costs more than + // re-validating), so each call must re-run even in an active chain. + $this->validator->beginChain(); + $this->validator->validateWithAttributes('tags', [], ['a', 'b']); + $this->validator->validateWithAttributes('tags', [], ['a', 'b']); + $this->validator->endChain(); + + $this->assertSame(2, Tags::$count, 'Array values must never be cached'); + } + + public function testMultiArgCallsAreNeverCached(): void + { + // The cache only serves single-value validations; multi-arg (cross-field) + // calls must always re-validate so a changed sibling arg is never masked. + $this->validator->beginChain(); + $this->validator->validateWithAttributes('counted', [], 5, 99); + $this->validator->validateWithAttributes('counted', [], 5, 99); + $this->validator->endChain(); + + $this->assertSame(2, Counted::$count, 'Multi-arg calls must never be cached'); + } }