Skip to content

Commit 3260062

Browse files
committed
perf(d5): solve MOS by SCC-condensed summary fixpoint, drop the depth cap
The MOS solver resolved each method's summary by a memo-less recursive descent over the call graph, capped at depth 3. The cap existed only to dodge the exponential that re-descent hits on diamond-shaped call graphs (A forwards a param down two paths that reconverge on a shared callee), and it bought that safety with two costs: false `unknown`s on forward chains deeper than the cap, and a less-precise `may` on recursion (the descent broke each cycle at a spurious `no`, which a later join turned a provable `must` into `may`). Replace it with the textbook interprocedural summary fixpoint: * condense the call graph into strongly-connected components (iterative Tarjan, emitted bottom-up = reverse-topological); * process components bottom-up — a callee's summary is final before any caller reads it, so each method is resolved once and reused (the context-insensitive summary IS the memoization); * within a recursive component, seed each edge at the lattice bottom (⊥, "no evidence yet") and iterate the four-point transfer lattice to its least fixpoint, mapping any residual ⊥ to `no` at finalization; * resolve forward-returns by a memoized, cycle-safe chase. This removes the exponential without the cap, and is strictly more precise on recursion: a method that disposes on its base case and recurses otherwise now resolves to `must` (every terminating path disposes), and mutual recursion grounded by a dispose deep in the cycle carries that `must` across the whole SCC. Mutual recursion that never disposes still settles at `no` and still terminates. The only residual `unknown` is an extern (unsummarized) forward boundary; `solve_with_log` now surfaces exactly those (sorted, deterministic) in place of the old cap log — no silent truncation. Public API unchanged in shape: solve() / solve_with_log() keep their return types; the now-meaningless `cap` keyword is dropped (the sole caller, the ownir bridge, never passed it). Tests that asserted the old depth-broken behaviour (chain past cap -> unknown; self-recursion -> may) are updated to the precise fixpoint results, plus new diamond, deep-chain, extern-log, and grounded-mutual-recursion cases. Full suite green (ownership 40/40, ownir bridge 194/194, mypy --strict, ruff). No existing bridge/corpus diagnostic shifts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019846YSZ35c7CdkWQ1qX5gm
1 parent 4516e29 commit 3260062

2 files changed

Lines changed: 258 additions & 127 deletions

File tree

‎ownlang/ownership.py‎

