Skip to content

Do not report a name the expression does not contain (#989) - #1000

Merged
Rafael-SOWNet merged 2 commits into
masterfrom
fix/freevariables-placeholder-989
Aug 22, 2026
Merged

Do not report a name the expression does not contain (#989)#1000
Rafael-SOWNet merged 2 commits into
masterfrom
fix/freevariables-placeholder-989

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

Fixes §1 of #989. Leaves §2 open — see the end.

The defect

ConditionalSet.DirectChildren is its predicate with the bound name renamed to a fresh one, so that two builders differing only in that name compare and hash alike. Every property that reports names by walking DirectChildren picked that invented name up and returned it.

"{ k : k > 0 }".ToEntity().FreeVariables   // was: { %1 }        now: { }
"{ k : k > 0 }".ToEntity().Vars            // was: { %1 }        now: { k }
"{ k : k > a }".ToEntity().FreeVariables   // was: { %1, a }     now: { a }
"{ k : k > a }".ToEntity().Vars            // was: { %1, a }     now: { k, a }
"x + { k : k > a }".ToEntity().Vars        // was: { x, %1, a }  now: { x, k, a }

%1 is neither answer on anybody's definition. It is not the bound name, it is not in the expression the caller wrote, Variable.CreateTemp invents a different one for a different predicate, and it cannot be typed — the parser has no %.

Wider than the issue says

I filed #989 against FreeVariables and wrote there that Vars "promises less: it is documented as the variables that occur, and an occurrence is what it counts". That was wrong, and measuring it is what showed it: the leak is one channel — DirectChildren — and Vars and VarsAndConsts go through it too. %1 does not occur either, and k, which does, was missing. So Vars was not being lenient, it was breaking its own promise in both directions.

Fixing only the property the issue named would have left the same invented name in the more widely used one.

The fix

A set builder binds the name it declares exactly as a lambda binds its parameter, so all three properties now answer for { k : ... } what they already answered for lambda(k, ...):

lambda(k, k > a) { k : k > a } was { k : k > a } now
FreeVariables { a } { %1, a } { a }
Vars { k, a } { %1, a } { k, a }

Two cases added, next to the Lambda case that was already there.

What is deliberately untouched

DirectChildren still publishes the renamed predicate. I checked whether the rename could simply be dropped at the source — it is what gives alpha-invariance to SortHash, EqualsImprecisely and pattern matching, and { x : x > 0 } = { y : y > 0 } is True because of it. There is a test asserting that still holds.

It was worth checking because the two obvious rewriting consumers do not use it: ConditionalSet.Replace is func(New(Var, Predicate.Replace(func))) and ConditionalSet.Substitute does its own capture-avoiding rename, both from Var and Predicate directly. So only the reporting properties ever saw %1, and only they change.

§2 is not answered here

Whether sum, integral, limit and derivative should bind their variable in FreeVariables too is the other half of #989. It is a documented choice — the XML doc says "a parameter of some outer lambda" — and a breaking change for anyone reading the property as "occurring variables", so I would rather have an answer than assume one. That issue stays open.

Measured

  • Suite: 7376 passed, 0 failed, 14 skipped. Without the new tests it is 7366, the master baseline.
  • Corpus: 116/119, 0 wrong, 0 error, 0 timeout — compared row by row against the recorded report, no case's verdict or answer changed, only timings.
  • 10 new cases. Controlled: with the fix reverted they are 6 failed, 11 passed, the passes being the pre-existing lambda cases and the alpha-equivalence check, which should pass either way.
  • BREAKING-CHANGES.md entry with both values measured on a build of each arm.

Against the other open PRs

Derived with git merge-tree --write-tree:

against conflicts
#990 fix/bound-name-must-be-symbolic BREAKING-CHANGES.md only
#991 constant-node-984 BREAKING-CHANGES.md only
#997 fix/special-set-membership-995 BREAKING-CHANGES.md only
#998 fix/determinant-division-free-992 BREAKING-CHANGES.md only

No source conflicts with any of them. Whichever merges last takes the mechanical round and I will do it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KbKcbJP266A3EGyQ5kq7Ru

ConditionalSet.DirectChildren is its predicate with the bound name renamed
to a fresh one, so that two builders differing only in that name compare
and hash alike. Every property that reports names by walking DirectChildren
returned that invented name.

  "{ k : k > 0 }".ToEntity().FreeVariables   was: { %1 }  now: { }
  "{ k : k > 0 }".ToEntity().Vars            was: { %1 }  now: { k }
  "{ k : k > a }".ToEntity().Vars            was: { %1, a }  now: { k, a }

%1 is neither answer on anybody's definition: not the bound name, not in
the expression the caller wrote, different for a different predicate, and
not typeable -- the parser has no %. It broke Vars' own promise too, which
is the variables that occur; k occurs and was missing, %1 does not occur
and was there. The issue reports this for FreeVariables only, but the leak
is one channel and reaches all three.

A set builder binds the name it declares exactly as a lambda binds its
parameter, so all three now answer for { k : ... } what they already
answered for lambda(k, ...).

Only the reporting changed. DirectChildren still publishes the renamed
predicate, because that is what makes two alpha-equivalent builders equal,
and Replace and Substitute never used it -- both override and work from Var
and Predicate directly. { x : x > 0 } = { y : y > 0 } is still True, with a
test saying so.

The second half of the issue -- whether sum, integral, limit and derivative
should bind their variable here too -- is a documented choice rather than a
leak, and is left open.

Suite 7376 passed, 0 failed. Corpus unchanged at 116/119 with 0 wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbKcbJP266A3EGyQ5kq7Ru
@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

Cross-reference: #1001 (the SymPy exporter, #985) needs this one to finish its job, and says so on its own thread. ToSympyCode builds its preamble from expr.Vars, so a set builder emitted %1 = sympy.Symbol('%1') — a SyntaxError in Python regardless of what the expression line says.

Measured on a local integration branch of the two: 45 of 45 generated programs run under work/sympycheck (43 with #1001 alone, 24 on master), suite 7395 passed, 0 failed. Only BREAKING-CHANGES.md conflicts between them, and both halves are additive.

…laceholder-989

# Conflicts:
#	BREAKING-CHANGES.md
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.

1 participant