Repository navigation
FreeVariables leaks a set builder's %1 placeholder, and only a lambda counts as a binder #989
Description
Activity
§1 is fixed in #1000 — and one sentence in this issue was wrong, in a way that would have halved the fix.
I wrote above that
Vars"promises less: it is documented as the variables that occur, minus the named constants, and an occurrence is what it counts", and filed §1 againstFreeVariablesalone. Measuring it before fixing it showed that is not what is happening:"{ k : k > 0 }".ToEntity().Vars // { %1 } "{ k : k > a }".ToEntity().VarsAndConsts // { %1, a }
%1does not occur either. The leak is one channel —DirectChildren— andVarsandVarsAndConstsboth walk it, soVarswas not being lenient about occurrence, it was breaking its own promise in both directions at once: the name that occurs (k) was missing, and the name that does not (%1) was there. Fixing only the property I named here would have left the invented name in the more widely used one.All three now answer for
{ k : ... }what they already answered forlambda(k, ...):lambda(k, k > a){ k : k > a }was{ k : k > a }nowFreeVariables{ a }{ %1, a }{ a }Vars{ k, a }{ %1, a }{ k, a }Also worth recording, since the obvious fix is at the source and it is the wrong one: the rename in
DirectChildrenis load-bearing and #1000 does not touch it. It is what gives alpha-invariance toSortHash,EqualsImpreciselyand pattern matching —{ x : x > 0 } = { y : y > 0 }isTruebecause of it, and there is now a test saying so. What made the narrow fix safe is that the two rewriting consumers never used it:ConditionalSet.Replaceisfunc(New(Var, Predicate.Replace(func)))andConditionalSet.Substitutedoes its own capture-avoiding rename, both working fromVarandPredicatedirectly. Only the reporting properties ever saw%1.§2 is untouched and this issue stays open for it. Whether
sum,integral,limitandderivativeshould bind their variable inFreeVariablesis still a design call I would rather have answered than assume — it is documented behaviour and a breaking change for anyone reading the property as "occurring variables".Re-measured on
masterat6b93b401(2.3.0), .NET 10. Part 1 is fixed; part 2 is untouched, and it is the half that was a question rather than a defect.1. The placeholder no longer escapes — fixed by #1000.
"{ k : k > 0 }".ToEntity().FreeVariables // { } was { %1 } "{ k : k > 0 }".ToEntity().Vars // { k }2. Still only a lambda is treated as a binder. Every line of that block reproduces exactly as filed:
lambda(k, k) FreeVariables = { } correct sum(k, k, 1, 3) FreeVariables = { k } integral(k, k) FreeVariables = { k } limit(k, k, 0) FreeVariables = { k } derivative(k, k) FreeVariables = { k }So the question this issue actually asked is still open, and it has got slightly sharper since:
sum(k, k, 1, 3)evaluates to6, andFreeVariablessays that value depends onk.Leaving this open for its second half. It is a breaking change to a public property that has been there a long time, the migration target already exists (
VarsAndConstsis what "occurring variables" means), and #746 reserves a decision of this shape for a major — so it wants an answer rather than a patch. Retitling would be reasonable; I have not, since the title still describes what is left if you read past the first clause.VarAndConsts is what it says: free variables and constants (pi and e). "Used variables" looks like another property.
That settles it, and it makes the answer sharper than the question was — thank you.
Measured against your definition, on
masterat6b93b401. The documented example on the property itself isLambda(x, x * 2 + sin(y * pi)):today free variables and constants Varsx, y— VarsAndConstsx, y, piy, pi—xis bound by the lambdaFreeVariablesyySo
VarsAndConstsis not what it says, and it is not a binder-coverage gap either: it is wrong forlambda, the one binder the library already honours inFreeVariables. The XML example pins the wrong answer.That means my framing above was wrong in a way worth correcting rather than quietly dropping. I wrote that extending "bound" to every binder "is a breaking change for anyone who reads
FreeVariablesas occurring variables — which is whatVarsAndConstsis for, so the migration exists." It does not exist.VarsAndConstsis not the home for "every name that occurs"; by your definition it is a second thing that also has to change, and "used variables" is a third property that does not exist yet.So the shape is three properties moving together, not one:
FreeVariables— extend "bound" from lambda to all six binders. Its doc comment still defines bound as "a parameter of some outer lambda", which was true when written and stopped being true whenCalculusOperatorandConditionalSetgained a declaredVarin Let a binder bindi, in every binder there is (#976) #986.VarsAndConsts— free variables and constants, per your definition.- A new "used variables" — every name that occurs, bound ones included. That is exactly what
Vars/VarsAndConstsdo today, so no behaviour is lost; it gets a name that describes it.
Since two published properties change what they return, this is a major-version change rather than something to slip into a minor. I have put it on #1019, the pending-breakages docket you asked for on #1009, with the measurement above.
Part 1 of this issue — the
%1placeholder escaping — was a defect on any reading and is fixed (#1000). Leaving this open for part 2, which is now a decision that has been taken rather than one waiting to be.Re-measured on
masterat69e40108while sweeping the open issues before a release. Most of this
is done, and one part is left that is smaller and more specific than the issue describes.FreeVariablesVars{ k : k > 0 }{ }{ k }lambda(k, k){ }{ k }sum(k, k, 1, 3){ }{ k }product(k, k, 1, 3){ }{ k }integral(k, k, 0, 1){ }{ k }integral(k * b, k, 0, 1){ b }— integral(k, k){ k }{ k }derivative(k, k){ k }{ k }limit(k, k, 0){ k }{ k }(1), the placeholder leak, is fixed — #1000.
{ k : k > 0 }.FreeVariablesis{ }.(2) was answered — #1045. A summation, a product and a definite integral bind their
variable. An indefinite integral and a derivative deliberately do not, and that commit gives the
reason rather than leaving it implicit: the antiderivative oft * bovertis
b * t ^ 2 / 2 + C, still a function oft, andd/dtdenotes a function oft. That
is the right call and I am not reopening it.VarsandVarsAndConstsare untouched, as
that issue asked.What is left:
limitLimitf(Expression, Var, Destination, ApproachFrom) : CalculusOperator(Expression, Var)— the
same shape asIntegralfandDerivativef— and it is not in the binder switch
#1045 added. That commit's message enumerates the indefinite integral and the derivative as
deliberate exclusions and does not mentionlimitat all, so this reads as an omission rather
than a decision.And unlike those two, a limit does not have the property that justifies excluding them. An
antiderivative and a derivative are still functions of the variable; a limit never is —
lim_{k→0} kis0, and no limit's value depends on the name it approaches along. So the
variable is consumed exactly as a summation index is.One line in the same switch, next to
Integralf. Happy to send it if that reading is right —
it is a behaviour change on a public property, so it wants the sameBREAKING-CHANGESentry
#1045 got.- added a commit that references this issue
on Aug 27, 2026 Resolved on
masterat5aad93d8. Both halves, measured:FreeVariables{ k : k > 0 }{ }lambda(k, k){ }sum(k, k, 1, 3){ }product(k, k, 1, 3){ }integral(k, k, 0, 1){ }limit(k, k, 0){ }limitleft(k, k, 0){ }integral(k, k){ k }— on purposederivative(k, k){ k }— on purpose- (1) the placeholder leak — Do not report a name the expression does not contain (#989) #1000.
- (2) the binders — A bound index is not a free variable (#1019) #1045 for the summation, the product and the definite integral;
A limit binds the variable it approaches along (#989) #1091 for the limit, which that PR had missed.
The two that still report their variable do so deliberately, and the reason is what separates
them from the limit: an antiderivative oft * bovertisb * t ^ 2 / 2 + Candd/dt
denotes a function oft— both are still functions of the variable, while a limit never is.
lim(t, t, 0)is0.VarsandVarsAndConstsare untouched throughout, as this issue asked — they mean every name
occurring.Leaving it to you to close, since (2) was posed here as a design question rather than a defect
report ("I would rather ask than assume; the property is public and old"). The answer that got
implemented is extend "bound" to every binder whose value cannot depend on the name, which is
narrower than "every binder" and is why the two above are excluded. If that is not the answer you
wanted, the place to say so is before a release goes out with it.Measured on
masterat9d9b21e4, .NET 10, default settings. Both parts, on the issue's own inputs.{ k : k > 0 } -> { } lambda(k, k) -> { } sum(k, k, 1, 3) -> { } product(k, k, 1, 3) -> { } integral(k, k, 0, 1) -> { } limit(k, k, 0) -> { } integral(k, k) -> { k } derivative(k, k) -> { k }(1) is fixed. The
%1placeholder no longer escapes: the set builder answers{ }, not{ %1 }.(2) is fixed, and the two remaining
{ k }are correct rather than left over. Every binder is honoured now. What separates the last two rows from the rest is not the node type but whether the variable is actually bound:integral(k, k)is an indefinite integral, so its value is a function ofk— as isderivative(k, k). Add bounds and it binds, which is theintegral(k, k, 0, 1) -> { }row. SoFreeVariablesmeans what the name means on every one of these.Closing as fixed.
Measured on
masterat4ee698da.1. A set builder leaks its internal placeholder
%1is the fresh nameConditionalSet.InitDirectChildreninvents so that the set's variable is not confused with a variable of the same name outside it. It is not in the expression the caller wrote, it cannot be typed, andVariable.CreateTempwill invent a different one for a different predicate — so this is neither the bound name nor a stable answer.FreeVariablesrecurses throughDirectChildrenand picks it up.Whatever the definition of "free" is, this is not it.
2. Only a lambda is treated as a binder
The XML doc says so plainly — "We call a bound variable a variable which is a parameter of some outer lambda. Then, all other variables are free." — so this is a documented choice rather than an oversight, and I am not filing it as a bug so much as asking whether the choice still holds.
It reads oddly now for two reasons.
sum(k, k, 1, 3)is6: nothing about the answer depends onk, and a caller askingFreeVariablesto find out what an expression depends on is told it depends onk. And #986 has just made "these are binders" explicit in the type system —CalculusOperatorandConditionalSetboth bindVar, and that is now where the bound name is decided. Six binders exist and one of them is honoured here.Varshas the same shape ("sum(k, k, 1, 3)".ToEntity().Varsis{ k }), butVarspromises less: it is documented as the variables that occur, minus the named constants, and an occurrence is what it counts.What I think
(1) is a defect on any definition and worth fixing on its own — the placeholder should not escape.
(2) is a design call. Extending "bound" to every binder makes
FreeVariablesmean what the name means, and it is a breaking change for anyone who reads it as "occurring variables" — which is whatVarsAndConstsis for, so the migration exists. I would rather ask than assume; the property is public and old.Found while restoring three probe cases (
vars,freevars,varsconsts) and writing a one-line comment saying what each does. The comment was wrong, which is how the difference turned up.