Lines changed: 225 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -5,37 +5,41 @@
55
This module is pure **data + algorithm**: it computes a Method Ownership Summary
66
(MOS) for each first-party method from per-method *local evidence* (a "skeleton"
77
of what a body directly does with each disposable parameter and what it returns),
8-
resolving the parts that depend on other methods' summaries via a **depth-capped
9-
bottom-up resolution** over the call graph.
10-
11-
D5.0 deliberately does **nothing** to findings — no extractor wiring, no
12-
behaviour change. It defines the summary vocabulary and the solver so the lattice
13-
can be unit-tested in pure Python (the synthetic-flow discipline, lifted to the
14-
effect level). Lowering a summary into the core's `consume`/`borrow`/`acquire`
15-
vocabulary is D5.1+; the wrapper/alias (`aliasOf`) obligation-identity model is
16-
D5.4 (see the note's §11).
17-
18-
The skeleton is the extractor-facing input; here it is hand-authored in tests.
8+
resolving the parts that depend on other methods' summaries by a **summary
9+
fixpoint over the strongly-connected-component condensation** of the call graph.
10+
11+
The summary is *context-insensitive*: each method has exactly one MOS, independent
12+
of who calls it or how deep the call sits. That is what makes the computation both
13+
linear (each method resolved once and reused — no per-call re-descent) and exact
14+
on recursion: cycles are solved to their least fixpoint on the four-point lattice
15+
rather than truncated. There is **no depth cap** — the SCC condensation bounds the
16+
work without one (an earlier slice capped the recursive descent at depth 3 purely
17+
to dodge the exponential a memo-less re-descent would otherwise hit on diamond call
18+
graphs; the condensation removes both the blowup and the cap-induced false
19+
`unknown`s on deep chains).
20+
21+
Lowering a summary into the core's `consume`/`borrow`/`acquire` vocabulary lives in
22+
the bridge (`ownir.py`, D5.1+: `must`→consume, `no`→borrow, `may`/`unknown`→plain);
23+
the wrapper/alias (`aliasOf`) obligation-identity model is D5.4. The skeleton is the
24+
extractor-facing input; here it is hand-authored in tests.
1925
2026
Precision note: a parameter is reported `must`-transfer only when ownership leaves
21-
the caller on **every** normal-return path the skeleton lists. A recursive forward
22-
edge contributes *no* transfer evidence (the cycle is broken at `no`), and a chain
23-
deeper than the cap, or a forward to an unsummarized (extern) callee, degrades to
24-
`unknown` — never to a guessed `must`. That keeps the project's precision-first
25-
stance: we only ever *claim* transfer when we can prove it.
27+
the caller on **every** normal-return path. The fixpoint seeds each recursive edge
28+
at the lattice bottom (⊥, "no evidence yet") rather than at a spurious `no`, so a
29+
method that disposes on its base case and recurses otherwise resolves to `must`
30+
(every *terminating* path disposes), and mutual recursion that never disposes
31+
settles at `no`. A forward to an unsummarized (extern) callee is the only residual
32+
`unknown`, and `solve_with_log` surfaces every such boundary — never a guessed
33+
`must`. That keeps the project's precision-first stance: we only ever *claim*
34+
transfer when we can prove it.
2635
"""
2736

2837
from __future__ import annotations
2938

30-
from collections.abc import Iterable
39+
from collections.abc import Iterable, Iterator
3140
from dataclasses import dataclass, field
3241
from enum import StrEnum
3342

34-
# Default interprocedural forward-chain depth, matching CA2000's
35-
# `max_interprocedural_method_call_chain` default. Beyond it, a forward degrades
36-
# to `unknown` (and is logged) rather than spending unbounded work.
37-
DEFAULT_CAP = 3
38-
3943

4044
class Transfer(StrEnum):
4145
"""Did ownership of a disposable parameter leave the caller?
@@ -147,90 +151,102 @@ def to_dict(self) -> dict[str, object]:
147151
}
148152

149153

