Skip to main content

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}