Repository navigation
fix(conditions): pair each Fn::Equals once when building condition CNF - #4987
Merged
Merged
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4987 +/- ##
==========================================
- Coverage 94.80% 94.80% -0.01%
==========================================
Files 441 441
Lines 16124 16128 +4
Branches 3130 3130
==========================================
+ Hits 15287 15290 +3
Misses 463 463
- Partials 374 375 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
kddejong
marked this pull request as ready for review
November 3, 2026 22:03
A condition's `equals` includes the Fn::Equals of every condition it references, so the mutual-exclusion loop added in aws-cloudformation#4805 visited the same Fn::Equals once per referrer and re-emitted its exclusion clauses each time. The clause count grew quadratically in the number of references rather than in the number of distinct Fn::Equals. Skipping an Fn::Equals whose hash has already been paired is logically a no-op: `Equal.cnf` is `Symbol(hash)`, so a repeat visit produces a clause sympy has already been given. On the template from aws-cloudformation#4984 (30 Fn::Equals on AWS::Region, 30 Fn::Or groups of 10) this takes the CNF from 53,025 clauses to 825 and the lint from 17.4s to 1.3s, with identical output. Fixes aws-cloudformation#4984
…oops The AllowedValues loop walked the same conditions x equals product and re-visited each Fn::Equals once per referrer, appending duplicate symbols into equals_cnfs. That one was harmless -- sympy's Or is idempotent, so the duplicates collapsed to a single clause -- but it is the same latent shape as the clause blowup, so give it the same guard. Hoist the dedupe into one distinct_equals map built before both loops, mirroring the shared equal_vars dict in the template-level engine (cfnlint/conditions/conditions.py) rather than keeping a separate guard per loop. Also hoist the per-parameter Ref hash out of the inner loop. Behaviour is unchanged: insertion order preserves the previous first-seen iteration order, so the NAND pairs are emitted in the same order, and the AllowedValues coverage check is unaffected because two distinct Fn::Equals on one Ref always carry different values.
kddejong
force-pushed
the
fix/issue/4984
branch
from
November 3, 2026 22:03
aecc130 to
c7a9339
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4984
Problem
A condition's
equalstraverses into every condition it references, so the sameFn::Equalsis reachable once per referrer. The mutual-exclusion loop added in #4805 paired anFn::Equalsagainst everything already seen on each of those visits, re-emitting exclusion clauses it had already emitted.The duplicates are bit-identical clauses. Counting multiplicity in the unfixed CNF for the reporter's template:
390 clauses appear once — the base condition equivalences, untouched by this PR. The other 435 are the exclusion pairs, each stored 121 times. 390 + 435×121 = 53,025 (the figure in the issue), and 390 + 435 = 825 (what this PR produces). The clause set is unchanged; only the duplicate copies go away.
The 121 is exact. The template has 30
Fn::EqualsonAWS::Regionand 30Fn::Orgroups that are sliding windows of 10 over them, so each comparison is reached 11 times — once through its own definition and once through each of the 10 groups containing it. The pairing loop appends every visit to itsseenlist, so a given pair of comparisons is emitted once per ordered (visit, visit) combination: 11 × 11 = 121, acrossC(30,2)= 435 distinct pairs.Note the
Equalobjects are distinct Python objects but the same sympy variable, which is why only the clause count grows and not the variable count:Fix
Skip an
Fn::Equalswhose hash has already been paired.This is the guard the template-level engine has always had.
Conditions._build_cnfinsrc/cfnlint/conditions/conditions.pyopens its equivalent loop with:#4805 set out to bring the context-level engine in line with the template-level one, and it ported the constraint correctly — but not that guard. This PR adds it.
The skip is logically a no-op rather than a behaviour change:
Equal.cnfisSymbol(self.hash), so a repeat visit hands sympy a clause it already holds. The constraint #4805 added is fully preserved — the resulting CNF is the pre-#4805 baseline plus exactly theC(n,2)pairs that PR intends.Before #4805 the loop was gated to parameters with
AllowedValues, soAWS::Regionnever entered it. #4805 correctly widened the constraint to everyRef; the duplicate pairing was pre-existing in the loop's shape and became reachable for the common case.Second commit: the same guard on the
AllowedValuesloopThe loop below it walked the same
conditions x equalsproduct and re-visited eachFn::Equalsonce per referrer too, appending duplicate symbols intoequals_cnfs. That one was already harmless, because sympy'sOris idempotent and collapsed them to a single clause:No blowup, but it is the same latent shape, so it gets the same treatment. Rather than two separate guards, the dedupe is hoisted into one
distinct_equalsmap built before both loops — mirroring how the template-level engine keeps a single sharedequal_varsdict across every_build_equal_varscall. The per-parameterRefhash also moves out of the inner loop.Behaviour is unchanged. Insertion order preserves the previous first-seen iteration order, so the NAND pairs are emitted in the same order, and the
AllowedValuescoverage check is unaffected because two distinctFn::Equalson oneRefalways carry different values (Equal.create_from_instancesorts the instance before hashing, so the operand order cannot make one comparison look like two).Measurements
Fn::Equals/ 20 groupsFn::Equals/ 30 groupsThe before-counts match the figures in the issue exactly. The after-counts are the 1.58.0 baseline (260 / 390) plus the 190 / 435 pairs #4805 adds.
Verification
Output is unchanged. Every template under
test/fixtures/templates/(260 files) was linted throughcfnlint.api.lint_allbefore and after, withPYTHONHASHSEED=0pinned, and the collected matches are byte-identical. (Without pinning the seed, two runs of the same code differ, because E3019 interpolates a Pythonsetinto its message — unrelated to this change.)Templates confirmed to stay silent:
test/fixtures/templates/good/conditions/e3014_exclusive_conditions.yaml— the E3014: twoFn::Equalson the sameRefare treated as exclusive only for a Parameter withAllowedValues#4799 regression fixture, and itself the referencing shape this change dedupes (UseGroupis anFn::Notof anFn::OroverUseIpv4/UseIpv6, so theirFn::Equalsare visited twice). Still reports nothing.test/unit/module/context/conditions/,test/unit/module/conditions/andtest/unit/module/test_api.py.test/integration/andtest_cli_args.pyare pre-existing in my sandbox (they shell out to acfn-lintbinary that is not onPATH) and fail identically on an unmodifiedmain.Tests added
test_referenced_equals_paired_onceasserts the clause count stays bounded on the reproduction shape, and that the #4805 exclusion still holds. It fails without the fix (assert 53025 < 1800).test_inline_equals_mutual_exclusioncovers anFn::Equalsreachable only by traversing another condition, so a naive revert of #4805 would not pass it. It guards the other direction: that deduping does not drop the constraint.test_allowed_values_covered_through_referencecovers the second loop: anAllowedValuesparameter fully covered byFn::Equalsthat are only reachable by traversal. It is what catches a dedupe keyed on the wrong field — keying on theRefhash instead of theFn::Equalshash leaves a value uncovered, drops theOr, and fails this test (along with six others).Note on the second suggestion in the issue
The reporter also proposed memoizing
Conditions.evolve. That is a separate optimization with its own correctness argument (it assumes CNF clause lists are never mutated), and it is not needed to close this regression — the dedupe alone restores baseline timing. Left out to keep this change minimal; happy to open it separately if wanted.