ADR-029: A Higher-Order Function Does What Its Lambda Does

Status: Superseded by ADR-288 — its D1-D4 are D13-D16 there Extends: ADR-027 §7, which named effect polymorphism as the larger version of the problem, and ADR-028 D6 Related: ADR-024 (? is the absence of a claim), ADR-020 (the ledger’s type language this widens), ADR-031 (closes §4)


1. Context

ADR-028 gave std’s higher-order methods signatures so that the type checker could resolve through them, and deliberately gave them no sync:

Entry::and_modify and Entry::or_insert_with run the lambda they are given. Whether they pause depends on what is in it, and a ledger cannot say “it depends” yet.

That was honest and it was expensive. A ledger entry has to hold for every caller, so a function that runs somebody else’s code has to commit to the pessimistic answer — and the pessimistic answer propagates. xs.sort_by_key fn { a } cannot pause by any reading of it, and the compiler had no way to know:

error[NK2202]: `im_lock` is `sync`, and `ordne` can pause
   9 |     return ordne(xs)
     = `ordne` carries no `sync` in this program's contracts

ordne sorts a list by a key. That is a correct program being rejected, which is the one thing the compiler is not allowed to do, and it was not a bug in either analysis: both were reading a contract that said what it had to say.

The effect is worse than one message, because of what sync gates. access, access_all, par_iter and a scope’s parallel tasks all demand a sync lambda (Part II, 12.2). So “no higher-order function is sync” reads, at the place it bites, as “no map, filter, fold or sort_by_key inside a lock” — over any lambda at all.


2. Decisions

D1 — The ledger’s type language gains fn(…).

Ty::Fn { params }, written fn(&Stats), fn(), fn(&str, i64). It says what a lambda is handed; what it hands back is deliberately absent, because nothing needs it and a spelling is easier to add than to change.

Only a ledger writes one. Nikaia’s grammar has no syntax for a function type — fn f(g: fn(i32)) is a parse error today — so no .nika source can declare a parameter of this shape. That is worth stating because it closes a hole rather than leaving one: a function that took a lambda and passed it on would need its own effects to follow its parameter, which is real polymorphism and is not what this ADR does. The grammar makes the case unreachable, and the day it does not, this paragraph is where the gap is recorded.

D2 — A lambda’s parameters get their types from the callee’s signature.

The checker walked a call’s arguments before resolving the callee, so a lambda was walked with nothing known about it. It now resolves first and walks after, handing each argument the type its parameter expects.

The implicit form is the one that needed this and the one that could not have it: fn { a.add(t) } records no parameters at all in the AST, because Part I 5.3 settles how many arguments a lambda takes from which of a, b, c its body mentions, and that is decided when it is emitted. So the names are bound here, in order, to whatever the signature says the lambda is handed.

D3 — sync = "from(f)" says the lambda decides.

A fourth state beside true, "inferred" and absent, naming the parameter that decides — the same shape returns = "borrows(a | b)" already uses for “depends on a parameter”, which is why it needed no new grammar in the file.

A caller reads it as “this call adds no pausing of its own.” That is sound for exactly one reason, and the reason is worth writing down because it is not obvious: the lambda runs during the call, so its body is part of the function that writes it, and that function’s own analysis has already counted its calls. from adds nothing because there is nothing left to add.

Which bounds the columns that may say it, and the bound is not D4’s. Beside immediacy the reason needs a second premise: the analysis reading the contract must ask about a whole function, because only then is “already counted” counted where the contract is being read. throws meets it — the error set is grown by the same walk, so a lambda’s failures are already in the enclosing function’s set, and a throws = "from(f)" would have nothing to add and could only weaken the "?" a method call already earns. ADR-033’s touches does not: its consumer asks what a statement reaches, nothing reads a lambda for an effect, and “adds nothing” there would buy an overlap on an incomplete touch set — two printlns free to interleave, which is the polarity ADR-010 D1 forbids and D4’s immediacy does not rescue. So this is one rule and not three: throws needs no key, and touches would need the opposite instruction — add what the lambda reaches, and everything where it cannot be read — under a word that does not look like this one. The programs that settle each are in docs/history/from-for-throws-and-touches.md.

D4 — from is for an immediate lambda, and std is held to it.

The corollary of D3’s reason is its limit. A parameter the callee stores or spawns — Part I 5.4’s @detached — breaks it: the lambda’s calls belong to nobody the caller is counting, so a pausing body would slip into a lock with the ledger saying it could not.

The ledger cannot spell @detached, so the rule is enforced rather than assumed: a_detached_lambda_may_not_use_from walks every from entry in std.contracts and requires that it name a parameter which is a fn(…). std has no detached parameter today; the day it gains one, from needs a companion and that test is where it is noticed.


3. Measured — and the honest answer is “nothing yet”

ADR-027 and ADR-028 were measured by stripping every sync declaration from the examples and counting what came back. On that measure this ADR changes nothing: 14 of 18, before and after. Counting how many functions in the whole corpus are sync gives the same answer: 20 of 43, unchanged.

That is not a disappointing result to be explained away. It is the result: no program in this repository is unblocked by it. The four functions still open (Summary::{record, merge}, Report::{record, merge}) are blocked by something else, named in §4, and every other higher-order call in the corpus sits in a function that does I/O anyway.

What the change does is remove a false rejection, and the smallest program that shows it is the measurement:

fn ordne(xs: Vec[i64]) -> i64 { xs.sort_by_key fn { a } return 1 }
fn im_lock(xs: Vec[i64]) -> i64 sync { return ordne(xs) }

Before: NK2202, ordne can pause. After: it compiles, and ordne is sync = "inferred". That program is now a test.

So the case for this ADR is not a number on the current corpus. It is that the corpus is eleven mostly parser-driven programs and the rule it removes — no iterator method inside a lock — applies to a category rather than to a program. A reader who wants a measured crossover before believing a change is right to want one, and should know that this one does not have it yet.


4. What is still in the way

The four remaining functions are all this:

self.stations.entry(m.name).and_modify fn { a.add(m.temp) }

D2 types a from and_modify’s signature, and and_modify’s signature says fn(?). It cannot say fn(&Stats), because Stats comes from the type argument of the map — HashMap[&str, Stats] — and the signature language has no type variables. ADR-024 settled that a generic parameter is recorded as ? for a Nikaia function whose generics are erased; a hand-written library contract that wants to say “the V of the receiver” is a different question, and this ADR does not answer it.

So the next question is a specific one: may a std.contracts signature refer to its receiver’s type arguments? It is worth about four functions here and rather more in any program that uses a map of structs. It is also the first thing in this file’s history that would add inference to the ledger’s type language rather than vocabulary, which is why it gets its own decision.


5. Considered and rejected

Mark the higher-order methods sync = true and move on. 18 of 18 on the measure, and a promise about code the ledger has never seen. ADR-027 §2 exists to prevent exactly this.

Work out the effect at each call site instead of recording it. The ledger would say nothing and the inference would look at the lambda. It is the same answer by a longer route for a program’s own code, and no answer at all across a package boundary — which is what a ledger is for.

Give the function type a result as well (fn(&Stats) -> i64). Nothing needs it: the sync question is about what a lambda does, not what it returns, and map’s own result is ? anyway. Adding an unused half to a spelling makes it harder to change later.

Infer from rather than writing it. For std it cannot be inferred — the bodies are Rust. For Nikaia code it could, the day a Nikaia function can take a lambda at all, and that day the question is whether the parameter is immediate or detached (D4) rather than whether it is a lambda.


6. Consequences

This site is open source. Improve this page.