Skip to content

Commit b2f4131

Browse files
committed
resource-effects Stage 3: the inference suite and the E3 mixed-return rule
EXPLORATORY (research/resource-effects-v1; pre-registered in Own.NET-paperwork stage3-inference-prereg-v1.json). E3 / INF-R3prime in both engines behind OWEN_RE_MIXED_RETURN=1: a mixed return skeleton whose every returned local is acquired here or bound to one first-party callee's result is a forward to that callee (fresh iff the callee is fresh); verdict-identical off. corpus/re-inference/t-suite.cs carries the 14 pre-registered tests; the controls test pins the suite's verdicts under the body arm with and without E3. Measured (stage3-inference-v1.json): 13 of 14 met with Python == Rust in every arm; T12 (mutual recursion) resolves to unknown by the spec's fail-closed cycle rule INF-F5 (not repaired); the fact-level mutants M3/M5/M7 fail as required, M4 is inexpressible (aliasing holds by construction) and is retired with the reason recorded. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Am9eQwzNfbugH72eVKetC2
1 parent c36978f commit b2f4131

5 files changed

Lines changed: 170 additions & 3 deletions

File tree

‎corpus/re-inference/t-suite.cs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// resource-effects Stage 3 inference suite (pre-registered in Own.NET-paperwork
2+
// paper-eval/resource-effects/stage3-inference-prereg-v1.json, tests T01-T14). One class per test; the
3+
// expected summaries live in the prereg and the measured ones in stage3-inference-v1.json.
4+
using System;
5+
6+
sealed class R : IDisposable { public bool IsOpen => true; public void Touch() { } public void Dispose() { } }
7+
8+
sealed class Conn : IDisposable
9+
{
10+
public void Kill() { Dispose(); }
11+
public void Touch() { }
12+
public void Dispose() { }
13+
}
14+
15+
interface IFactory { R Make(); }
16+
interface IKill { void Kill(); }
17+
18+
static class T01 { public static R Make() { var r = new R(); return r; } }
19+
static class T02 { public static R Make2() { return T01.Make(); } public static R Make3() { return new R(); } }
20+
static class T03 { public static R Outer() => T02.Make2(); }
21+
static class T04 { static void Run() { var c = new Conn(); c.Kill(); } static void RunUse() { var c = new Conn(); c.Kill(); c.Touch(); } }
22+
static class T05 { public static void Drop(Conn c) { c.Kill(); } static void Run() { var c = new Conn(); Drop(c); } }
23+
static class T06 { public static void Finish(R r, bool b) { if (b) { r.Dispose(); } } }
24+
static class T07 { public static void Finish(R r, bool keep) { if (!keep) { r.Dispose(); } } }
25+
static class T08 { public static R Make7(bool b) { if (b) { return new R(); } return T01.Make(); } static void Drop(bool b) { var a = Make7(b); a.Touch(); } }
26+
static class T09 { public static void Finish(R r) { r.Dispose(); } public static void Fwd(R r) { Finish(r); } }
27+
static class T10 { public static void Use(R r) { r.Touch(); } }
28+
static class T11 { public static R Id(R x) => x; static void Run() { var r = new R(); var s = Id(r); s.Dispose(); } }
29+
static class T11B { public static R Id(R x) { return x; } static void Run() { var r = new R(); var s = Id(r); s.Dispose(); } static void RunBoth() { var r = new R(); var s = Id(r); s.Dispose(); r.Dispose(); } }
30+
static class T12 { public static R A(bool b) => b ? new R() : B(); public static R B() => A(false); static void Drop() { var a = A(true); a.Touch(); } }
31+
static class T13 { public static R Ext() => Unknown.Get(); static void Drop() { var a = Ext(); a.Touch(); } }
32+
static class T14
33+
{
34+
public static R Via(IFactory f) => f.Make();
35+
public static void ViaKill(IKill k) { k.Kill(); }
36+
static void Drop(IFactory f) { var a = Via(f); a.Touch(); }
37+
static void Run(IFactory f, IKill k) { var r = new R(); ViaKill(k); r.Touch(); }
38+
}

