Skip to content

Proposal: skip re-validation of unchanged semantic-variable values within a metamorphosis chain #81

Description

@koriym

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)

  1. 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.).
  2. 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.
  3. 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.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions