brink_analyzer/infer/effects.rs
1//! T2-1 effect-row inference substrate (docs/effects-spec.md §2/§4/§5, issue
2//! #860 — tracked from #859). The soundness core of the T2 effects epic.
3//!
4//! Three layers, never conflated (spec §2):
5//!
6//! - **Atomic effects** are emitted by expressions when they run: `read cell`,
7//! `write cell`, `call external-kind`. Data never has effects; code does.
8//! - **Rows** ([`EffectRow`]) are static summaries of possible atoms —
9//! `{reads, writes, calls}` as **unordered sets** (ordering is the journal's
10//! contract, not the row's). Every atom is absorbed into the enclosing
11//! definition's row, and a direct call pulls in the callee's whole row.
12//! - **Types**: rows ride `Ty::Fn` (spec §5, the heap answer). Since issue
13//! #1680 step 3 the type does carry one — [`super::FnRow`], the structural
14//! set of creation targets §7's token lookup keys on — but **this walk
15//! cannot read it**: `def_effect_atoms` runs the body pass with empty
16//! globals and empty signatures (load-bearing for §6.1a's acyclicity), and
17//! a `#fn` literal types as `Unknown` under empty signatures. So a call
18//! through a value stored in a VAR/CONST cell (§6 mechanism 3, the heap)
19//! still stays **opaque** — the conservative floor, see
20//! [`EffectRow::opaque`] — which is sound. Wiring that rung means deciding
21//! which stratum reads the type-carried row (spec §6.1c). Three rungs
22//! narrow the floor today, each structural (no inferred row or signature
23//! ever decides a call-graph edge, §6.1a):
24//!
25//! 1. Issue #872: a call through a **local** whose every write traces to a
26//! `#fn(target, …)`/`bind(…)`-chain origin resolves to those origins
27//! (`InferPass::resolve_pending_value_calls` in `infer::body`). Fork A
28//! (`docs/decision-log.md` 2026-07-28, issue #1726) widened it from a
29//! single write-once origin to the **join over every traced write**, and
30//! added [`EffectAtoms::creates_fn_values`] — the structural record of
31//! which targets a body creates fn values for, harvested by the same
32//! walk with empty globals and empty sigs.
33//! 2. Issue #1680 / §6.1: a call through a **fn-typed param** is a **row
34//! variable** — [`EffectRow::holes`], the "row with a hole" Fork C
35//! ruled. The definition's own row stays pessimal
36//! ([`EffectRow::is_pessimal`]); each *caller* instantiates the hole
37//! from its structurally-traced argument
38//! ([`EffectAtoms::call_fn_args`]) and so escapes the floor.
39//! 3. The heap (VAR/CONST cells joined project-wide, §5's "sound, coarse,
40//! improvable") is still pessimal. The `Ty::Fn` row it needs now
41//! exists; what is missing is a stratum that can read it — see the
42//! **Types** bullet above and spec §6.1c (issue #1680's remainder).
43//!
44//! **Soundness direction (spec §3, conservative-total)**: rows may over-report,
45//! never under-report. Over-report costs parallelism or a spurious wakeup;
46//! under-report is an engine-level race. The pessimal touches-everything row
47//! ([`EffectRow::pessimal`]) is always available and always sound; "no answer"
48//! is never an option. The `conservative_total_*` property tests pin the
49//! no-under-report invariant, mutually-recursive fixture included.
50//!
51//! **Inference (spec §4)**: a definition's row coalesces exactly like its type
52//! — walk the body, collect atoms ([`EffectAtoms`], harvested by the same
53//! `infer_def_body` walk FG-2.1's `referenced_globals` already drives), union;
54//! a direct call to an inferable callee pulls in the callee's row with
55//! recursion handled by the **same per-SCC fixpoint as TM-1's type solver**
56//! ([`solve_scc_effects`] — monotone join, finite lattice of cells + kinds,
57//! terminates, no widening). An `Unknown`/opaque callee has no row to read →
58//! pessimal (spec §4's gradual-mode corollary).
59
60use std::collections::{BTreeMap, BTreeSet};
61
62use brink_format::DefinitionId;
63
64/// A static summary of the atomic effects a definition (and everything it
65/// transitively calls) may perform — the spec §2 "row". Unordered sets over a
66/// finite per-project lattice, so [`join`](Self::join) is a monotone
67/// least-upper-bound and the per-SCC fixpoint terminates without widening.
68///
69/// `opaque` is the top element of that lattice: when set, the row is treated
70/// as *touching every cell and calling every kind* regardless of the listed
71/// members — the conservative-total floor (spec §3) for a call whose effects
72/// inference cannot see (a call through a function value, an unresolved
73/// callee). [`covers`](Self::covers) reads it as "⊒ everything".
74#[derive(Debug, Clone, Default, PartialEq, Eq)]
75#[expect(
76 clippy::struct_excessive_bools,
77 reason = "opaque + the NS-A2 emits/tags/faults dimensions are independent \
78 lattice components of one row, not a state machine"
79)]
80pub struct EffectRow {
81 /// Cells (VAR/CONST global [`DefinitionId`]s) this row may read. Seeded
82 /// from FG-2.1's `referenced_globals` per-def read-set (spec §4).
83 pub reads: BTreeSet<DefinitionId>,
84 /// Cells this row may write (assignment targets resolving to a VAR/CONST).
85 pub writes: BTreeSet<DefinitionId>,
86 /// `EXTERNAL` binding *names* (the call-kinds, spec §2) this row may
87 /// transitively call.
88 pub calls: BTreeSet<String>,
89 /// The pessimal top element (spec §3): this row performs a call whose
90 /// effects inference cannot summarize — a call through a function value
91 /// with no visible row, or an unresolved callee. An opaque row is sound
92 /// against any concrete row it might stand in for.
93 pub opaque: bool,
94 /// NS-A2 (issue #1108, from #1087): the definition may produce **content**
95 /// — narration/dialogue fragments a host renders (text, interpolations,
96 /// glue-only output counts; ruled 2026-07-18). Tag-only lines do NOT set
97 /// this — tags are the metadata channel, tracked by [`Self::tags`]
98 /// (maintainer ruling refinement on #1087). Bool granularity v1.
99 pub emits: bool,
100 /// NS-A2 (issue #1108, from #1087's second ruling): the definition may
101 /// touch the **tag channel** — line tags, tag-only lines, choice tags.
102 /// Independent of [`Self::emits`]: a flow can be silent-but-annotating,
103 /// narrating-but-untagged, both, or neither. Bool granularity v1.
104 pub tags: bool,
105 /// NS-A2 (issue #1108, from #1097): the definition may raise a
106 /// **turn-terminating fault** — the designed domain-fault inventory
107 /// (E078-lineage conversions, OOB indexing, missing-key reads,
108 /// division by zero, the A1 `StdlibWrongType`/`NotOrderable` stdlib
109 /// faults, projection invalidation, value-call dispatch faults). Bool
110 /// granularity v1; per-fault-kind is the reserved refinement.
111 pub faults: bool,
112 /// NS-A4 / **F29(a)** (ruled by delegation 2026-07-19, stdlib-spec
113 /// §4b): the *refined* faults bit — like [`faults`](Self::faults) but
114 /// with charge sites **discharged by local type evidence** where the
115 /// walk can prove the construct total (a wrong-type-only intrinsic
116 /// over a provably-right-typed argument, float division, `for` over a
117 /// provable collection, an int-bounded range literal). Invariant:
118 /// `faults_refined → faults` (the refinement only ever *removes*
119 /// charges). Consumed by the protocol-impl contract gate (E114): a
120 /// `display`/`compare` impl whose row is provably total does NOT
121 /// inherit the conservative bit; the conservative union applies only
122 /// when the impl's own row is opaque or genuinely fault-bearing.
123 /// Deliberately NOT part of [`covers`](Self::covers)/
124 /// [`is_empty`](Self::is_empty) semantics (those stay anchored to the
125 /// conservative bit — the ground-truth harness and assertion checks
126 /// must keep the no-under-report property), and never serialized into
127 /// the `.inkb` `EffectRows` section.
128 pub faults_refined: bool,
129 /// **§6.1 row variables — the "row with a hole"** (`docs/effects-spec.md`
130 /// §6 mechanism 1 and §6.1b; Fork C of issue #1680, ruled 2026-07-28).
131 /// Each member is the *declaration index* of one of this definition's own
132 /// `fn`-typed params that the body **calls through**. The row is
133 /// therefore parametric: its true effects are this row's listed atoms
134 /// **⊔ the row of whatever fn value the caller passes in that position**.
135 ///
136 /// A hole is **not** a second opacity bit. [`opaque`](Self::opaque) stays
137 /// the *intrinsic* floor (a call inference genuinely cannot see);
138 /// [`is_pessimal`](Self::is_pessimal) is the effective floor every
139 /// consumer must read, and it is `true` for any row with an unfilled
140 /// hole. That keeps the conservative-total direction (spec §3) exactly as
141 /// it was: a higher-order definition read on its own is still pessimal.
142 /// The precision arrives one hop up, in [`solve_scc_effects`], which
143 /// **instantiates** the hole from the caller's structurally-traced
144 /// argument origins ([`EffectAtoms::call_fn_args`]) and so no longer
145 /// inherits the callee's floor.
146 ///
147 /// Shallow by construction (§6.1: "every value's row is fixed at its
148 /// creation site"): a hole is filled with ground rows, never with another
149 /// hole — an argument that is itself a fn-typed param, or a target whose
150 /// own row still holes, falls back to the floor rather than chaining.
151 pub holes: BTreeSet<u32>,
152}
153
154impl EffectRow {
155 /// The pessimal touches-everything row (spec §3) — always sound, the
156 /// answer for an `Unknown`/opaque callee.
157 #[must_use]
158 pub fn pessimal() -> Self {
159 Self {
160 opaque: true,
161 // F29: the conservative union applies to an opaque row — the
162 // refined bit never claims totality for a row inference
163 // cannot see.
164 faults_refined: true,
165 ..Self::default()
166 }
167 }
168
169 /// The **effective** pessimal floor — the bit every consumer of a row
170 /// must read in place of [`opaque`](Self::opaque).
171 ///
172 /// `opaque` records only *intrinsic* opacity (a call whose effects
173 /// inference genuinely cannot see). A row that carries §6.1
174 /// [`holes`](Self::holes) is equally unusable on its own: its true
175 /// effects depend on an argument the definition has not been given yet.
176 /// Reading such a row as non-pessimal would under-report — the one thing
177 /// spec §3 forbids — so an unfilled hole tops the lattice exactly like
178 /// `opaque` does. Only [`solve_scc_effects`], which can *fill* the hole
179 /// from the call site, is entitled to look past it.
180 #[must_use]
181 pub fn is_pessimal(&self) -> bool {
182 self.opaque || !self.holes.is_empty()
183 }
184
185 /// Whether this row lists (or subsumes) nothing at all — an empty,
186 /// non-opaque row (a genuinely pure·silent·untagged·total definition).
187 #[must_use]
188 pub fn is_empty(&self) -> bool {
189 !self.is_pessimal()
190 && self.reads.is_empty()
191 && self.writes.is_empty()
192 && self.calls.is_empty()
193 && !self.emits
194 && !self.tags
195 && !self.faults
196 }
197
198 /// Fold `other` into `self` — the lattice join (set union per component,
199 /// `opaque`/`emits`/`tags`/`faults` are sticky). Monotone: `self` only
200 /// ever grows, which is what makes the [`solve_scc_effects`] fixpoint
201 /// converge over the finite cells + kinds universe.
202 pub fn join(&mut self, other: &EffectRow) {
203 self.join_atoms(other);
204 // Holes are indices into the *declaring definition's* param list, so
205 // unioning them is only meaningful between two rows describing the
206 // same definition (e.g. a fixpoint round's old and new estimate).
207 // Folding a *callee's* row into a caller goes through
208 // [`Self::join_atoms`] instead — see its doc.
209 self.holes.extend(other.holes.iter().copied());
210 }
211
212 /// [`join`](Self::join) minus the [`holes`](Self::holes) component — the
213 /// join used when folding a **callee's** row into a caller.
214 ///
215 /// A hole is an index into the *callee's* own param list; carrying it up
216 /// into the caller's row would silently reinterpret it against the
217 /// caller's params, which is neither sound nor meaningful. The caller
218 /// instead discharges each of the callee's holes explicitly (fill it from
219 /// the call site's traced argument, or take the pessimal floor) — see
220 /// [`solve_scc_effects`].
221 pub fn join_atoms(&mut self, other: &EffectRow) {
222 self.reads.extend(other.reads.iter().copied());
223 self.writes.extend(other.writes.iter().copied());
224 self.calls.extend(other.calls.iter().cloned());
225 self.opaque |= other.opaque;
226 self.emits |= other.emits;
227 self.tags |= other.tags;
228 self.faults |= other.faults;
229 self.faults_refined |= other.faults_refined;
230 }
231
232 /// Whether `self` conservatively covers (⊒) `other`: every atom `other`
233 /// admits, `self` also admits. An opaque `self` covers anything; a
234 /// non-opaque `self` can never cover an opaque `other`. This is the
235 /// no-under-report relation the conservative-total property tests assert
236 /// (spec §3): a def's inferred row must cover its own body atoms and every
237 /// callee's row.
238 #[must_use]
239 pub fn covers(&self, other: &EffectRow) -> bool {
240 if self.is_pessimal() {
241 return true;
242 }
243 if other.is_pessimal() {
244 return false;
245 }
246 other.reads.is_subset(&self.reads)
247 && other.writes.is_subset(&self.writes)
248 && other.calls.is_subset(&self.calls)
249 && (self.emits || !other.emits)
250 && (self.tags || !other.tags)
251 && (self.faults || !other.faults)
252 }
253}
254
255/// The raw per-definition atoms harvested from one body walk — the inputs the
256/// [`solve_scc_effects`] fixpoint closes over. `reads`/`writes`/`calls` are the
257/// direct atoms this body emits; `direct_calls` are the inferable
258/// (knot/stitch) callees whose rows must be joined in transitively;
259/// `creates_fn_values` are the targets this body creates fn values for
260/// (Fork A, issue #1726 — structural, fed into the call graph alongside
261/// `direct_calls`); `opaque` records that the body performed a call through a
262/// function value whose reaching values were not all created in-project (or
263/// another effects-opaque construct), forcing the pessimal floor.
264#[derive(Debug, Clone, Default, PartialEq, Eq)]
265#[expect(
266 clippy::struct_excessive_bools,
267 reason = "mirrors EffectRow's independent dimension flags"
268)]
269pub struct EffectAtoms {
270 pub reads: BTreeSet<DefinitionId>,
271 pub writes: BTreeSet<DefinitionId>,
272 /// Directly-called `EXTERNAL` binding names (call-kind atoms).
273 pub calls: BTreeSet<String>,
274 /// Inferable (knot/stitch) call targets — the edges the fixpoint follows.
275 /// A superset shape of FG-2.1's `call_edges`, harvested from the same walk.
276 pub direct_calls: BTreeSet<DefinitionId>,
277 /// Fork A (`docs/decision-log.md` 2026-07-28 "Fork A — fn-value
278 /// call-graph edges are harvested STRUCTURALLY", issue #1726): the
279 /// inferable targets whose **fn values this body creates** — every
280 /// `#fn(target, …)` literal in the body, whether or not the value is ever
281 /// called here.
282 ///
283 /// **Structural, never row-derived.** The target of a `#fn` literal is a
284 /// syntactic name, so deciding membership never consults an inferred row
285 /// or signature — which is exactly what keeps `call_graph_query →
286 /// scc_membership_query → solve_scc_query → call_graph_query` acyclic
287 /// (§6.1 fixes every fn value's row at its creation site, and creation
288 /// sites are syntactic). `bind(f, …)` adds nothing of its own: it copies
289 /// an existing value rather than naming a new target.
290 ///
291 /// A **subset of [`Self::direct_calls`]** by construction — the same walk
292 /// records a `#fn` target as a call-graph edge too, which is how these
293 /// edges reach the SCC batching and [`solve_scc_effects`] with no change
294 /// to either. Kept as its own set because "creates a value for `g`" and
295 /// "calls `g`" are different facts: spec §7's token table and §8 rung 1's
296 /// reachability slicing both need the creation sites specifically.
297 ///
298 /// **Lambda literals are still out of scope**: a lambda's
299 /// `DefinitionId` is now minted at HIR time (`hir::stamp_container_ids`,
300 /// issue #1727 — LIR lowering only reads it, no longer mints it), but a
301 /// lambda literal has no index symbol / `DefKey` of its own, so there is
302 /// still nothing here to record it against. Joining it into the SCC
303 /// solve is #2152's job, not this one's.
304 pub creates_fn_values: BTreeSet<DefinitionId>,
305 /// This body calls through a function value whose reaching values were
306 /// not all created in-project (or otherwise escapes the static call
307 /// graph) — its row is pessimal (spec §3/§4). Fork A (issue #1726)
308 /// collapsed this to a real row for the in-project case: a call through a
309 /// local whose *every* write traced to a `#fn`/`bind` creation site
310 /// narrows to the join over those targets instead. It stays pessimal for
311 /// genuinely unknown sources — host callbacks (§6.2) and values loaded
312 /// from the heap (§6.3).
313 pub opaque: bool,
314 /// NS-A2: this body directly contains a content-producing construct
315 /// (see [`EffectRow::emits`]).
316 pub emits: bool,
317 /// NS-A2: this body directly touches the tag channel (see
318 /// [`EffectRow::tags`]).
319 pub tags: bool,
320 /// NS-A2: this body directly contains a construct that can raise a
321 /// turn-terminating fault (see [`EffectRow::faults`]).
322 pub faults: bool,
323 /// NS-A4 / F29(a): the refined faults bit (see
324 /// [`EffectRow::faults_refined`]) — the same charge sites with local
325 /// type-evidence discharges applied. `faults_refined → faults`.
326 pub faults_refined: bool,
327 /// §6.1 (issue #1680): the declaration indices of this body's own
328 /// `fn`-typed params that it **calls through** — the row variables its
329 /// row is parametric in. Becomes [`EffectRow::holes`] via
330 /// [`Self::base_row`].
331 ///
332 /// **Structural, never row-derived**, exactly like
333 /// [`Self::creates_fn_values`]: membership is decided by "the callee of
334 /// this call site resolves to param #*i* of the enclosing definition",
335 /// a syntactic fact, so the call graph stays row-independent (Fork A,
336 /// §6.1a).
337 ///
338 /// A param only qualifies when the body cannot have changed what it
339 /// holds: `ref` params are excluded outright (the callee's own caller
340 /// aliases the slot), and so is any param the body assigns to or hands
341 /// to a `ref` slot — for those the call site keeps [`Self::opaque`],
342 /// the pre-#1680 behavior.
343 pub param_holes: BTreeSet<u32>,
344 /// §6.1 (issue #1680): the caller half of a row variable — for each
345 /// `(callee, param index)` this body calls with a **traceable fn-value
346 /// argument**, what that argument can hold. [`solve_scc_effects`] reads
347 /// this to fill the callee row's [`EffectRow::holes`].
348 ///
349 /// Joined over *every* call site to that callee in this body (the walk is
350 /// flow-insensitive), so two sites passing two different `#fn` targets
351 /// yield both targets and the fill joins both — conservative, per Fork A's
352 /// join-over-writes rule. A position with no entry, or an entry whose
353 /// [`FnArgOrigins::untraced`] is set, cannot fill: the hole takes the
354 /// pessimal floor instead.
355 ///
356 /// Recorded only for **inferable** (knot/stitch) callees — the only
357 /// definitions that have a row with holes to fill.
358 pub call_fn_args: BTreeMap<(DefinitionId, u32), FnArgOrigins>,
359}
360
361/// §6.1 (issue #1680): what one call site's argument in a given position can
362/// hold, summarized structurally over every call to that callee in one body —
363/// the caller-side material [`solve_scc_effects`] instantiates a callee's
364/// [`EffectRow::holes`] from.
365///
366/// The same shape (and the same soundness rule) as `infer::body`'s per-local
367/// write summary: a fill is legal only when *every* contributing argument
368/// traced to an in-project creation target. One untraced argument and the
369/// position is unusable — the value could have been created anywhere,
370/// including outside the project (§6.2's host callbacks).
371#[derive(Debug, Clone, Default, PartialEq, Eq)]
372pub struct FnArgOrigins {
373 /// The `#fn`/`bind`-chain creation targets this position's arguments
374 /// traced to, sorted.
375 pub targets: BTreeSet<DefinitionId>,
376 /// At least one argument in this position did not trace to a creation
377 /// site — the position cannot fill a hole.
378 pub untraced: bool,
379}
380
381impl FnArgOrigins {
382 /// Whether this position can fill a row variable: every contributing
383 /// argument traced, and at least one target actually recorded.
384 #[must_use]
385 pub fn is_fillable(&self) -> bool {
386 !self.untraced && !self.targets.is_empty()
387 }
388}
389
390impl EffectAtoms {
391 /// The base row before transitive closure — just this body's own atoms,
392 /// excluding the `direct_calls`/`creates_fn_values` edges (which the
393 /// fixpoint resolves to their callees' rows).
394 #[must_use]
395 pub fn base_row(&self) -> EffectRow {
396 EffectRow {
397 reads: self.reads.clone(),
398 writes: self.writes.clone(),
399 calls: self.calls.clone(),
400 opaque: self.opaque,
401 emits: self.emits,
402 tags: self.tags,
403 faults: self.faults,
404 faults_refined: self.faults_refined,
405 holes: self.param_holes.clone(),
406 }
407 }
408}
409
410/// Solve one SCC batch's effect-row fixpoint (spec §4 — the same per-SCC join
411/// TM-1's type solver runs, lifted to the effect lattice). Lifts
412/// `infer::solve_one_batch`'s shape: `known_rows` must already carry the
413/// finalized row of every def *outside* `batch` that a member calls (every
414/// condensation-predecessor SCC's rows); `atoms` carries every batch member's
415/// harvested [`EffectAtoms`].
416///
417/// Each member's row is `base_row ⊔ (join of every direct callee's row)`,
418/// re-evaluated until nothing changes. A callee inside `batch` reads the
419/// current in-round estimate (that is the mutual-recursion fixpoint); a callee
420/// in `known_rows` reads its finalized row; a callee found in neither is an
421/// unknown target → pessimal (defensive — `direct_calls` only ever holds
422/// inferable ids, so every callee is normally resolvable, but soundness never
423/// depends on that).
424///
425/// Terminates: `join` is monotone over the finite cells + kinds lattice, so
426/// each round either grows some row (bounded by the universe) or stabilizes;
427/// the round cap is a house-rule guard against unbounded growth, never
428/// load-bearing for a well-formed batch.
429#[must_use]
430pub fn solve_scc_effects(
431 batch: &BTreeSet<DefinitionId>,
432 atoms: &BTreeMap<DefinitionId, EffectAtoms>,
433 known_rows: &BTreeMap<DefinitionId, EffectRow>,
434) -> BTreeMap<DefinitionId, EffectRow> {
435 // Seed each member with its own base atoms.
436 let mut rows: BTreeMap<DefinitionId, EffectRow> = batch
437 .iter()
438 .map(|&id| {
439 let base = atoms
440 .get(&id)
441 .map(EffectAtoms::base_row)
442 .unwrap_or_default();
443 (id, base)
444 })
445 .collect();
446
447 // Information flows one call-hop per round; an SCC's diameter is at most
448 // its member count, so `batch.len()` rounds suffice for convergence within
449 // the component. `+ 1` leaves headroom; the `changed` break exits earlier
450 // in every real case.
451 let cap = batch.len().saturating_add(1);
452 for _round in 0..cap {
453 let mut changed = false;
454 for &id in batch {
455 let Some(member_atoms) = atoms.get(&id) else {
456 continue;
457 };
458 let mut next = member_atoms.base_row();
459 for &callee in &member_atoms.direct_calls {
460 // In-batch member → current fixpoint estimate; otherwise a
461 // finalized predecessor-SCC row.
462 let Some(row) = rows.get(&callee).or_else(|| known_rows.get(&callee)) else {
463 // Unknown callee — no row to read → pessimal (spec §4).
464 next.opaque = true;
465 continue;
466 };
467 // `join_atoms`, not `join`: the callee's own §6.1 holes index
468 // *its* param list, so they are discharged here rather than
469 // carried up into this caller's row.
470 next.join_atoms(row);
471 for &hole in &row.holes {
472 instantiate_hole(
473 &mut next,
474 member_atoms.call_fn_args.get(&(callee, hole)),
475 &rows,
476 known_rows,
477 );
478 }
479 }
480 if rows.get(&id) != Some(&next) {
481 changed = true;
482 rows.insert(id, next);
483 }
484 }
485 if !changed {
486 break;
487 }
488 }
489
490 rows
491}
492
493/// Discharge one of a callee's §6.1 row variables into the caller's row
494/// under construction (issue #1680).
495///
496/// `origins` is what the caller passed in that param position, as summarized
497/// structurally by the body walk ([`EffectAtoms::call_fn_args`]). The hole is
498/// **filled** — the caller absorbs each traced target's row instead of the
499/// callee's floor — only when every one of these hold:
500///
501/// - the position is [fillable](FnArgOrigins::is_fillable): recorded, and
502/// every contributing argument traced to an in-project creation site;
503/// - every traced target has a row here (in-batch estimate or finalized
504/// predecessor);
505/// - that row carries no holes of its own — §6.1's shallow-polymorphism
506/// ruling ("every value's row is fixed at its creation site") means a fill
507/// is a *ground* row; chaining one hole into another is deliberately not
508/// attempted.
509///
510/// Any other case takes the pessimal floor, which is exactly the pre-#1680
511/// behavior for a call through a fn-typed param. Every branch either narrows
512/// or degrades to `opaque` — never silently drops the callee's effects.
513///
514/// `pub(crate)`: also reused by `infer::mod`'s
515/// `conservative_total_no_under_report_over_mutual_recursion` property test,
516/// which must instantiate a holed callee's row the same way this fixpoint
517/// does before comparing it against a caller's row with `covers` — the raw,
518/// still-parametric callee row is never itself a coverable target.
519pub(crate) fn instantiate_hole(
520 next: &mut EffectRow,
521 origins: Option<&FnArgOrigins>,
522 rows: &BTreeMap<DefinitionId, EffectRow>,
523 known_rows: &BTreeMap<DefinitionId, EffectRow>,
524) {
525 let Some(origins) = origins.filter(|o| o.is_fillable()) else {
526 next.opaque = true;
527 return;
528 };
529 for target in &origins.targets {
530 match rows.get(target).or_else(|| known_rows.get(target)) {
531 Some(row) if row.holes.is_empty() => next.join_atoms(row),
532 // A hole-carrying (still parametric) target, or one with no row
533 // at all: no ground answer to substitute → floor.
534 Some(row) => {
535 next.join_atoms(row);
536 next.opaque = true;
537 }
538 None => next.opaque = true,
539 }
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use brink_format::{DefinitionId, DefinitionTag};
547
548 fn cell(n: u64) -> DefinitionId {
549 DefinitionId::new(DefinitionTag::GlobalVar, n)
550 }
551
552 #[test]
553 fn join_is_set_union_and_opaque_is_sticky() {
554 let mut a = EffectRow {
555 reads: [cell(1)].into_iter().collect(),
556 calls: ["Play".to_string()].into_iter().collect(),
557 ..Default::default()
558 };
559 let b = EffectRow {
560 reads: [cell(2)].into_iter().collect(),
561 writes: [cell(3)].into_iter().collect(),
562 opaque: true,
563 ..Default::default()
564 };
565 a.join(&b);
566 assert_eq!(a.reads, [cell(1), cell(2)].into_iter().collect());
567 assert_eq!(a.writes, [cell(3)].into_iter().collect());
568 assert_eq!(a.calls, ["Play".to_string()].into_iter().collect());
569 assert!(a.opaque, "opaque must be sticky under join");
570 }
571
572 #[test]
573 fn covers_is_superset_and_opaque_tops_the_lattice() {
574 let big = EffectRow {
575 reads: [cell(1), cell(2)].into_iter().collect(),
576 ..Default::default()
577 };
578 let small = EffectRow {
579 reads: [cell(1)].into_iter().collect(),
580 ..Default::default()
581 };
582 assert!(big.covers(&small));
583 assert!(!small.covers(&big));
584
585 let pess = EffectRow::pessimal();
586 assert!(pess.covers(&big), "pessimal covers everything");
587 assert!(!big.covers(&pess), "no concrete row covers pessimal");
588 assert!(pess.covers(&pess));
589 }
590
591 #[test]
592 fn solve_scc_effects_propagates_a_callee_row_to_its_caller() {
593 // up(1) -> leaf(2); leaf reads cell(10), calls "Play".
594 let up = cell(1);
595 let leaf = cell(2);
596 let batch: BTreeSet<DefinitionId> = [up].into_iter().collect();
597 let atoms: BTreeMap<DefinitionId, EffectAtoms> = [(
598 up,
599 EffectAtoms {
600 writes: [cell(20)].into_iter().collect(),
601 direct_calls: [leaf].into_iter().collect(),
602 ..Default::default()
603 },
604 )]
605 .into_iter()
606 .collect();
607 let known_rows: BTreeMap<DefinitionId, EffectRow> = [(
608 leaf,
609 EffectRow {
610 reads: [cell(10)].into_iter().collect(),
611 calls: ["Play".to_string()].into_iter().collect(),
612 ..Default::default()
613 },
614 )]
615 .into_iter()
616 .collect();
617
618 let rows = solve_scc_effects(&batch, &atoms, &known_rows);
619 let row = &rows[&up];
620 assert_eq!(row.reads, [cell(10)].into_iter().collect());
621 assert_eq!(row.writes, [cell(20)].into_iter().collect());
622 assert_eq!(row.calls, ["Play".to_string()].into_iter().collect());
623 assert!(!row.opaque);
624 }
625
626 #[test]
627 fn solve_scc_effects_reaches_a_mutual_recursion_fixpoint() {
628 // a(1) <-> b(2) in one SCC. a reads cell(10), b writes cell(20); each
629 // calls the other. The fixpoint must give both the union of both atoms.
630 let a = cell(1);
631 let b = cell(2);
632 let batch: BTreeSet<DefinitionId> = [a, b].into_iter().collect();
633 let atoms: BTreeMap<DefinitionId, EffectAtoms> = [
634 (
635 a,
636 EffectAtoms {
637 reads: [cell(10)].into_iter().collect(),
638 direct_calls: [b].into_iter().collect(),
639 ..Default::default()
640 },
641 ),
642 (
643 b,
644 EffectAtoms {
645 writes: [cell(20)].into_iter().collect(),
646 direct_calls: [a].into_iter().collect(),
647 ..Default::default()
648 },
649 ),
650 ]
651 .into_iter()
652 .collect();
653
654 let rows = solve_scc_effects(&batch, &atoms, &BTreeMap::new());
655 for id in [a, b] {
656 let row = &rows[&id];
657 assert_eq!(
658 row.reads,
659 [cell(10)].into_iter().collect(),
660 "both SCC members see a's read"
661 );
662 assert_eq!(
663 row.writes,
664 [cell(20)].into_iter().collect(),
665 "both SCC members see b's write"
666 );
667 }
668 }
669
670 #[test]
671 fn join_carries_emits_tags_faults_stickily() {
672 let mut a = EffectRow::default();
673 let b = EffectRow {
674 emits: true,
675 ..Default::default()
676 };
677 let c = EffectRow {
678 tags: true,
679 faults: true,
680 ..Default::default()
681 };
682 a.join(&b);
683 a.join(&c);
684 assert!(a.emits && a.tags && a.faults);
685 // Joining an empty row afterwards never clears them (sticky).
686 a.join(&EffectRow::default());
687 assert!(a.emits && a.tags && a.faults);
688 }
689
690 #[test]
691 fn covers_is_per_dimension_for_emits_tags_faults() {
692 let silent = EffectRow::default();
693 let emitting = EffectRow {
694 emits: true,
695 ..Default::default()
696 };
697 let tagging = EffectRow {
698 tags: true,
699 ..Default::default()
700 };
701 let faulting = EffectRow {
702 faults: true,
703 ..Default::default()
704 };
705 assert!(!silent.covers(&emitting));
706 assert!(!silent.covers(&tagging));
707 assert!(!silent.covers(&faulting));
708 assert!(
709 emitting.covers(&silent),
710 "asserting less than reality is legal"
711 );
712 // The dimensions are independent: emits does not cover tags/faults.
713 assert!(!emitting.covers(&tagging));
714 assert!(!emitting.covers(&faulting));
715 assert!(!tagging.covers(&emitting));
716 // Opaque tops all three new dimensions too.
717 let pess = EffectRow::pessimal();
718 assert!(pess.covers(&emitting));
719 assert!(pess.covers(&tagging));
720 assert!(pess.covers(&faulting));
721 assert!(!faulting.covers(&pess));
722 }
723
724 #[test]
725 fn solve_scc_effects_propagates_emitter_tagger_faulter_status_transitively() {
726 // up(1) -> leaf(2); leaf emits + tags + faults, up is glue-only.
727 let up = cell(1);
728 let leaf = cell(2);
729 let batch: BTreeSet<DefinitionId> = [up].into_iter().collect();
730 let atoms: BTreeMap<DefinitionId, EffectAtoms> = [(
731 up,
732 EffectAtoms {
733 direct_calls: [leaf].into_iter().collect(),
734 ..Default::default()
735 },
736 )]
737 .into_iter()
738 .collect();
739 let known_rows: BTreeMap<DefinitionId, EffectRow> = [(
740 leaf,
741 EffectRow {
742 emits: true,
743 tags: true,
744 faults: true,
745 ..Default::default()
746 },
747 )]
748 .into_iter()
749 .collect();
750
751 let rows = solve_scc_effects(&batch, &atoms, &known_rows);
752 let row = &rows[&up];
753 assert!(row.emits, "glue-only caller of an emitter still emits");
754 assert!(row.tags);
755 assert!(row.faults);
756 assert!(!row.opaque);
757 }
758
759 #[test]
760 fn an_opaque_atom_makes_the_whole_row_pessimal() {
761 let a = cell(1);
762 let batch: BTreeSet<DefinitionId> = [a].into_iter().collect();
763 let atoms: BTreeMap<DefinitionId, EffectAtoms> = [(
764 a,
765 EffectAtoms {
766 reads: [cell(10)].into_iter().collect(),
767 opaque: true,
768 ..Default::default()
769 },
770 )]
771 .into_iter()
772 .collect();
773 let rows = solve_scc_effects(&batch, &atoms, &BTreeMap::new());
774 assert!(rows[&a].opaque);
775 }
776
777 /// §6.1 (issue #1680) at the lattice level: an unfilled row variable is
778 /// as unbounded as intrinsic opacity, and `covers` must treat it that way
779 /// or the no-under-report invariant breaks the moment a holed row is
780 /// compared with an assertion.
781 #[test]
782 fn a_hole_tops_the_lattice_exactly_like_opaque() {
783 let holed = EffectRow {
784 holes: [0].into_iter().collect(),
785 ..Default::default()
786 };
787 let concrete = EffectRow {
788 reads: [cell(1)].into_iter().collect(),
789 ..Default::default()
790 };
791 assert!(holed.is_pessimal());
792 assert!(!holed.opaque, "the hole is not the intrinsic opaque bit");
793 assert!(!holed.is_empty(), "a parametric row is never 'empty'");
794 assert!(holed.covers(&concrete));
795 assert!(!concrete.covers(&holed));
796 }
797
798 /// A callee's holes index the *callee's* param list, so folding its row
799 /// into a caller must not carry them up — that is `join_atoms`, and it is
800 /// what the solver uses for every callee edge.
801 #[test]
802 fn join_atoms_leaves_the_callees_holes_behind() {
803 let mut up = EffectRow::default();
804 let down = EffectRow {
805 reads: [cell(1)].into_iter().collect(),
806 holes: [2].into_iter().collect(),
807 ..Default::default()
808 };
809 up.join_atoms(&down);
810 assert_eq!(up.reads, [cell(1)].into_iter().collect());
811 assert!(up.holes.is_empty());
812
813 // `join` (same-definition estimates) does union them.
814 let mut same_def = EffectRow::default();
815 same_def.join(&down);
816 assert_eq!(same_def.holes, [2].into_iter().collect::<BTreeSet<u32>>());
817 }
818
819 #[test]
820 fn a_traced_argument_instantiates_the_callees_row_variable() {
821 // caller(1) -> higher_order(2), whose row holes at param 0; the call
822 // site passes a fn value for target(3), which writes cell(20).
823 let caller = cell(1);
824 let higher_order = cell(2);
825 let target = cell(3);
826 let batch: BTreeSet<DefinitionId> = [caller].into_iter().collect();
827 let atoms: BTreeMap<DefinitionId, EffectAtoms> = [(
828 caller,
829 EffectAtoms {
830 direct_calls: [higher_order, target].into_iter().collect(),
831 call_fn_args: [(
832 (higher_order, 0),
833 FnArgOrigins {
834 targets: [target].into_iter().collect(),
835 untraced: false,
836 },
837 )]
838 .into_iter()
839 .collect(),
840 ..Default::default()
841 },
842 )]
843 .into_iter()
844 .collect();
845 let known_rows: BTreeMap<DefinitionId, EffectRow> = [
846 (
847 higher_order,
848 EffectRow {
849 holes: [0].into_iter().collect(),
850 ..Default::default()
851 },
852 ),
853 (
854 target,
855 EffectRow {
856 writes: [cell(20)].into_iter().collect(),
857 ..Default::default()
858 },
859 ),
860 ]
861 .into_iter()
862 .collect();
863
864 let rows = solve_scc_effects(&batch, &atoms, &known_rows);
865 let row = &rows[&caller];
866 assert!(!row.is_pessimal(), "a filled hole is not a floor");
867 assert!(row.holes.is_empty(), "the callee's hole is not inherited");
868 assert!(
869 row.writes.contains(&cell(20)),
870 "the instantiated row carries the argument target's own writes"
871 );
872 }
873
874 #[test]
875 fn an_untraced_or_missing_argument_leaves_the_hole_pessimal() {
876 let caller = cell(1);
877 let higher_order = cell(2);
878 let target = cell(3);
879 let holed: BTreeMap<DefinitionId, EffectRow> = [(
880 higher_order,
881 EffectRow {
882 holes: [0].into_iter().collect(),
883 ..Default::default()
884 },
885 )]
886 .into_iter()
887 .collect();
888 let batch: BTreeSet<DefinitionId> = [caller].into_iter().collect();
889
890 for (label, origins) in [
891 ("no entry at all", None),
892 (
893 "an untraced write in the position",
894 Some(FnArgOrigins {
895 targets: [target].into_iter().collect(),
896 untraced: true,
897 }),
898 ),
899 ("an entry with no targets", Some(FnArgOrigins::default())),
900 ] {
901 let atoms: BTreeMap<DefinitionId, EffectAtoms> = [(
902 caller,
903 EffectAtoms {
904 direct_calls: [higher_order].into_iter().collect(),
905 call_fn_args: origins
906 .into_iter()
907 .map(|o| ((higher_order, 0), o))
908 .collect(),
909 ..Default::default()
910 },
911 )]
912 .into_iter()
913 .collect();
914 let rows = solve_scc_effects(&batch, &atoms, &holed);
915 assert!(rows[&caller].opaque, "{label} must keep the pessimal floor");
916 }
917 }
918
919 /// §6.1 is shallow: a fill target whose own row is still parametric has
920 /// no ground answer to substitute, so the caller takes the floor rather
921 /// than chaining one hole into another.
922 #[test]
923 fn a_still_parametric_fill_target_keeps_the_floor() {
924 let caller = cell(1);
925 let higher_order = cell(2);
926 let target = cell(3);
927 let batch: BTreeSet<DefinitionId> = [caller].into_iter().collect();
928 let atoms: BTreeMap<DefinitionId, EffectAtoms> = [(
929 caller,
930 EffectAtoms {
931 direct_calls: [higher_order].into_iter().collect(),
932 call_fn_args: [(
933 (higher_order, 0),
934 FnArgOrigins {
935 targets: [target].into_iter().collect(),
936 untraced: false,
937 },
938 )]
939 .into_iter()
940 .collect(),
941 ..Default::default()
942 },
943 )]
944 .into_iter()
945 .collect();
946 let known_rows: BTreeMap<DefinitionId, EffectRow> = [
947 (
948 higher_order,
949 EffectRow {
950 holes: [0].into_iter().collect(),
951 ..Default::default()
952 },
953 ),
954 (
955 target,
956 EffectRow {
957 writes: [cell(20)].into_iter().collect(),
958 holes: [0].into_iter().collect(),
959 ..Default::default()
960 },
961 ),
962 ]
963 .into_iter()
964 .collect();
965
966 let rows = solve_scc_effects(&batch, &atoms, &known_rows);
967 assert!(rows[&caller].opaque);
968 assert!(
969 rows[&caller].writes.contains(&cell(20)),
970 "degrading to the floor still absorbs everything the target listed"
971 );
972 }
973
974 /// A fill target with no row anywhere (a torn edge) must degrade, never
975 /// silently drop the callback's effects.
976 #[test]
977 fn a_fill_target_with_no_row_forces_pessimal() {
978 let caller = cell(1);
979 let higher_order = cell(2);
980 let ghost = cell(99);
981 let batch: BTreeSet<DefinitionId> = [caller].into_iter().collect();
982 let atoms: BTreeMap<DefinitionId, EffectAtoms> = [(
983 caller,
984 EffectAtoms {
985 direct_calls: [higher_order].into_iter().collect(),
986 call_fn_args: [(
987 (higher_order, 0),
988 FnArgOrigins {
989 targets: [ghost].into_iter().collect(),
990 untraced: false,
991 },
992 )]
993 .into_iter()
994 .collect(),
995 ..Default::default()
996 },
997 )]
998 .into_iter()
999 .collect();
1000 let known_rows: BTreeMap<DefinitionId, EffectRow> = [(
1001 higher_order,
1002 EffectRow {
1003 holes: [0].into_iter().collect(),
1004 ..Default::default()
1005 },
1006 )]
1007 .into_iter()
1008 .collect();
1009 let rows = solve_scc_effects(&batch, &atoms, &known_rows);
1010 assert!(rows[&caller].opaque);
1011 }
1012
1013 #[test]
1014 fn an_unknown_callee_forces_pessimal() {
1015 // caller's direct_call target is absent from both batch and known_rows
1016 // (a torn/unknown edge) — the row must degrade to pessimal, never
1017 // silently under-report.
1018 let a = cell(1);
1019 let ghost = cell(99);
1020 let batch: BTreeSet<DefinitionId> = [a].into_iter().collect();
1021 let atoms: BTreeMap<DefinitionId, EffectAtoms> = [(
1022 a,
1023 EffectAtoms {
1024 direct_calls: [ghost].into_iter().collect(),
1025 ..Default::default()
1026 },
1027 )]
1028 .into_iter()
1029 .collect();
1030 let rows = solve_scc_effects(&batch, &atoms, &BTreeMap::new());
1031 assert!(rows[&a].opaque);
1032 }
1033}