‎docs/notes/re-stage3-inference.md‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# resource-effects Stage 3 — first-party summary inference over the algebra (EXPLORATORY)
2+
3+
Research branch `research/resource-effects-v1`. Pre-registered in Own.NET-paperwork
4+
`paper-eval/resource-effects/stage3-inference-prereg-v1.json`; evidence in `stage3-inference-v1.json`.
5+
#304 frozen and unchanged; #382 untouched; nothing here is a production result.
6+
7+
## 1. E3 — the one engine rule (both engines, opt-in `OWEN_RE_MIXED_RETURN=1`, verdict-identical off)
8+
9+
INF-R3′: a MIXED return skeleton — every returned local acquired in this body on some paths and bound to
10+
ONE first-party callee's result on the others (`if (b) return new R(); return Make();`) — is a forward to
11+
that callee, fresh iff the callee is fresh at the fixpoint. Two different callees stay ambiguous (`none`);
12+
a returned parameter is never fresh. Python `_infer_return_skeleton` (+17 lines) and the Rust bridge
13+
`infer_return_skeleton` (+33) carry the same block; off the opt-in the R3/R4 separation is unchanged.
14+
15+
## 2. The 14-test suite (`corpus/re-inference/t-suite.cs`): 13 of 14 met, Python == Rust in every arm
16+
17+
| test | expected | measured (body + E3) |
18+
|---|---|---|
19+
| T01 direct factory | fresh | fresh |
20+
| T02 wrapper (`return Make()`, `return new R()`) | fresh / fresh | fresh / fresh |
21+
| T03 two-level (`=> Make2()`) | fresh | fresh |
22+
| T04 receiver release (`c.Kill()`) | Run clean, RunUse OWN002 | as expected |
23+
| T05 release wrapper (`Drop(c) { c.Kill(); }`) | c must | c must |
24+
| T06 conditional / T07 negated guard | r may | r may / r may |
25+
| T08 ambiguous branch (mixed) | fresh (none today) | none without E3, **fresh with E3**; the caller's leak appears |
26+
| T09 forwarded ownership | r must | r must |
27+
| T10 borrowed param | r no | r no |
28+
| T11 returned param (`=> x`, `{ return x; }`) | never fresh | no record / `none` |
29+
| T12 recursive / SCC (`A => b ? new R() : B(); B => A(false)`) | fresh (fixpoint) | **unknown**: INF-F5 resolves a forward-return CYCLE to `unknown` (fail-closed) — the expectation assumed an optimistic cycle policy the engine does not have; NOT MET, recorded, not repaired |
30+
| T13 unresolved external | never fresh | no record; the caller is silent |
31+
| T14 virtual / interface | never fresh; never a release | `unknown`; no release |
32+
33+
## 3. Fact-level mutants (each applied to the extracted facts; each MUST fail its target)
34+
35+
- **M3 ignore a branch** — fails as required: T06/T07 become a fabricated `must`, T12 becomes `fresh`, a new
36+
finding appears.
37+
- **M5 ignore overload identity** — fails as required on the P-037-X control x2b-c3: both callers degrade to
38+
OWN051 and the guarded selection is lost.
39+
- **M7 ignore virtual/interface** — fails as required: `Via` becomes fresh and its caller gets a false OWN001.
40+
- **M4 ignore return aliasing** — NOT APPLICABLE at the fact level: the frontend never emits
41+
`return <parameter>` (the return is a bare exit) and the engine claims `fresh` only for a local it saw
42+
acquired, so the mutant cannot be expressed without fabricating an acquire. The aliasing rule holds by
43+
construction; the mutant is retired and the reason recorded.
44+
45+
## 4. Reading
46+
47+
The intra-body effects the algebra names are inferred as pre-registered. The additions of this track so
48+
far are E1 (+ expression bodies), E2 and E3: no OwnIR field, no lattice value, one skeleton-derivation rule.
49+
The two departures are policy, not effect failures (the fail-closed cycle rule; aliasing by construction).
50+
Stage 4 now asks where the two EXTERNAL effects of the frozen witnesses can come from — the dependency
51+
source through exactly these rules, or the explicit two-row model.

‎ownlang/ownir.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1681,6 +1681,12 @@ def visit(ns: Any) -> None:
16811681
return out
16821682

16831683

1684+
1685+
def _re_mixed_return_enabled() -> bool:
1686+
"""resource-effects Stage 3 E3 opt-in (EXPLORATORY; research branch only, never a default)."""
1687+
import os
1688+
return os.environ.get("OWEN_RE_MIXED_RETURN") == "1"
1689+
16841690
def _infer_return_skeleton(nodes: Any, param_names: set[str],
16851691
first_party: frozenset[str] = frozenset(),
16861692
call_key: Callable[[str, str | None], str] | None = None,
@@ -1717,6 +1723,26 @@ def _infer_return_skeleton(nodes: Any, param_names: set[str],
17171723
if all(v in acquired and v not in param_names and v not in call_results
17181724
for v in returned):
17191725
return ReturnSkeleton("fresh")
1726+
# resource-effects Stage 3 E3 (research/resource-effects-v1, EXPLORATORY; pre-registered in
1727+
# Own.NET-paperwork stage3-inference-prereg-v1.json; opt-in OWEN_RE_MIXED_RETURN=1):
1728+
# INF-R3prime — a MIXED skeleton whose every returned local is acquired here on some
1729+
# paths and bound to ONE first-party callee's result on the others (`if (b) return new R();
1730+
# return Make();`) is a forward to that callee: fresh iff the callee is fresh at the
1731+
# fixpoint. Two different callees stay ambiguous (none); a returned parameter is never
1732+
# fresh. Off the opt-in the R3/R4 separation above is unchanged.
1733+
if _re_mixed_return_enabled() and returned and all(
1734+
v not in param_names and (v in acquired or call_results.get(v) is not None)
1735+
for v in returned):
1736+
entries = {call_results[v] for v in returned if call_results.get(v) is not None}
1737+
if entries and len({e[0] for e in entries}) == 1:
1738+
callee = next(iter(entries))[0]
1739+
sigs = {e[1] for e in entries}
1740+
csig = next(iter(sigs)) if len(sigs) == 1 else None
1741+
if _canonical_callee_name(callee) not in first_party \
1742+
and _is_bcl_fresh_factory(callee):
1743+
return ReturnSkeleton("fresh")
1744+
return ReturnSkeleton(
1745+
"forward", callee=call_key(callee, csig) if call_key else callee)
17201746
if len(returned) == 1:
17211747
(v,) = tuple(returned)
17221748
entry = call_results.get(v)

‎rust/crates/own-bridge/src/lower.rs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,40 @@ fn infer_return_skeleton(
654654
{
655655
return ReturnSkeleton::Fresh;
656656
}
657+
// resource-effects Stage 3 E3 (EXPLORATORY; opt-in `OWEN_RE_MIXED_RETURN=1`; see the Python
658+
// twin `_infer_return_skeleton`): a mixed skeleton — every returned local acquired here on
659+
// some paths and bound to ONE first-party callee's result on the others — is a forward to
660+
// that callee (fresh iff the callee is fresh at the fixpoint). Two callees stay ambiguous.
661+
if re_mixed_return_enabled()
662+
&& returned.iter().all(|v| {
663+
!param_names.contains(v)
664+
&& (acquired.contains(v) || matches!(call_results.get(v), Some(Some(_))))
665+
})
666+
{
667+
let entries: Vec<&CallOrigin> = returned
668+
.iter()
669+
.filter_map(|v| call_results.get(v))
670+
.filter(|e| e.is_some())
671+
.collect();
672+
let callees: HashSet<&str> = entries
673+
.iter()
674+
.filter_map(|e| e.as_ref().map(|(c, _)| c.as_str()))
675+
.collect();
676+
if callees.len() == 1 {
677+
let callee = *callees.iter().next().expect("len == 1");
678+
let sigs: HashSet<Option<&str>> = entries
679+
.iter()
680+
.filter_map(|e| e.as_ref().map(|(_, s)| s.as_deref()))
681+
.collect();
682+
let csig = if sigs.len() == 1 { *sigs.iter().next().expect("len == 1") } else { None };
683+
if !first_party.contains(canonical(callee)) && is_bcl_fresh_factory(callee) {
684+
return ReturnSkeleton::Fresh;
685+
}
686+
return ReturnSkeleton::Forward {
687+
callee: call_key(callee, csig),
688+
};
689+
}
690+
}
657691
if returned.len() == 1 {
658692
let v = returned.iter().next().expect("len == 1");
659693
if let Some(Some((callee, csig))) = call_results.get(v) {
@@ -1495,6 +1529,11 @@ fn p037x_guarded_enabled() -> bool {
14951529
std::env::var_os("OWEN_P037X_GUARDED").is_some_and(|v| v == "1")
14961530
}
14971531

1532+
/// resource-effects Stage 3 E3 opt-in: `OWEN_RE_MIXED_RETURN=1` (EXPLORATORY; never a default).
1533+
fn re_mixed_return_enabled() -> bool {
1534+
std::env::var_os("OWEN_RE_MIXED_RETURN").is_some_and(|v| v == "1")
1535+
}
1536+
14981537
fn p037x_guarded_doc(facts: &OwnIr) -> Option<own_guarded::GuardedDoc> {
14991538
p037x_guarded_enabled().then(|| own_guarded::GuardedDoc::solve(facts))
15001539
}

‎tests/test_re_controls.py‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@
8383
"s08b-caller": ["OWN001", "OWN001", "OWN001"],
8484
}
8585
_SHAPES = os.path.join("corpus", "re-cfg-probe")
86+
# Stage 3: the inference suite (corpus/re-inference/t-suite.cs) verdict codes under the body arm and
87+
# under the body arm with the engine rule E3 (OWEN_RE_MIXED_RETURN=1: T08 Make7's caller leaks);
88+
# the summaries are recorded in paper-eval/resource-effects/stage3-inference-v1.json
89+
_RECORD_3: dict[str, list[str]] = {
90+
"body": ["OWN001", "OWN002"],
91+
"body+E3": ["OWN001", "OWN001", "OWN002"],
92+
}
93+
_SUITE = os.path.join("corpus", "re-inference")
8694

8795

8896
def _codes(text: str) -> list[str]:
@@ -96,8 +104,9 @@ def _run(argv: list[str], env: dict[str, str]) -> str:
96104

97105
def _check(name: str, arm: str, want: list[str], ext_env: dict[str, str], tmp: str,
98106
rust: str | None, env: dict[str, str], fails: list[str],
99-
folder: str = _CONTROLS) -> None:
107+
folder: str = _CONTROLS, eng_env: dict[str, str] | None = None) -> None:
100108
src = os.path.join(folder, f"{name}.cs")
109+
env = {**env, **(eng_env or {})}
101110
facts = os.path.join(tmp, f"{name}.{arm}.json")
102111
try:
103112
subprocess.run(["dotnet", "run", "--project", _EXT, "--", "--flow-locals", src,
@@ -127,7 +136,7 @@ def run() -> int:
127136
if rust and not os.path.isfile(rust):
128137
rust = None
129138
env = {**os.environ, "PYTHONPATH": _REPO}
130-
for k in ("OWEN_RE_ORACLE", "OWEN_RE_MINTED_RETURN", "OWEN_RE_BODY"):
139+
for k in ("OWEN_RE_ORACLE", "OWEN_RE_MINTED_RETURN", "OWEN_RE_BODY", "OWEN_RE_MIXED_RETURN"):
131140
env.pop(k, None)
132141
os.environ.pop(k, None)
133142
key = os.path.join(_REPO, _KEYS, "answer-key.json")
@@ -152,10 +161,14 @@ def run() -> int:
152161
for name, want in sorted(_RECORD_2.items()):
153162
_check(name, "body", want, {"OWEN_RE_MINTED_RETURN": "1", "OWEN_RE_BODY": "1"}, tmp,
154163
rust, env, fails, folder=_SHAPES)
164+
for arm, want in sorted(_RECORD_3.items()):
165+
_check("t-suite", arm, want, {"OWEN_RE_MINTED_RETURN": "1", "OWEN_RE_BODY": "1"}, tmp,
166+
rust, env, fails, folder=_SUITE,
167+
eng_env={"OWEN_RE_MIXED_RETURN": "1"} if arm == "body+E3" else None)
155168
for f in fails:
156169
print(f"FAIL: {f}")
157170
n = (sum(len(a) for a in _RECORD.values()) + len(_OBSERVATION)
158-
+ sum(len(a) for a in _RECORD_1B.values()) + len(_RECORD_2))
171+
+ sum(len(a) for a in _RECORD_1B.values()) + len(_RECORD_2) + len(_RECORD_3))
159172
print(f"re controls: {n} row(s), {len(fails)} failed"
160173
+ ("" if rust else " (Rust parity not checked: OWEN_RUST_CORE unset)"))
161174
return 1 if fails else 0

0 commit comments

Comments
 (0)