150-
def _resolve_param(key: str, i: int, depth: int, stack: frozenset[str],
151-
cap: int, sk: dict[str, MethodSkeleton],
152-
capped: list[str]) -> Transfer:
153-
skel = sk.get(key)
154-
if skel is None:
155-
return Transfer.UNKNOWN
156-
# `i` is the callee's *logical* parameter index (`ParamSkeleton.index`), which
157-
# need not equal the tuple offset — a skeleton may list only the disposable /
158-
# interesting params, so a wrapper `Create(cmd, reader)` can carry just index 1.
159-
# Resolve by `.index`, never by position.
160-
p = next((q for q in skel.params if q.index == i), None)
161-
if p is None:
162-
return Transfer.UNKNOWN
163-
if not p.disposable:
164-
return Transfer.NO
165-
if not p.paths:
166-
return Transfer.NO # nothing happens to it -> kept (borrowed)
167-
verdict: Transfer | None = None
168-
for a in p.paths:
169-
verdict = _path_verdict(a, depth, stack, cap, sk, capped) if verdict is None \
170-
else join(verdict, _path_verdict(a, depth, stack, cap, sk, capped))
171-
return verdict if verdict is not None else Transfer.NO
172-
173-
174-
def _path_verdict(a: PathAction, depth: int, stack: frozenset[str], cap: int,
175-
sk: dict[str, MethodSkeleton], capped: list[str]) -> Transfer:
176-
if a.kind in ("dispose", "adopt", "return"):
177-
return Transfer.MUST # ownership left the caller on this path
178-
if a.kind == "borrow":
179-
return Transfer.NO
180-
if a.kind == "forward":
181-
if a.callee not in sk:
182-
return Transfer.UNKNOWN # extern / unsummarized callee
183-
if depth + 1 >= cap:
184-
capped.append(f"{a.callee}#{a.arg} (depth {depth + 1} >= cap {cap})")
185-
return Transfer.UNKNOWN
186-
if a.callee in stack:
187-
return Transfer.NO # recursion: this edge carries no transfer evidence
188-
return _resolve_param(a.callee, a.arg, depth + 1, stack | {a.callee},
189-
cap, sk, capped)
190-
return Transfer.UNKNOWN
191-
192-
193-
def _resolve_return(key: str, depth: int, stack: frozenset[str], cap: int,
194-
sk: dict[str, MethodSkeleton], capped: list[str]) -> str:
195-
skel = sk.get(key)
196-
if skel is None:
197-
return "unknown"
198-
r = skel.ret
199-
if r.kind == "fresh":
200-
return "fresh"
201-
if r.kind == "aliasOf":
202-
return f"aliasOf:{r.arg}"
203-
if r.kind == "aliased":
204-
return "aliased"
205-
if r.kind == "forward":
206-
if r.callee not in sk:
207-
return "unknown"
208-
if depth + 1 >= cap:
209-
capped.append(f"return {r.callee} (depth {depth + 1} >= cap {cap})")
210-
return "unknown"
211-
if r.callee in stack:
212-
return "unknown"
213-
inner = _resolve_return(r.callee, depth + 1, stack | {r.callee}, cap, sk, capped)
214-
if inner.startswith("aliasOf:"):
215-
# `inner` aliases one of the *callee's* params; remapping it to one of
216-
# OUR args needs the call's argument mapping, which the skeleton does not
217-
# carry yet (it arrives with the obligation-identity model in D5.4). Until
218-
# then, never propagate a wrong index — degrade to unknown (precision-safe:
219-
# nothing is acquired/aliased at lowering).
220-
return "unknown"
221-
return inner # fresh / aliased / none / unknown propagate as-is
222-
if r.kind == "none":
223-
return "none" # explicit no-owned-return
224-
return "unknown" # an unrecognised kind fails closed, never silently "none"
225-
226-
227-
def solve_with_log(skeletons: Iterable[MethodSkeleton], *,
228-
cap: int = DEFAULT_CAP) -> tuple[dict[str, MethodSummary], list[str]]:
229-
"""Resolve every method's MOS from its skeleton plus its callees' skeletons.
230-
231-
Returns (summaries-by-key, capped-log). The log names every forward that hit
232-
the depth cap, so a run can surface what it gave up on (no silent
233-
truncation)."""
154+
# A parameter is resolved by `.index` (its logical `ParamSkeleton.index`), never by
155+
# tuple offset: a skeleton may list only the disposable/interesting params, so a
156+
# wrapper `Create(cmd, reader)` can carry just index 1.
157+
ParamKey = tuple[str, int] # (method key, logical parameter index)
158+
159+
# `None` is the lattice bottom ⊥ ("no evidence yet") used only as the fixpoint seed
160+
# on a recursive edge. It never escapes a solved summary — it is mapped to `no` at
161+
# finalization (a parameter nothing demonstrably consumes is kept = borrowed).
162+
def _join_opt(a: Transfer | None, b: Transfer | None) -> Transfer | None:
163+
"""`join` lifted to the bottom-extended lattice: ⊥ (None) is the identity."""
164+
if a is None:
165+
return b
166+
if b is None:
167+
return a
168+
return join(a, b)
169+
170+
171+
def _sccs(adj: dict[str, set[str]]) -> list[list[str]]:
172+
"""Tarjan's SCCs, returned bottom-up (every component precedes its callers).
173+
174+
Iterative (no Python recursion limit on deep call graphs). Tarjan emits each
175+
component only after all components it depends on, so the natural output order
176+
is exactly the reverse-topological order the summary fixpoint wants: a callee's
177+
summary is final before any caller reads it; only same-SCC callees are still
178+
mid-iteration. Adjacency is sorted so the result (and the cap-free log) is
179+
deterministic regardless of input ordering."""
180+
index: dict[str, int] = {}
181+
low: dict[str, int] = {}
182+
on_stack: set[str] = set()
183+
stack: list[str] = []
184+
out: list[list[str]] = []
185+
counter = 0
186+
for root in adj:
187+
if root in index:
188+
continue
189+
index[root] = low[root] = counter
190+
counter += 1
191+
stack.append(root)
192+
on_stack.add(root)
193+
work: list[tuple[str, Iterator[str]]] = [(root, iter(sorted(adj[root])))]
194+
while work:
195+
node, it = work[-1]
196+
descended = False
197+
for w in it: # the iterator is stored in `work`, so it resumes here
198+
if w not in index:
199+
index[w] = low[w] = counter
200+
counter += 1
201+
stack.append(w)
202+
on_stack.add(w)
203+
work.append((w, iter(sorted(adj[w]))))
204+
descended = True
205+
break
206+
if w in on_stack:
207+
low[node] = min(low[node], index[w])
208+
if descended:
209+
continue
210+
if low[node] == index[node]: # component root
211+
comp: list[str] = []
212+
while True:
213+
x = stack.pop()
214+
on_stack.discard(x)
215+
comp.append(x)
216+
if x == node:
217+
break
218+
out.append(comp)
219+
work.pop()
220+
if work: # propagate this node's low-link up to its DFS parent
221+
parent = work[-1][0]
222+
low[parent] = min(low[parent], low[node])
223+
return out
224+
225+
226+
def _call_graph(sk: dict[str, MethodSkeleton]) -> dict[str, set[str]]:
227+
"""Dependency edges M -> callees whose summaries M's summary reads: every
228+
first-party callee a param forwards to, plus a forwarded return target."""
229+
adj: dict[str, set[str]] = {k: set() for k in sk}
230+
for k, skel in sk.items():
231+
deps = adj[k]
232+
for p in skel.params:
233+
for a in p.paths:
234+
if a.kind == "forward" and a.callee in sk:
235+
deps.add(a.callee)
236+
if skel.ret.kind == "forward" and skel.ret.callee in sk:
237+
deps.add(skel.ret.callee)
238+
return adj
239+
240+
241+
def solve_with_log(skeletons: Iterable[MethodSkeleton]) -> tuple[
242+
dict[str, MethodSummary], list[str]]:
243+
"""Resolve every method's MOS by a summary fixpoint over the call graph's SCC
244+
condensation.
245+
246+
Returns (summaries-by-key, unresolved-log). The log names every forward that
247+
crosses an extern (unsummarized) boundary — the only place a transfer or return
248+
degrades to `unknown`. There is no depth cap and so no silent truncation: the
249+
condensation makes the work linear, and recursion is solved, not cut off."""
234250
sk: dict[str, MethodSkeleton] = {}
235251
for s in skeletons:
236252
if s.key in sk:
@@ -239,24 +255,118 @@ def solve_with_log(skeletons: Iterable[MethodSkeleton], *,
239255
# depend on input order — fail fast instead.
240256
raise ValueError(f"duplicate MethodSkeleton key: {s.key}")
241257
sk[s.key] = s
242-
capped: list[str] = []
258+
259+
unresolved: set[str] = set()
260+
param_val: dict[ParamKey, Transfer] = {} # finalized disposable-param transfers
261+
262+
def lookup(callee: str, arg: int, cur: dict[ParamKey, Transfer | None]) -> Transfer | None:
263+
"""The current transfer of callee param `arg` (resolve by `.index`): a final
264+
value for a callee in a lower SCC, the live iterate for one in the current
265+
SCC (possibly ⊥), `no` for a non-disposable, `unknown` past an extern edge."""
266+
skel = sk.get(callee)
267+
if skel is None:
268+
unresolved.add(f"{callee}#{arg} (extern, no summary)")
269+
return Transfer.UNKNOWN
270+
p = next((q for q in skel.params if q.index == arg), None)
271+
if p is None:
272+
return Transfer.UNKNOWN # callee has no such logical param
273+
if not p.disposable:
274+
return Transfer.NO
275+
keyp = (callee, arg)
276+
if keyp in param_val:
277+
return param_val[keyp]
278+
if keyp in cur:
279+
return cur[keyp] # same-SCC member, mid-fixpoint (may be ⊥)
280+
return Transfer.UNKNOWN # unreachable under a correct topo order; fail closed
281+
282+
def contrib(a: PathAction, cur: dict[ParamKey, Transfer | None]) -> Transfer | None:
283+
if a.kind in ("dispose", "adopt", "return"):
284+
return Transfer.MUST # ownership left the caller on this path
285+
if a.kind == "borrow":
286+
return Transfer.NO
287+
if a.kind == "forward":
288+
return lookup(a.callee, a.arg, cur)
289+
return Transfer.UNKNOWN
290+
291+
def transfer_of(key: str, index: int,
292+
cur: dict[ParamKey, Transfer | None]) -> Transfer | None:
293+
p = next(q for q in sk[key].params if q.index == index)
294+
if not p.paths:
295+
return Transfer.NO # nothing happens to it -> kept (borrowed)
296+
acc: Transfer | None = None
297+
for a in p.paths:
298+
acc = _join_opt(acc, contrib(a, cur))
299+
return acc
300+
301+
# --- param transfers: bottom-up, per-SCC least fixpoint on the lattice -------
302+
for comp in _sccs(_call_graph(sk)):
303+
members: list[ParamKey] = [
304+
(k, p.index) for k in comp for p in sk[k].params if p.disposable
305+
]
306+
if not members:
307+
continue
308+
cur: dict[ParamKey, Transfer | None] = dict.fromkeys(members) # seed ⊥ (None)
309+
changed = True
310+
while changed: # monotone ascent on a height-3 lattice: converges fast
311+
changed = False
312+
for m in members:
313+
new = transfer_of(m[0], m[1], cur)
314+
if new != cur[m]:
315+
cur[m] = new
316+
changed = True
317+
for m in members: # ⊥ (no evidence) finalizes as `no` (kept/borrowed)
318+
v = cur[m]
319+
param_val[m] = v if v is not None else Transfer.NO
320+
321+
# --- returns: a memoized, cycle-safe chase along forward-return edges --------
322+
ret_val: dict[str, str] = {}
323+
324+
def resolve_return(key: str, visiting: frozenset[str]) -> str:
325+
if key in ret_val:
326+
return ret_val[key] # context-insensitive: a return has one forward target
327+
r = sk[key].ret
328+
if r.kind == "fresh":
329+
v = "fresh"
330+
elif r.kind == "aliasOf":
331+
v = f"aliasOf:{r.arg}"
332+
elif r.kind == "aliased":
333+
v = "aliased"
334+
elif r.kind == "none":
335+
v = "none" # explicit no-owned-return
336+
elif r.kind == "forward":
337+
if r.callee not in sk:
338+
unresolved.add(f"return {r.callee} (extern, no summary)")
339+
v = "unknown"
340+
elif r.callee in visiting:
341+
v = "unknown" # return-forward cycle: no ground to stand on
342+
else:
343+
inner = resolve_return(r.callee, visiting | {key})
344+
# `inner` aliasOf:<i> aliases one of the *callee's* params; remapping it
345+
# to OUR args needs the call's argument mapping, which the skeleton does
346+
# not carry yet (the obligation-identity model, D5.4). Never propagate a
347+
# wrong index — degrade to unknown (precision-safe: nothing aliased at
348+
# lowering). fresh / aliased / none / unknown propagate as-is.
349+
v = "unknown" if inner.startswith("aliasOf:") else inner
350+
else:
351+
v = "unknown" # an unrecognised kind fails closed, never silently "none"
352+
ret_val[key] = v
353+
return v
354+
243355
out: dict[str, MethodSummary] = {}
244356
for key, skel in sk.items():
245357
params = tuple(
246358
ParamSummary(
247359
p.index, p.name, p.disposable,
248-
_resolve_param(key, p.index, 0, frozenset({key}), cap, sk, capped)
249-
if p.disposable else Transfer.NO,
360+
param_val.get((key, p.index), Transfer.NO) if p.disposable else Transfer.NO,
250361
p.escapes,
251362
)
252363
for p in skel.params
253364
)
254-
returns = _resolve_return(key, 0, frozenset({key}), cap, sk, capped)
365+
returns = resolve_return(key, frozenset())
255366
out[key] = MethodSummary(key, params, returns, skel.file, skel.line)
256-
return out, capped
367+
return out, sorted(unresolved)
257368

258369

259-
def solve(skeletons: Iterable[MethodSkeleton], *,
260-
cap: int = DEFAULT_CAP) -> dict[str, MethodSummary]:
261-
"""Convenience wrapper around :func:`solve_with_log` dropping the cap log."""
262-
return solve_with_log(skeletons, cap=cap)[0]
370+
def solve(skeletons: Iterable[MethodSkeleton]) -> dict[str, MethodSummary]:
371+
"""Convenience wrapper around :func:`solve_with_log` dropping the unresolved log."""
372+
return solve_with_log(skeletons)[0]

0 commit comments

Comments
 (0)