Skip to content

feat(borrow): CORE-01 Slice D (#177 pt3) — reject @linear capture by closure at borrow check#397

Open
hyperpolymath wants to merge 1 commit into
mainfrom
core-01/slice-d-captured-linears
Open

feat(borrow): CORE-01 Slice D (#177 pt3) — reject @linear capture by closure at borrow check#397
hyperpolymath wants to merge 1 commit into
mainfrom
core-01/slice-d-captured-linears

Conversation

@hyperpolymath
Copy link
Copy Markdown
Owner

Summary

Tighter integration with the quantity checker for captured linears: the borrow checker now refuses to let a closure capture a @linear (QOne) binding. Pre-Slice-D, this case fell only to the quantity checker (which scales lambda captures by QOmega and emits `LinearVariableUsedMultiple`); now the same constraint fires earlier in the pipeline (Typecheck → Borrow → Quantity) with a diagnostic that points at the lambda span — the actual capture site — rather than the downstream "used multiple times" message.

Mechanism

  • New `state.linear_bindings` tracks sym-ids of @linear bindings declared in the current function: explicit `@linear` annotations on `let`-statements and `let`-expressions, `@linear` function params, and `let x: Cmd[T] = …` (linear-by-construction per ADR-002).
  • At `ExprLambda`, the free-var walk now also looks up each captured name's symbol and rejects with `LinearCapturedByClosure(name, lambda_span)` if the sym-id is in `linear_bindings`.
  • The Shared-borrow creation for non-linear captures is unchanged.

Refs

Test plan

  • `dune runtest --force` — 327 → 330 tests, 0 failures
  • 3 new e2e fixtures + handlers:
    • `slice_d_captured_linear_let_rejected.affine` — `@linear let x; fn() => x + 1` → `LinearCapturedByClosure("x", _)`
    • `slice_d_captured_linear_param_rejected.affine` — `fn foo(@linear y: Int) { let f = fn() => y + 1; … }` → `LinearCapturedByClosure("y", _)`
    • `slice_d_captured_nonlinear_ok.affine` — anti-regression: non-linear capture must still pass

🤖 Generated with Claude Code

…closure at borrow check

Tighter integration with the quantity checker for captured linears:
the borrow checker now refuses to let a closure capture a @linear
(QOne) binding.  Pre-Slice-D, this case fell only to the quantity
checker (which scales lambda captures by QOmega and emits
LinearVariableUsedMultiple); now the same constraint fires earlier
in the pipeline (Typecheck → Borrow → Quantity) with a more
specific diagnostic that points at the *lambda span* — the actual
capture site — rather than the downstream "used multiple times"
message.

Mechanism:
  - New [state.linear_bindings] tracks sym-ids of @linear bindings
    declared in the current function: explicit @linear annotations on
    `let`-statements and `let`-expressions, @linear function params,
    and `let x: Cmd[T] = …` (linear-by-construction per ADR-002).
  - At ExprLambda, collect-free walks the body; for each captured
    free-name we look up its symbol and reject with
    [LinearCapturedByClosure name * lambda_span] if the sym-id is in
    [linear_bindings].
  - The Shared-borrow creation for non-linear captures is unchanged.

Tests (+3, all green):
  - slice_d_captured_linear_let_rejected: @linear let captured →
    LinearCapturedByClosure("x", _)
  - slice_d_captured_linear_param_rejected: @linear param captured →
    LinearCapturedByClosure("y", _)
  - slice_d_captured_nonlinear_ok: anti-regression, non-linear
    capture must still pass

Gate: 327 → 330 tests, 0 failures under `dune runtest --force`.

Refs #177, CORE-01 pt3 (Slice D).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hyperpolymath
Copy link
Copy Markdown
Owner Author

Parallel-work review (Slice D audit)

Slice D is the one deferred-items entry I didn't duplicate — I had no parallel PR open here. This is a fresh audit of the diff against the soundness story in the comment.

Two real bugs, two hygiene items, three suggested fixture adds.


1. Cmd _-typed parameters slip through the linear-capture check

Two paths populate state.linear_bindings:

Let-statements / let-expressions use the shared helper:

if let_is_linear lb.el_quantity lb.el_ty then
  state.linear_bindings <- sym.Symbol.sym_id :: state.linear_bindings

let_is_linear correctly falls back to borrow_quantity_of_ty ty_opt when the explicit quantity is None, so let x: Cmd[Int] = … (no explicit @linear) is tracked.

Parameters in check_function:

if p.p_quantity = Some QOne then
  state.linear_bindings <-
    sym.sym_id :: state.linear_bindings

This checks ONLY the explicit p_quantity = Some QOne annotation. A param c: Cmd[Int] (Cmd-typed, no explicit @linear) is QOne by Cmd _ inference but never reaches linear_bindings — the same call to borrow_quantity_of_ty that the let path makes is missing here.

Result: a closure capturing a Cmd-typed param silently slips through. The companion quantity-checker pass would still flag downstream "used multiple times" but the early borrow-side error that the PR comment promises does not fire.

Suggested fix — make the param path symmetric:

let p_is_linear =
  match p.p_quantity with
  | Some q -> q = QOne
  | None -> borrow_quantity_of_ty (Some p.p_ty) = QOne
in
if p_is_linear then
  state.linear_bindings <- sym.sym_id :: state.linear_bindings

(p.p_ty is not an option, so wrap in Some.)

2. The helper borrow_quantity_of_ty is unexercised by fixtures

Three fixtures, all using explicit @linear — none uses Cmd[T] to drive the inference branch of let_is_linear. So the helper's Some (TyApp ({ name = "Cmd"; _ }, _)) -> QOne arm is unreached by CI. If somebody later renames "Cmd" to "Linear" (or whatever the canonical name is) the linear inference silently breaks and no test catches it.

A fourth fixture along the lines of:

fn capture_cmd_let() -> Int {
  let c: Cmd[Int] = make_cmd();
  let f = fn() => use_cmd(c);
  f()
}

— expected LinearCapturedByClosure — would pin this path.


3. linear_bindings is not snapshot/restored at block exit

check_block snapshots borrows, block_local_syms, ref_bindings at entry and restores them at exit. linear_bindings is not in the snapshot:

let borrows_at_entry = state.borrows in
let block_locals_at_entry = state.block_local_syms in
let ref_bindings_at_entry = state.ref_bindings in
(* no linear_bindings_at_entry *)
...
state.borrows <- borrows_at_entry;
state.block_local_syms <- block_locals_at_entry;
state.ref_bindings <- ref_bindings_at_entry;
(* no restore of linear_bindings *)

So a @linear let x declared inside a nested block stays in linear_bindings after the block ends. Benign in practice because sym_ids don't recycle to in-scope symbols, but inconsistent with the rest of the state-field discipline and a foot-gun if a future change ever does mutate the linear_bindings list during block exit cleanup.

4. merge_arm_results does not unify linear_bindings

Same character as #3 — when match/try/handle arms merge, borrows/moved are unioned but linear_bindings entries created in arm $i$ aren't reconciled. Same benign-but-inconsistent footprint.


Coverage gaps in fixtures

Three adds that round out the matrix:

  1. Cmd _ let captured (positive rejection via inference) — exercises borrow_quantity_of_ty's Cmd arm.
  2. Cmd _ param captured (currently slips through per ci: Bump actions/setup-node from 4.0.2 to 6.3.0 #1) — would fail against the unfixed PR, pass against the patched version. The strongest regression for the param-path fix.
  3. Shadowing: @linear let x = …; let x = 100; let f = fn() => x + 1; f() — the closure captures the inner non-linear x. Expected: Ok. Pins that the rejection scopes to the sym-id in linear_bindings, not the name.

What's architecturally fine

  • The borrow_quantity_of_ty / let_is_linear helpers are sensibly duplicated in borrow.ml with the comment pointing at the canonical helper in quantity.ml.
  • The lambda capture rejection fires before the existing shared-borrow loop — early exit and good span attribution.
  • LinearCapturedByClosure (name, lam_span) carries enough context for a clean diagnostic, and the formatter is good.
  • The deferred-items comment is correctly updated to remove the Slice D entry.

Nothing blocking — the only must-fix is #1 (param-path symmetry). Items #3 and #4 are hygiene; can land separately.

hyperpolymath added a commit that referenced this pull request May 27, 2026
…ans + machines) (#401)

## Summary

Documents the entire round of work across **six open PRs** that together
cover the four entries in the \`lib/borrow.ml\` deferred-items comment
at lines 1483-1505. Two artefacts, one for each audience.

### For humans — \`docs/history/SESSION-HANDOFF-2026-05-27.adoc\`

\`.adoc\` per the repo's DOC-FORMAT rule. Sections:

- What this session actually did
- **Parallel-implementation map** — which of my PRs paired with which
existing PR
- PR state table (draft? auto-merge? mergeability?)
- **Audit findings** — the load-bearing deltas: self-assign hole +
return-escape gap (#395), sub-place soundness divergence (#396),
Cmd-typed-param tracking gap (#397)
- **Safe-to-close conditions per PR** — the matrix the next agent needs
- Cleanup performed
- Guidance for the next agent

### For machines —
\`.machine_readable/sessions/2026-05-27-borrow-deferred-items.a2ml\`

New \`sessions/\` subdirectory (the existing \`.machine_readable/6a2/\`
is the canonical state-snapshot dir; sessions are per-event records).
Schema declared as \`a2ml/session-record/v1\`. Structured records:

- One \`[[deferred-item]]\` per entry in the comment block
- One \`[[open-pr]]\` per the six PRs
- One \`[[close-condition]]\` per PR with fallback paths
- \`[cleanup]\`, \`[lessons]\` blocks

### Cleanup landed alongside

- \`.git/gc.log\` removed after \`git prune\` (the persistent
\"unreachable loose objects\" warning is gone).
- Two stale parallel-Claude untracked dirs (\`affinescript-vite/\`,
\`editors/vscode/node_modules/\`) **not touched** — they're not this
session's work.

## What's NOT in this PR

- No \`lib/\` changes. No \`test/\` changes.
- No closes/state-changes on the six open PRs
(#395/#396/#397/#398/#399/#400). Per maintainer direction \"keep both PR
sets open\", parallel implementations stay parallel.
- No edits to \`STATE.a2ml\` or other \`6a2/\` files — those are flagged
STALE and updating them is a separate disciplined task.

## Safe-to-close ledger (also in the docs)

| PR | Close condition | Fallback |
|---|---|---|
| **#395** | auto-merge fires once CI clears | n/a |
| **#396** | auto-merge fires once CI clears | if merges without
sub-place fix, **#399** stays open as follow-up |
| **#397** | auto-merge fires once CI clears | audit posted a must-fix;
owner can incorporate or file follow-up |
| **#398** | owner ratifies ADR-022 via the posted one-liner |
do-not-close — only deferred-item that needs an architectural change |
| **#399** | **#396** merges with sub-place fix | rebase as sub-place
soundness correction |
| **#400** | **#395** merges with self-assign-guard + return-escape test
| rebase as follow-up adding the two missing pieces |
| **this PR** | safe to close once merged | n/a — pure docs |

## Test plan

- [ ] CI green (this PR only touches \`docs/\` and
\`.machine_readable/\` — no test-relevant code).
- [ ] \`asciidoctor docs/history/SESSION-HANDOFF-2026-05-27.adoc\`
renders without errors (if you have asciidoctor installed; CI doesn't
enforce).
- [ ] The \`docs/history/SESSION-HANDOFF-2026-05-27.adoc\`
cross-references in the body resolve to actual PR numbers / file paths
in the repo.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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