Skip to content

fix(cek): prevent variable capture in dischargeCekValue (#7526)#7548

Open
Unisay wants to merge 6 commits intomasterfrom
yura/issue-7526-discharge-cek-value-open-terms
Open

fix(cek): prevent variable capture in dischargeCekValue (#7526)#7548
Unisay wants to merge 6 commits intomasterfrom
yura/issue-7526-discharge-cek-value-open-terms

Conversation

@Unisay
Copy link
Contributor

@Unisay Unisay commented Jan 27, 2026

Context

  • What: Fix variable capture bug in CEK machine's dischargeCekValue function
  • Why: Free variables in environment values were incorrectly bound when discharged under lambda binders, causing semantic errors in term reconstruction
  • Issue: Fixes dischargeCekValue doesn't handle open terms correctly #7526

Approach

The bug occurred in dischargeCekValue when reconstructing terms from CEK runtime values. When an environment value containing free variables was discharged under lambda binders, those free variables would incorrectly capture to the lambda binder instead of remaining free.

Root Cause: goValue was called directly on environment values without accounting for the shift parameter tracking lambda depth. This meant free variables in environment values weren't shifted to preserve their "freeness" relative to the enclosing lambda context.

Solution: Instead of a two-pass approach (discharge + separate shiftTermBy post-pass), we fuse the shifting directly into the discharge traversal by threading a global shift parameter through goValue and goValEnv:

  • goValue gains a Word64 parameter (global) that accumulates shifts from outer discharge contexts
  • When a variable is found in the env, goValue (global + shift) passes the accumulated shift into the recursive discharge
  • When a variable is truly free (not in the env), shiftNamedDeBruijn (global + shift) shifts it directly
  • A new shiftNamedDeBruijn utility is added to PlutusCore.DeBruijn
  • The standalone shiftTermBy function is removed entirely

This single-pass approach avoids a redundant traversal and handles truly free variables (not found in the environment) consistently.

Tests

Comprehensive discharge tests in Evaluation.FreeVars:

  • Variable capture scenarios from the original bug (empty env with free vars under lambdas)
  • Nested closures with accumulated shifts across multiple env lookups
  • Truly free variables past non-empty environments (with and without lambdas, nested, and mixed bound/free vars)
  • Boundary condition: shift == idx verifying bound variable detection
  • Constructor (VConstr) arguments containing free variables under lambdas

@Unisay Unisay force-pushed the yura/issue-7526-discharge-cek-value-open-terms branch from fed648f to f6f915c Compare January 27, 2026 15:06
@Unisay Unisay self-assigned this Jan 27, 2026
@Unisay Unisay requested review from a team and SeungheonOh January 27, 2026 15:24
@basetunnel
Copy link
Collaborator

Is there a reason to fix this immediately? We have the scope check in place to reject open terms before CEK execution, and I'm not aware of any code path that evaluate open terms. As this could slow down the CEK machine, it only seems necessary if we ever remove the scope check.

@basetunnel
Copy link
Collaborator

/benchmark validation

@github-actions
Copy link
Contributor

Click here to check the status of your benchmark.

@github-actions
Copy link
Contributor

Comparing benchmark results of 'validation' on 'd548debc10' (base) and 'b93455d76d' (PR)

Results table
Script d548deb b93455d Change
auction_1-1 172.6 μs 170.4 μs -1.3%
auction_1-2 545.0 μs 552.2 μs +1.3%
auction_1-3 542.2 μs 556.2 μs +2.6%
auction_1-4 217.8 μs 220.0 μs +1.0%
auction_2-1 167.8 μs 169.9 μs +1.3%
auction_2-2 545.7 μs 550.1 μs +0.8%
auction_2-3 706.8 μs 716.0 μs +1.3%
auction_2-4 540.6 μs 546.5 μs +1.1%
auction_2-5 217.6 μs 220.4 μs +1.3%
coop-1 238.0 μs 237.3 μs -0.3%
coop-2 764.6 μs 736.8 μs -3.6%
coop-3 2.027 ms 2.022 ms -0.2%
coop-4 912.5 μs 911.6 μs -0.1%
coop-5 397.8 μs 397.3 μs -0.1%
coop-6 701.8 μs 676.9 μs -3.5%
coop-7 342.1 μs 328.6 μs -3.9%
crowdfunding-success-1 197.0 μs 198.8 μs +0.9%
crowdfunding-success-2 197.0 μs 199.6 μs +1.3%
crowdfunding-success-3 202.0 μs 199.3 μs -1.3%
currency-1 219.9 μs 220.0 μs +0.0%
escrow-redeem_1-1 309.9 μs 315.1 μs +1.7%
escrow-redeem_1-2 309.1 μs 314.6 μs +1.8%
escrow-redeem_2-1 367.7 μs 364.3 μs -0.9%
escrow-redeem_2-2 358.3 μs 363.9 μs +1.6%
escrow-redeem_2-3 358.3 μs 364.8 μs +1.8%
escrow-refund-1 145.8 μs 147.6 μs +1.2%
future-increase-margin-1 220.4 μs 220.2 μs -0.1%
future-increase-margin-2 465.7 μs 473.8 μs +1.7%
future-increase-margin-3 466.5 μs 474.2 μs +1.7%
future-increase-margin-4 422.3 μs 427.5 μs +1.2%
future-increase-margin-5 717.9 μs 720.0 μs +0.3%
future-pay-out-1 220.0 μs 220.3 μs +0.1%
future-pay-out-2 465.9 μs 473.2 μs +1.6%
future-pay-out-3 464.9 μs 473.7 μs +1.9%
future-pay-out-4 708.3 μs 720.4 μs +1.7%
future-settle-early-1 221.7 μs 220.9 μs -0.4%
future-settle-early-2 466.0 μs 473.6 μs +1.6%
future-settle-early-3 465.1 μs 473.3 μs +1.8%
future-settle-early-4 538.8 μs 546.6 μs +1.4%
game-sm-success_1-1 344.2 μs 347.7 μs +1.0%
game-sm-success_1-2 187.4 μs 190.0 μs +1.4%
game-sm-success_1-3 549.7 μs 562.5 μs +2.3%
game-sm-success_1-4 217.6 μs 218.1 μs +0.2%
game-sm-success_2-1 344.1 μs 347.4 μs +1.0%
game-sm-success_2-2 187.4 μs 189.8 μs +1.3%
game-sm-success_2-3 546.9 μs 562.7 μs +2.9%
game-sm-success_2-4 217.3 μs 218.1 μs +0.4%
game-sm-success_2-5 546.7 μs 554.5 μs +1.4%
game-sm-success_2-6 217.1 μs 218.2 μs +0.5%
multisig-sm-01 344.9 μs 348.3 μs +1.0%
multisig-sm-02 337.8 μs 339.7 μs +0.6%
multisig-sm-03 337.4 μs 340.7 μs +1.0%
multisig-sm-04 340.6 μs 344.6 μs +1.2%
multisig-sm-05 476.3 μs 482.4 μs +1.3%
multisig-sm-06 343.4 μs 347.8 μs +1.3%
multisig-sm-07 338.2 μs 339.9 μs +0.5%
multisig-sm-08 336.2 μs 341.2 μs +1.5%
multisig-sm-09 340.9 μs 341.0 μs +0.0%
multisig-sm-10 475.4 μs 482.9 μs +1.6%
ping-pong-1 287.9 μs 290.0 μs +0.7%
ping-pong-2 291.7 μs 290.9 μs -0.3%
ping-pong_2-1 180.9 μs 186.2 μs +2.9%
prism-1 158.3 μs 159.5 μs +0.8%
prism-2 358.5 μs 363.0 μs +1.3%
prism-3 327.2 μs 329.5 μs +0.7%
pubkey-1 134.5 μs 136.0 μs +1.1%
stablecoin_1-1 839.2 μs 840.3 μs +0.1%
stablecoin_1-2 183.3 μs 184.9 μs +0.9%
stablecoin_1-3 951.5 μs 951.1 μs -0.0%
stablecoin_1-4 200.6 μs 196.4 μs -2.1%
stablecoin_1-5 1.210 ms 1.208 ms -0.2%
stablecoin_1-6 239.6 μs 241.4 μs +0.8%
stablecoin_2-1 826.5 μs 836.5 μs +1.2%
stablecoin_2-2 183.0 μs 184.4 μs +0.8%
stablecoin_2-3 950.6 μs 961.0 μs +1.1%
stablecoin_2-4 194.7 μs 196.4 μs +0.9%
token-account-1 167.5 μs 169.9 μs +1.4%
token-account-2 292.8 μs 298.6 μs +2.0%
uniswap-1 351.4 μs 349.2 μs -0.6%
uniswap-2 197.7 μs 199.6 μs +1.0%
uniswap-3 1.510 ms 1.513 ms +0.2%
uniswap-4 309.2 μs 313.9 μs +1.5%
uniswap-5 1.022 ms 1.016 ms -0.6%
uniswap-6 294.1 μs 298.3 μs +1.4%
vesting-1 304.7 μs 307.6 μs +1.0%
d548deb b93455d Change
TOTAL 36.55 ms 36.76 ms +0.6%

@basetunnel
Copy link
Collaborator

/benchmark nofib

@github-actions
Copy link
Contributor

Click here to check the status of your benchmark.

@github-actions
Copy link
Contributor

Comparing benchmark results of 'nofib' on 'd548debc10' (base) and 'b93455d76d' (PR)

Results table
Script d548deb b93455d Change
clausify/formula1 1.957 ms 1.936 ms -1.1%
clausify/formula2 2.635 ms 2.612 ms -0.9%
clausify/formula3 7.211 ms 7.150 ms -0.8%
clausify/formula4 15.72 ms 15.67 ms -0.3%
clausify/formula5 35.11 ms 34.74 ms -1.1%
knights/4x4 11.83 ms 11.57 ms -2.2%
knights/6x6 28.21 ms 27.46 ms -2.7%
knights/8x8 48.53 ms 47.27 ms -2.6%
primetest/05digits 5.444 ms 5.452 ms +0.1%
primetest/10digits 10.70 ms 10.75 ms +0.5%
primetest/30digits 31.27 ms 31.50 ms +0.7%
primetest/50digits 50.97 ms 51.40 ms +0.8%
queens4x4/bt 3.480 ms 3.424 ms -1.6%
queens4x4/bm 4.495 ms 4.456 ms -0.9%
queens4x4/bjbt1 4.209 ms 4.142 ms -1.6%
queens4x4/bjbt2 3.943 ms 3.882 ms -1.5%
queens4x4/fc 8.704 ms 8.596 ms -1.2%
queens5x5/bt 47.64 ms 46.90 ms -1.6%
queens5x5/bm 52.52 ms 52.47 ms -0.1%
queens5x5/bjbt1 55.84 ms 55.14 ms -1.3%
queens5x5/bjbt2 54.79 ms 53.92 ms -1.6%
queens5x5/fc 109.9 ms 109.0 ms -0.8%
d548deb b93455d Change
TOTAL 595.1 ms 589.4 ms -1.0%

@effectfully
Copy link
Contributor

@basetunnel

We have the scope check in place to reject open terms before CEK execution

Only in prod where it doesn't matter, because discharging doesn't happen in prod (it would be a major bug if it did).

I.e. this doesn't introduce any slowdown on the critical path and a slowdown in tests and whatever is completely fine. Perhaps even what looks like a quadratic-in-case-of-free-variables version implemented in this PR.

@Unisay Unisay enabled auto-merge (squash) February 5, 2026 14:36
@Unisay Unisay requested a review from basetunnel February 5, 2026 14:37
-- var is in the env, discharge its value
goValue
-- var is in the env, discharge its value and shift free vars
(shiftTermBy shift . goValue)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to measure how this call impacts overall CEK performance?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it does a full traversal over each value (at least when shift > 0), I'd be interested if this logic could be fused in goValue instead.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's an attempt which passes the new tests: yura/issue-7526-discharge-cek-value-open-terms...basetunnel/issue-7526-discharge-cek-value-open-terms

It maintains a "global" shift, so that any free variable encountered in goValEnv can directly be shifted. I don't know how to test it performance-wise, but it avoids doing an extra pass.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I reworked the PR to use your global shift approach.

Add shiftTermBy function to correctly shift free variables when discharging values from the environment. Previously, free variables in discharged terms could be captured by outer lambdas, causing incorrect variable references in the output term.

The fix tracks binding depth during discharge and shifts free variables (those with indices beyond the current binding depth) by the appropriate amount to maintain correct scoping.

Resolves #7526
Add 8 test cases covering variable capture scenarios in dischargeCekValue:

- Free variables under 1, 2, and 3 lambdas

- Deeply indexed free variables

- Multiple free variables in the same term

- Nested environment structures

Tests verify that free variables are correctly shifted to prevent capture when terms are discharged from the evaluation environment.

Tests for #7526
Document the variable capture bug fix in the changelog.
Replace the two-pass dischargeCekValue implementation (discharge + shiftTermBy
post-pass) with a single-pass approach that threads a global shift parameter
through goValue/goValEnv. This avoids a separate traversal for shifting and
handles truly free variables (not found in the environment) consistently.

- Add shiftNamedDeBruijn utility to PlutusCore.DeBruijn
- Thread `global` shift parameter through goValue and goValEnv
- Delete the standalone shiftTermBy function
- Add 4 new tests for truly free vars past non-empty environments
@Unisay Unisay force-pushed the yura/issue-7526-discharge-cek-value-open-terms branch from b93455d to e4e6afb Compare February 11, 2026 11:23
@Unisay Unisay requested a review from basetunnel February 11, 2026 18:53
-- We only return a discharged builtin application when (a) it's being returned by the
-- machine, or (b) it's needed for an error message.
-- @term@ is fully discharged, so we can return it directly without any further discharging.
VBuiltin _ term _ -> term
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This term should also be shifted, see the problematic example at #7526 (comment). Perhaps it can be turned in a test too?


-- | Shift a 'NamedDeBruijn' index by a given amount.
shiftNamedDeBruijn :: Word64 -> NamedDeBruijn -> NamedDeBruijn
shiftNamedDeBruijn i (NamedDeBruijn t (Index n)) = NamedDeBruijn t (Index (n + i))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we need a bang pattern on i? It seems like this is usually done in the CEK code dealing with indices.

Document why VBuiltin terms don't need global shifting during
discharge, and note the unchecked Word64 overflow in
shiftNamedDeBruijn.
Add shift==idx boundary test verifying bound variable detection,
and VConstr test verifying free variables in constructor arguments
are shifted correctly under lambdas.
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.

dischargeCekValue doesn't handle open terms correctly

3 participants