Skip to main content

brink_runtime/
effect_trace.rs

1//! Ground-truth effect-atom recorder (issue #870, T2 effects epic,
2//! `docs/effects-spec.md`). The effects analogue of the oracle: this module
3//! records, per **executing definition scope**, the atomic effects the VM
4//! actually performs — cells read, cells written, external kinds called —
5//! so `brink-test-harness` can assert the statically-inferred `effects(def)`
6//! row (`brink-db::ProjectDb::effects`) covers every one of them for every
7//! def a real run executed. A purely structural inter-row consistency check
8//! (caller's row ⊇ callee's row) cannot catch an under-report where *both*
9//! rows silently agree on the wrong (too-small) answer — exactly the #866
10//! ref-param-write regression this issue is named for. This is the
11//! independent, run-the-bytecode-and-look check that closes that gap.
12//!
13//! **Attribution mirrors the static analyzer's own model exactly**
14//! (`brink_analyzer::infer::body::record_ref_param_writes`), not naively
15//! "whichever def's bytecode happens to be executing": a `ref` argument's
16//! pointer/projection is constructed exactly once, at the call site, inside
17//! the *caller's* own bytecode (`Opcode::PushVarPointer`/`Opcode::
18//! MakeProjection` — both are emitted *only* there, never for a plain read,
19//! confirmed against `brink-codegen-inkb`'s `expr.rs`). The eventual
20//! dereference deep inside the callee's frame (`SetTemp`/`GetTemp`/
21//! `TakeTemp`'s pointer/projection arms, `ProjRead`/`ProjWrite`) is
22//! deliberately **not** re-recorded — the callee's own row is generic over
23//! whichever concrete cell a caller bound its `ref` parameter to, so the
24//! static model charges the write to the call site that names the concrete
25//! global, never to the callee. Recording at construction time reproduces
26//! that attribution for free: whichever def's bytecode is running when the
27//! pointer/projection value is built is, by construction, the def the
28//! static analyzer also charges. See the call sites in `vm.rs`'s
29//! `note_effect_*` helpers for the exact opcodes instrumented.
30//!
31//! Feature-gated exactly like the `bench-counters` module (issue #821):
32//! this module and every call site are compiled out entirely unless
33//! `effect-trace` is enabled (not part of `default` — no released consumer
34//! should ever turn it on), so an ordinary build pays exactly zero cost.
35
36use alloc::collections::{BTreeMap, BTreeSet};
37use alloc::string::String;
38use std::sync::Mutex;
39
40use brink_format::DefinitionId;
41
42/// Atoms observed for one executed definition scope (`docs/effects-spec.md`
43/// §2) — the runtime counterpart of `brink_analyzer::EffectRow`'s
44/// `{reads, writes, calls}` (this module never constructs an opaque row:
45/// every atom the VM performs is concrete).
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct ObservedRow {
48    pub reads: BTreeSet<DefinitionId>,
49    pub writes: BTreeSet<DefinitionId>,
50    pub calls: BTreeSet<String>,
51    /// NS-A2 (issue #1108): the def emitted visible content (a line ref,
52    /// value, glue, or spring on the visible output channel — string-eval
53    /// captures excluded; see `vm::note_effect_emit`).
54    pub emits: bool,
55    /// NS-A2: the def produced a tag (any `EndTag` destination).
56    pub tags: bool,
57    /// NS-A2: a tracked turn-terminating fault fired while the def was
58    /// executing (see [`is_tracked_fault`] for the inventory).
59    pub faults: bool,
60}
61
62static OBSERVED: Mutex<BTreeMap<DefinitionId, ObservedRow>> = Mutex::new(BTreeMap::new());
63
64/// Run `f` against the map, recovering from lock poisoning rather than
65/// panicking (`unwrap`/`expect` on a `PoisonError` are denied outside tests
66/// by workspace lint policy) — a panicking test elsewhere in the same
67/// process must never wedge every subsequent recorder call.
68fn with_map<R>(f: impl FnOnce(&mut BTreeMap<DefinitionId, ObservedRow>) -> R) -> R {
69    let mut guard = OBSERVED
70        .lock()
71        .unwrap_or_else(std::sync::PoisonError::into_inner);
72    f(&mut guard)
73}
74
75/// Record a cell read, attributed to `def` (the definition scope executing
76/// when the read happened — see the module docs for what "attributed to"
77/// means for a pointer/projection-mediated access).
78pub fn record_read(def: DefinitionId, cell: DefinitionId) {
79    with_map(|m| {
80        m.entry(def).or_default().reads.insert(cell);
81    });
82}
83
84/// Record a cell write, attributed to `def`.
85pub fn record_write(def: DefinitionId, cell: DefinitionId) {
86    with_map(|m| {
87        m.entry(def).or_default().writes.insert(cell);
88    });
89}
90
91/// Record an external-kind call, attributed to `def`.
92pub fn record_call(def: DefinitionId, name: String) {
93    with_map(|m| {
94        m.entry(def).or_default().calls.insert(name);
95    });
96}
97
98/// NS-A2 (issue #1108): record a visible content emission, attributed to
99/// `def`.
100pub fn record_emit(def: DefinitionId) {
101    with_map(|m| {
102        m.entry(def).or_default().emits = true;
103    });
104}
105
106/// NS-A2: record a tag-channel touch, attributed to `def`.
107pub fn record_tag(def: DefinitionId) {
108    with_map(|m| {
109        m.entry(def).or_default().tags = true;
110    });
111}
112
113/// NS-A2: record a tracked turn-terminating fault, attributed to `def` (the
114/// definition scope executing when `vm::step` returned the fault).
115pub fn record_fault(def: DefinitionId) {
116    with_map(|m| {
117        m.entry(def).or_default().faults = true;
118    });
119}
120
121/// NS-A2 (issue #1108, from #1097): is this error one of the **designed
122/// domain faults** the `faults` row dimension tracks? The inventory mirrors
123/// the static harvest in `brink-analyzer::infer::body` exactly — every
124/// variant listed here must be raisable only by a construct that sets the
125/// static `faults` bit (indexing, `/`/`mod`, the faulting stdlib
126/// intrinsics, conversions, `ref` projections, value calls), or the
127/// ground-truth harness would report a false under-report.
128///
129/// F34 note: `ComparatorWroteState` (dev-mode-only, like
130/// `UnorderedComparand`) is raisable only inside a pure-callback frame — a
131/// frame reachable only through `sort_by`/`sorted_by`'s, or the fn-value
132/// verb trio's (`map`/`filter`/`fold`, issue #1679), value-call dispatch,
133/// whose call sites the static harvest conservatively marks as faulting
134/// (`check_value_call`'s dispatch-faults rule). The observation attributes
135/// the fault to the *callee's* def (the scope executing at the write
136/// opcode), whose own static row need not carry a fault construct —
137/// acceptable because the write construct that triggers it is exactly what
138/// E119 rejects wherever the callee's origin is provable, and no
139/// ground-truth corpus case runs an opaque writing comparator/callback in
140/// dev mode.
141///
142/// `CallbackNotAFunction`/`CallbackReturnType` (issue #1679's dispatch
143/// faults, the trio's counterparts to `ComparatorNotAFunction`) sit beside
144/// the NS-A4 pair below for the same reason: `intrinsics.rs` declares
145/// `map`/`filter`/`fold` as never-fault-discharged (`may_fault`), so this
146/// ground-truth recorder must actually observe the trio's dispatch faults
147/// or it would silently under-report exactly the class it exists to catch.
148///
149/// Deliberately NOT tracked (not part of the dimension v1):
150/// - gradual-mode type errors (`TypeError`, `NotARecord`,
151///   `RecordFieldNotFound`, …) — the strict-mode-eliminated species;
152/// - infrastructure/malformed-bytecode errors (stack underflows, invalid
153///   ids, decode errors, step/line limits, `RanOutOfContent`);
154/// - host-surface errors (`ArgCountMismatch`, `UnknownPath`,
155///   `PrivateAccess`, external-resolution errors).
156#[must_use]
157pub fn is_tracked_fault(e: &crate::RuntimeError) -> bool {
158    use crate::RuntimeError as E;
159    matches!(
160        e,
161        E::DivisionByZero
162            | E::IndexOutOfBounds { .. }
163            | E::MapKeyNotFound { .. }
164            | E::NotIndexable(_)
165            | E::InvalidArrayIndex(_)
166            | E::InvalidMapKeyType(_)
167            | E::ConversionParseFailure { .. }
168            | E::InvalidConversionDomain { .. }
169            | E::CharAtIndexNotInt(_)
170            | E::CharAtOutOfBounds { .. }
171            | E::StdlibWrongType { .. }
172            | E::NotOrderable { .. }
173            | E::EmptyRangeDraw { .. }
174            | E::ProjectionInvalidated(_)
175            | E::NotCallable(_)
176            | E::FunctionValueArity { .. }
177            | E::FunctionValueCrossFlowLocal(_)
178            | E::FunctionValueRehydrationMismatch(_)
179            | E::UnorderedComparand { .. }
180            | E::ComparatorNotAFunction { .. }
181            | E::ComparatorReturnType { .. }
182            | E::ComparatorEscaped { .. }
183            | E::ComparatorWroteState { .. }
184            | E::CallbackNotAFunction { .. }
185            | E::CallbackReturnType { .. }
186    )
187}
188
189/// Clear every recorded atom. Call before each measured run — the recorder
190/// is a single process-wide map, so a caller driving multiple programs (or
191/// multiple explored episodes of one program) in the same process must
192/// reset between the units it wants to compare independently.
193pub fn reset() {
194    with_map(BTreeMap::clear);
195}
196
197/// Snapshot every def's observed atoms recorded since the last [`reset`].
198#[must_use]
199pub fn snapshot() -> BTreeMap<DefinitionId, ObservedRow> {
200    with_map(|m| m.clone())
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use brink_format::DefinitionTag;
207
208    fn def(n: u64) -> DefinitionId {
209        DefinitionId::new(DefinitionTag::Address, n)
210    }
211    fn cell(n: u64) -> DefinitionId {
212        DefinitionId::new(DefinitionTag::GlobalVar, n)
213    }
214
215    #[test]
216    fn records_are_attributed_per_def_and_reset_clears_everything() {
217        reset();
218        record_read(def(1), cell(10));
219        record_write(def(1), cell(11));
220        record_call(def(1), "Play".to_string());
221        record_write(def(2), cell(20));
222
223        let snap = snapshot();
224        assert_eq!(snap[&def(1)].reads, [cell(10)].into_iter().collect());
225        assert_eq!(snap[&def(1)].writes, [cell(11)].into_iter().collect());
226        assert_eq!(
227            snap[&def(1)].calls,
228            ["Play".to_string()].into_iter().collect()
229        );
230        assert_eq!(snap[&def(2)].writes, [cell(20)].into_iter().collect());
231
232        reset();
233        assert!(snapshot().is_empty(), "reset must clear every def's row");
234    }
235}