Motivation
SemanticValidator re-runs #[Validate] methods for every #[Input] constructor
argument at every hop of a metamorphosis chain, even when the same named value is
carried through unchanged from one Being to the next (a common pattern: an id,
an entity, or a totals object passed straight through several constructors).
Measured evidence
Measured on a real 5-hop production chain (BeMart's
ConfirmOrderInput → PreOrderResolved → PurchaseFlowApplied → PaymentVerified → OrderConfirming → [OrderConfirmed, OrderConfirmFailed],
OrderConfirming alone has 2 scalar + 3 object-typed #[Input] parameters), not
on a synthetic micro-benchmark:
- Total validation cost (real
SemanticValidator vs NullValidator, interleaved
A/B in one process to cancel machine drift, 3 runs × 2000 iterations):
101–104µs of ~670µs per chain (~17.6–18.3%). Reproducible across all 3 runs.
- Instrumented the args actually flowing into each
#[Input] parameter at every
hop (name + resolved #[Validate]-attribute set + value) across 500 chain
invocations, reset per chain: 18 validated args per chain, 12 of them
(66.7%) are exact repeats of a (name, attributes, value) triple already
validated earlier in the same chain. This is a structural property of the
chain shape — independent of any caching, stable at every sample size tried.
- Sample duplication pattern from one chain:
preOrderId and paymentMethodId
(scalars) re-validated at all 5 hops; order (OrderEntity), totals
(PurchaseTotals) re-validated 3–4 times each as the same object instance
(spl_object_id unchanged across hops — the values are provably unchanged,
not just equal).
- Theoretical ceiling: ~67µs/chain, ~10% of total chain time — worth pursuing,
unlike the ~22µs (~0.1–1%) ceiling a synthetic single-int-field 4-hop
benchmark suggested (that number was real but not representative of real
chains' wider constructors and object-typed #[Input] arguments).
Proposed design (not implemented — see open questions)
- Cache key:
(parameterName, sort($parameterAttributes), valueKey).
- Scalar/null values: type-tagged encoding, e.g.
gettype($value) . ':' . var_export($value, true) (avoids 1/"1"/true
collisions).
- Object values:
spl_object_id($value). Safe only because Be Framework's
public readonly property convention means object identity implies the
value hasn't changed since construction — a documented convention, not
something the type system enforces.
- Array values: excluded from the cache (always re-validate). Normalizing an
array for a cache key can cost more than the ~5–6µs/hop the cache would
save; not worth the complexity for the fraction of #[Input] args that are
arrays.
- Only cache when every
#[Validate] method resolved for the variable takes
zero #[Inject] parameters (see "Purity is not guaranteed" below).
- Cache is scoped to one
Becoming::__invoke() chain and must be reset when a
new chain starts — never shared across chains on a long-running
SemanticValidator instance (that would reproduce the unbounded-growth
problem already documented for SemanticLogger in
docs/semantic-log-architecture.md).
Open correctness questions (why this isn't implemented yet)
- Purity is not guaranteed.
SemanticValidationMethodResolver explicitly
supports #[Validate] methods that also take #[Inject] parameters
(repository, clock, external service) — see the hasInjectAttribute
filtering in getMatchingValidationMethods / hasEnoughArguments /
matchesParameterAttributes. A validator consulting injected state (e.g. a
uniqueness check against a repository) can legitimately return a different
answer for the same value at different points in time; caching by value
alone would silently serve a stale result. The design above tries to guard
against this by excluding #[Inject]-consuming validators, but that's a
structural heuristic, not a purity guarantee (a zero-#[Inject]-parameter
method can still be impure via time(), global state, etc.).
- Attribute-keyed method selection.
tests/FakeApp/SemanticVariables/Age.php
declares both validateAge(int $age) and validateTeen(#[Teen] int $age) —
which method(s) run depends on the call site's $parameterAttributes, not
just the value. A cache keyed on (name, value) alone would let a later
#[Teen] int $age parameter skip validation on the strength of an earlier
plain int $age check with the same value — wrong. The key must include
the resolved attribute set.
- Where does the per-chain reset live? A
beginChain()-style reset needs
to be threaded from Becoming::__invoke() down to SemanticValidator, and
SemanticValidator::validateWithAttributes() is also reachable directly via
validateProps() and the public legacy methods (validate(),
validateLegacy(), validateAndThrow(), validateObject()). If the reset
hook only fires from Becoming::__invoke(), any caller using
SemanticValidator directly outside a chain accumulates cache entries that
are never cleared and can serve stale results across unrelated validations —
the same unbounded-growth failure mode as the SemanticLogger issue. Needs
either an explicit "inside an active chain" flag that gates the cache off by
default, or a different ownership model entirely.
- Interface stability is not a blocker (this is Be Framework v0), so the shape
of whatever hook is needed should follow the cleanest design rather than
avoiding interface changes for their own sake.
Suggested next step
Resolve (3) first — decide who owns the chain-scoped cache lifecycle and how it
degrades safely to "no caching" outside an active chain — before writing any
caching code. (1) and (2) are addressable in the key/eligibility design once
(3) is settled.
Motivation
SemanticValidatorre-runs#[Validate]methods for every#[Input]constructorargument at every hop of a metamorphosis chain, even when the same named value is
carried through unchanged from one Being to the next (a common pattern: an id,
an entity, or a totals object passed straight through several constructors).
Measured evidence
Measured on a real 5-hop production chain (BeMart's
ConfirmOrderInput → PreOrderResolved → PurchaseFlowApplied → PaymentVerified → OrderConfirming → [OrderConfirmed, OrderConfirmFailed],OrderConfirmingalone has 2 scalar + 3 object-typed#[Input]parameters), noton a synthetic micro-benchmark:
SemanticValidatorvsNullValidator, interleavedA/B in one process to cancel machine drift, 3 runs × 2000 iterations):
101–104µs of ~670µs per chain (~17.6–18.3%). Reproducible across all 3 runs.
#[Input]parameter at everyhop (name + resolved
#[Validate]-attribute set + value) across 500 chaininvocations, reset per chain: 18 validated args per chain, 12 of them
(66.7%) are exact repeats of a
(name, attributes, value)triple alreadyvalidated earlier in the same chain. This is a structural property of the
chain shape — independent of any caching, stable at every sample size tried.
preOrderIdandpaymentMethodId(scalars) re-validated at all 5 hops;
order(OrderEntity),totals(
PurchaseTotals) re-validated 3–4 times each as the same object instance(
spl_object_idunchanged across hops — the values are provably unchanged,not just equal).
unlike the ~22µs (~0.1–1%) ceiling a synthetic single-int-field 4-hop
benchmark suggested (that number was real but not representative of real
chains' wider constructors and object-typed
#[Input]arguments).Proposed design (not implemented — see open questions)
(parameterName, sort($parameterAttributes), valueKey).gettype($value) . ':' . var_export($value, true)(avoids1/"1"/truecollisions).
spl_object_id($value). Safe only because Be Framework'spublic readonlyproperty convention means object identity implies thevalue hasn't changed since construction — a documented convention, not
something the type system enforces.
array for a cache key can cost more than the ~5–6µs/hop the cache would
save; not worth the complexity for the fraction of
#[Input]args that arearrays.
#[Validate]method resolved for the variable takeszero
#[Inject]parameters (see "Purity is not guaranteed" below).Becoming::__invoke()chain and must be reset when anew chain starts — never shared across chains on a long-running
SemanticValidatorinstance (that would reproduce the unbounded-growthproblem already documented for
SemanticLoggerindocs/semantic-log-architecture.md).Open correctness questions (why this isn't implemented yet)
SemanticValidationMethodResolverexplicitlysupports
#[Validate]methods that also take#[Inject]parameters(repository, clock, external service) — see the
hasInjectAttributefiltering in
getMatchingValidationMethods/hasEnoughArguments/matchesParameterAttributes. A validator consulting injected state (e.g. auniqueness check against a repository) can legitimately return a different
answer for the same value at different points in time; caching by value
alone would silently serve a stale result. The design above tries to guard
against this by excluding
#[Inject]-consuming validators, but that's astructural heuristic, not a purity guarantee (a zero-
#[Inject]-parametermethod can still be impure via
time(), global state, etc.).tests/FakeApp/SemanticVariables/Age.phpdeclares both
validateAge(int $age)andvalidateTeen(#[Teen] int $age)—which method(s) run depends on the call site's
$parameterAttributes, notjust the value. A cache keyed on
(name, value)alone would let a later#[Teen] int $ageparameter skip validation on the strength of an earlierplain
int $agecheck with the same value — wrong. The key must includethe resolved attribute set.
beginChain()-style reset needsto be threaded from
Becoming::__invoke()down toSemanticValidator, andSemanticValidator::validateWithAttributes()is also reachable directly viavalidateProps()and the public legacy methods (validate(),validateLegacy(),validateAndThrow(),validateObject()). If the resethook only fires from
Becoming::__invoke(), any caller usingSemanticValidatordirectly outside a chain accumulates cache entries thatare never cleared and can serve stale results across unrelated validations —
the same unbounded-growth failure mode as the
SemanticLoggerissue. Needseither an explicit "inside an active chain" flag that gates the cache off by
default, or a different ownership model entirely.
of whatever hook is needed should follow the cleanest design rather than
avoiding interface changes for their own sake.
Suggested next step
Resolve (3) first — decide who owns the chain-scoped cache lifecycle and how it
degrades safely to "no caching" outside an active chain — before writing any
caching code. (1) and (2) are addressable in the key/eligibility design once
(3) is settled.