Skip to content

Fix exponential complexity evaluation via NameState memoization - #270

Open
fym-rgb wants to merge 1 commit into
mainfrom
fix-mce-exponential-evaluation
Open

Fix exponential complexity evaluation via NameState memoization#270
fym-rgb wants to merge 1 commit into
mainfrom
fix-mce-exponential-evaluation

Conversation

@fym-rgb

@fym-rgb fym-rgb commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Issue #, if available

Fixes #269 (MachineComplexityEvaluator re-walks downstream machines once per exact-match
list value)

Description of changes

MachineComplexityEvaluator.evaluate(ByteState) recursed into next NameStates once per
ByteMatch — once per exact-match list value — with no memoization. All values of one key's
list lead to the same next NameState, so a rule with exact-match lists of sizes L1..Lk ahead
of a wildcard-bearing key re-walked the wildcard key's ByteMachine L1×...×Lk times. For a
CloudTrail-style rule with 2×50×21 lists ahead of four leading-* anything-but-wildcard
patterns, one evaluateComplexity call walked the wildcard NFA 2,100 times: ~25 s and ~26 GB
of transient allocation to report complexity 7 for a machine whose construction costs 73 ms /
3.6 MB.

Two changes, each sufficient for the list-chain case, together also covering convergent
NameState graphs:

  1. Dedupe recursion targets by next NameState instead of by ByteMatch, collapsing a value
    list to its single downstream NameState before recursing.
  2. Memoize per-NameState results for the duration of a single evaluation: each entry point
    (NameState.evaluateComplexity, ByteMachine.evaluateComplexity) creates a fresh
    identity-keyed cache and threads it through the recursion as a parameter, so a NameState
    reachable via multiple distinct parents is still evaluated once per evaluation.

The cache's lifetime equals its validity window — one evaluation of a machine, which cannot
change mid-evaluation — so evaluator instances remain stateless: no public signature
changes, and reuse across evaluations (including after rule changes) and sharing between
threads behave exactly as before. The cache-threaded forms are package-private overloads.

Why results are unchanged

  • A NameState's complexity is a pure function of the machine reachable from it (a max over
    its ByteMachines' walk sizes and its descendants' values), so caching a computed value and
    serving it to later visitors within one evaluation cannot change any result.
  • Dedupe-by-target is value-neutral: a recursion's result depends only on its target
    NameState; ByteMatch equality is (pattern, nextNameState), so the set of distinct targets
    is the same either way, and Math.max over duplicates is insensitive to multiplicity.
  • Capped results cache the same capped value: a walk that hits maxComplexity returns and
    caches maxComplexity, exactly what re-computation would return.
  • The zero placeholder written before recursing is a defensive cycle-breaker only; NameState
    graphs built by rule compilation are acyclic, so it is always overwritten before it can be
    read.

Benchmarks (single shot, wall time + thread-allocated bytes, Corretto 17)

variant before after complexity
full rule (2×50×21 lists + 4 ABW leading-* wildcards) 25,368 ms / 25.6 GB 87 ms / 14.9 MB 7 → 7
lists only 28 ms / 4.9 MB 5 ms / 0.2 MB 0 → 0
wildcard key only 61 ms / 14.6 MB 64 ms / 14.5 MB 7 → 7

The "wildcard key only" row bounds the honest cost of walking that NFA once; the fix makes the
full rule cost that, instead of 2,100 times that. Complexity values are identical on every
variant measured.

Test coverage

  • MachineComplexityEvaluatorMemoizationTest (new, 13 tests): pins per-ByteMachine walk
    counts with a counting evaluator subclass — deterministic count assertions, no wall-clock
    bounds. Covers exact-match list chains (4 walks where pre-fix behavior does 33), list-size
    independence, the production-shaped rule (4 walks vs 2,203), a 10-key deep chain (11 walks
    vs 2,047), nested keys, $or sub-rules, and hand-built convergent NameState graphs that
    isolate the memoization from the dedupe (4 walks vs 5; wide convergence 7 vs 11). It also
    pins statelessness: repeat evaluation recomputes rather than serving evaluator state, one
    evaluator reflects rule changes across evaluations, evaluator reuse across distinct
    machines, and a shared evaluator returns correct results under concurrent use (4 threads).
  • MachineComplexityEvaluatorEquivalenceTest (new, 8 tests): property-style equivalence
    against a cache-disabled evaluator subclass that reproduces pre-fix values — every supported
    value pattern type singly, ordered pairs of the complexity-relevant types, chain/nested/
    $or/multi-rule shapes, both additionalNameStateReuse configurations, capped (1, 3) and
    uncapped maxComplexity, plus a 250-machine seeded random corpus (deterministic in CI;
    failure messages carry the full generated rules). Also asserts idempotence of repeat
    evaluation.
  • Both new suites were red-proofed: restoring pre-fix evaluation fails 8 of the 13 memoization
    tests with exactly the predicted walk counts (33, 2,203, 2,047, 15, 7, 5, 11), and injecting
    a corrupted cache value fails all 8 equivalence tests.
  • Full mvn verify green (800 tests, 0 failures; checkstyle + spotbugs clean) and the CI
    benchmark step mvn test -Dtest="Benchmarks#CL2*" green — complexity evaluation is not on
    the event-matching path, so match throughput is structurally unaffected.

Non-goal noted for maintainer judgment (pre-existing, unchanged)

While validating equivalence we observed that an anything-but wildcard set under-scores
relative to the same patterns as plain wildcards (the four exclusions above score 7 as an ABW
set vs 12 as four plain wildcard patterns, measured on this branch), because a values-set
shares one Patterns object and complexity counts distinct patterns. That behavior is
pre-existing, is preserved exactly by this PR (the equivalence suite pins it), and is
deliberately not changed here — it deserves its own discussion if it matters.

Related issues


By submitting this pull request, I confirm that my contribution is made under the terms of the
Apache-2.0 license.

MachineComplexityEvaluator.evaluate recursed into next NameStates once
per ByteMatch, i.e. once per exact-match list value. A rule whose sorted
key chain carries value lists of sizes L1..Lk therefore re-walked the
last key's ByteMachine L1*...*Lk times. With realistic CloudTrail-style
lists (2 x 50 x 21) ahead of a key holding four leading-star
anything-but-wildcard patterns, one evaluateComplexity call walked the
wildcard NFA 2,100 times: ~25 seconds and ~26 GB of transient
allocation for a machine whose complexity is 7 and whose construction
costs 73 ms / 3.6 MB.

Two changes, each sufficient for the list-chain case, together covering
convergent NameState graphs as well:

- Dedupe recursion targets by next NameState instead of by ByteMatch,
  so a value list collapses to its single downstream NameState.
- Memoize per-NameState results for the duration of a single
  evaluation: each entry point creates a fresh identity-keyed cache
  and threads it through the recursion, so any NameState reachable via
  multiple paths is evaluated once per evaluation.

The cache lives exactly as long as its validity window - one evaluation
of a machine, which cannot change mid-evaluation - so evaluator
instances remain stateless: safe to reuse across evaluations, including
after rule changes, and safe to share between threads, exactly as
before.

Results are unchanged: complexity of a NameState is a pure function of
the machine reachable from it, recursion results depend only on the
target NameState (ByteMatch equality is (pattern, nextNameState), and
max is insensitive to duplicates), and a result capped at maxComplexity
caches as that same capped value. The synthetic reproduction above now
evaluates in ~90 ms / ~15 MB with identical complexity values.

New coverage: MachineComplexityEvaluatorMemoizationTest pins the
per-ByteMachine walk counts (each distinct ByteMachine walked once per
evaluation, including hand-built convergent graphs) with deterministic
count assertions rather than wall-clock bounds; the production-shaped
case walks 4 machines where the old evaluation walked 2,203. It also
pins statelessness: repeat evaluation recomputes rather than serving
evaluator state, one evaluator reflects rule changes across
evaluations, and a shared evaluator returns correct results under
concurrent use. MachineComplexityEvaluatorEquivalenceTest asserts,
across every value pattern type, pairwise key combinations,
chain/nested/$or/multi-rule shapes, both additionalNameStateReuse
configurations, capped and uncapped maxComplexity values, and a
250-machine seeded random corpus, that memoized evaluation returns
exactly the value unmemoized evaluation returns, and that repeat
evaluation is idempotent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MachineComplexityEvaluator re-walks downstream machines once per exact-match list value: evaluation cost is the product of list sizes

1 participant