Skip to main content

synth_core/
async_intrinsics.rs

1//! P3 async host-intrinsic classification and honest degradation (#80).
2//!
3//! When Meld lowers a P3 (Component Model async) component to a core module,
4//! the output imports RFC #46 async host intrinsics (`stream.read`,
5//! `stream.write`, `waitable-set.wait`, `task.return`, `error-context.new`,
6//! `future.read`, …). Synth compiles each import *call site* to a native
7//! `BL <field-name>` into kiln-builtins via the existing relocatable host-link
8//! path (#197) — the raw call lowering already exists and is unchanged.
9//!
10//! What this module adds is the missing *semantic gate* (#80): a compiler that
11//! blindly emits `BL stream.read` would silently miscompile — the intrinsic
12//! needs bounds-checked linear-memory buffers and register save/restore across
13//! a scheduler yield, none of which synth generates yet. The #275-style honest
14//! degradation is: LOWER exactly the ONE family we can compile correctly, and
15//! LOUD-DECLINE every other family BY NAME with a machine reason, rather than
16//! emitting a call that will misbehave at runtime.
17//!
18//! ## Bounded scope (#80)
19//!
20//! - **Lowered op: `error-context.drop`.** This is a pure scalar handle
21//!   operation — it takes one error-context handle (`i32`) and returns nothing,
22//!   touching no linear-memory buffer and never yielding. It marshals like any
23//!   AAPCS C call (handle in `r0`), so the existing field-name `BL` path
24//!   compiles it correctly today. This is the ONE op we can honestly claim.
25//!   The lowering decision is made PER OP, not per family (see
26//!   [`LOWERED_FIELDS`]).
27//! - **Declined (everything else), each with a named [`AsyncDecline`]:**
28//!   `error-context.new` / `.debug-message` (they pass a linmem message
29//!   pointer — the SAME bounds-checked buffer class as stream, NOT scalar),
30//!   `stream` (bounds-checked buffer memory layout — issue §3), `future`
31//!   (readable/writable-end buffer protocol), `waitable`/`task` (register
32//!   save/restore across the `waitable-set.wait` yield — issue §4).
33//!
34//! The intrinsic namespace and per-family field prefixes are the CONTRACT
35//! synth compiles against; they are pinned here (single source) citing
36//! RFC #46 until Meld's lowering fixes the canonical strings cross-repo
37//! (meld#94).
38
39/// The import module namespace P3-async intrinsics are imported under.
40///
41/// Meld lowers P3 async components with imports in this module (RFC #46 async
42/// ABI). Only imports in this namespace are subject to async classification;
43/// every other import is untouched (so non-async modules stay byte-identical).
44pub const ASYNC_MODULE: &str = "pulseengine:async";
45
46/// The async intrinsic families synth distinguishes (#80).
47///
48/// The lowered/declined decision is made PER OP (field name), not per family
49/// — within `error-context` only `.drop` is a scalar handle op; `.new` and
50/// `.debug-message` transfer a message string through linear memory (canonical
51/// ABI) and are declined alongside the buffer families. See [`is_lowered_field`].
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum AsyncFamily {
54    /// `error-context.*` — error-context resource ops. Only `.drop` (scalar
55    /// handle) is lowered; `.new` / `.debug-message` are declined (linmem
56    /// message buffer, same class as stream).
57    ErrorContext,
58    /// `stream.*` — typed stream read/write over linear-memory buffers.
59    /// **Declined** (buffer memory layout + bounds checks unimplemented).
60    Stream,
61    /// `future.*` — single-shot readable/writable ends. **Declined.**
62    Future,
63    /// `waitable-set.*` / `task.*` — scheduler yield points. **Declined**
64    /// (register save/restore across yield unimplemented).
65    WaitableTask,
66}
67
68/// The exact intrinsic FIELDS synth lowers today (#80). Deliberately a single
69/// unambiguously-scalar op: `error-context.drop` takes one error-context handle
70/// (i32) and returns nothing — it touches no linear-memory buffer and does not
71/// yield, so the existing AAPCS field-name BL path compiles it correctly. Every
72/// other async intrinsic (including `error-context.new` /
73/// `error-context.debug-message`, which pass a linmem message pointer) is
74/// declined. Widening this set requires an end-to-end test proving the pointer
75/// arguments lower against the linmem base — a named follow-up.
76pub const LOWERED_FIELDS: &[&str] = &["error-context.drop"];
77
78/// Whether synth currently lowers this exact intrinsic field to native ARM.
79pub fn is_lowered_field(field: &str) -> bool {
80    LOWERED_FIELDS.contains(&field)
81}
82
83impl AsyncFamily {
84    /// Classify an intrinsic field name into its family. Returns `None` for a
85    /// field name that is not a recognized P3-async intrinsic (an unknown
86    /// import in the async namespace — also a decline, see [`classify`]).
87    ///
88    /// Matching is by the `.`-separated resource prefix, matching the RFC #46
89    /// `resource.method` naming (`stream.read`, `error-context.new`, …).
90    pub fn from_field(field: &str) -> Option<Self> {
91        let resource = field.split('.').next().unwrap_or(field);
92        match resource {
93            "error-context" => Some(AsyncFamily::ErrorContext),
94            "stream" => Some(AsyncFamily::Stream),
95            "future" => Some(AsyncFamily::Future),
96            "waitable-set" | "waitable" | "task" => Some(AsyncFamily::WaitableTask),
97            _ => None,
98        }
99    }
100
101    /// A stable machine-readable family tag for diagnostics.
102    pub const fn tag(self) -> &'static str {
103        match self {
104            AsyncFamily::ErrorContext => "error-context",
105            AsyncFamily::Stream => "stream",
106            AsyncFamily::Future => "future",
107            AsyncFamily::WaitableTask => "waitable-task",
108        }
109    }
110}
111
112/// A refused P3-async import — the machine reason it cannot be compiled (#80).
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct AsyncDecline {
115    /// The offending import field (e.g. `stream.read`).
116    pub field: String,
117    /// The classified family, if recognized (`None` = unknown async import).
118    pub family: Option<AsyncFamily>,
119    /// The machine reason string.
120    pub reason: String,
121}
122
123impl core::fmt::Display for AsyncDecline {
124    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
125        write!(f, "#80 async-intrinsic decline: {}", self.reason)
126    }
127}
128
129/// The outcome of classifying a single import against the async contract.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum AsyncClassification {
132    /// Not a P3-async import (wrong module namespace). Untouched by #80.
133    NotAsync,
134    /// A P3-async import in a family synth lowers. The call flows through the
135    /// existing field-name `BL` path unchanged; the carried field is the
136    /// symbol name that path relocates against.
137    Lowered {
138        /// The lowered family (always [`AsyncFamily::ErrorContext`] today).
139        family: AsyncFamily,
140        /// The import field / `BL` target symbol.
141        field: String,
142    },
143    /// A P3-async import synth refuses to compile (a declined family or an
144    /// unknown async intrinsic).
145    Declined(AsyncDecline),
146}
147
148/// Classify one import (`module` / `field`) against the P3-async contract (#80).
149///
150/// - Import module != [`ASYNC_MODULE`] → [`AsyncClassification::NotAsync`]
151///   (byte-invisible: non-async modules are completely unaffected).
152/// - `error-context.*` → [`AsyncClassification::Lowered`].
153/// - a recognized-but-unlowered family (`stream`/`future`/`waitable`/`task`) →
154///   [`AsyncClassification::Declined`] naming the family and the missing
155///   capability.
156/// - an unrecognized field in the async namespace → `Declined` (unknown
157///   intrinsic — refuse rather than blindly `BL` an unspecified contract).
158pub fn classify(module: &str, field: &str) -> AsyncClassification {
159    if module != ASYNC_MODULE {
160        return AsyncClassification::NotAsync;
161    }
162    // Lowering is decided PER OP, not per family: only the exact scalar ops in
163    // LOWERED_FIELDS pass; a recognized family with an unlowered field (e.g.
164    // error-context.new, which carries a linmem message pointer) is declined.
165    if is_lowered_field(field) {
166        let family = AsyncFamily::from_field(field)
167            .expect("a LOWERED_FIELDS entry must classify into a family");
168        return AsyncClassification::Lowered {
169            family,
170            field: field.to_string(),
171        };
172    }
173    match AsyncFamily::from_field(field) {
174        Some(fam) => AsyncClassification::Declined(AsyncDecline {
175            field: field.to_string(),
176            family: Some(fam),
177            reason: decline_reason(fam, field),
178        }),
179        None => AsyncClassification::Declined(AsyncDecline {
180            field: field.to_string(),
181            family: None,
182            reason: format!(
183                "unknown P3-async intrinsic '{ASYNC_MODULE}::{field}' — synth \
184                 will not emit a call against an unspecified async contract \
185                 (RFC #46); recognized families: error-context (lowered), \
186                 stream / future / waitable-set / task (declined)"
187            ),
188        }),
189    }
190}
191
192/// The per-family machine reason for a declined intrinsic.
193fn decline_reason(family: AsyncFamily, field: &str) -> String {
194    let ns = ASYNC_MODULE;
195    match family {
196        AsyncFamily::ErrorContext => format!(
197            "'{ns}::{field}' (error-context family) not compiled: only \
198             error-context.drop (a scalar handle op) is lowered. This op \
199             transfers a message string through linear memory (canonical ABI), \
200             which needs the same bounds-checked linmem-base-relative buffer \
201             lowering as the stream family that synth does not yet generate \
202             (#80). Use error-context.drop, or link this op against a host that \
203             owns the buffer protocol."
204        ),
205        AsyncFamily::Stream => format!(
206            "'{ns}::{field}' (stream family) not compiled: synth does not yet \
207             generate the bounds-checked linear-memory buffer layout the stream \
208             read/write intrinsics require (#80 §3). Lower only error-context.drop \
209             for now, or link the stream intrinsic against a host that owns the \
210             buffer protocol."
211        ),
212        AsyncFamily::Future => format!(
213            "'{ns}::{field}' (future family) not compiled: the readable/writable-\
214             end buffer transfer protocol is unimplemented (#80 §3). Only \
215             error-context.drop is lowered."
216        ),
217        AsyncFamily::WaitableTask => format!(
218            "'{ns}::{field}' (waitable/task family) not compiled: synth does not \
219             yet save/restore register state across the scheduler yield at \
220             waitable-set.wait (#80 §4). Only error-context.drop is lowered."
221        ),
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    /// The lowered op: `error-context.drop` (scalar handle) is recognized and
230    /// passes through to the field-name BL path.
231    #[test]
232    fn error_context_drop_is_lowered() {
233        assert_eq!(
234            classify(ASYNC_MODULE, "error-context.drop"),
235            AsyncClassification::Lowered {
236                family: AsyncFamily::ErrorContext,
237                field: "error-context.drop".to_string(),
238            }
239        );
240        assert!(is_lowered_field("error-context.drop"));
241    }
242
243    /// RED-FIRST soundness (#80): the OTHER error-context ops carry a linmem
244    /// message pointer and are DECLINED with the buffer reason — NOT lowered as
245    /// if scalar (would silently miscompile the pointer arg).
246    #[test]
247    fn error_context_buffer_ops_are_declined() {
248        for field in ["error-context.new", "error-context.debug-message"] {
249            match classify(ASYNC_MODULE, field) {
250                AsyncClassification::Declined(d) => {
251                    assert_eq!(d.family, Some(AsyncFamily::ErrorContext));
252                    assert!(d.reason.contains("buffer"), "{field}: {}", d.reason);
253                    assert!(d.reason.contains("linear memory"), "{field}: {}", d.reason);
254                }
255                other => panic!("{field} must be declined (linmem buffer), got {other:?}"),
256            }
257            assert!(!is_lowered_field(field));
258        }
259    }
260
261    /// RED-FIRST (#80): the stream family is declined BY NAME with the buffer
262    /// reason.
263    #[test]
264    fn stream_is_declined_by_name() {
265        let c = classify(ASYNC_MODULE, "stream.read");
266        match c {
267            AsyncClassification::Declined(d) => {
268                assert_eq!(d.family, Some(AsyncFamily::Stream));
269                assert!(d.reason.contains("stream"));
270                assert!(d.reason.contains("buffer"));
271                assert!(d.to_string().contains("stream.read"));
272            }
273            other => panic!("stream.read should be declined, got {other:?}"),
274        }
275    }
276
277    /// RED-FIRST (#80): future and waitable/task families are declined.
278    #[test]
279    fn future_and_waitable_are_declined() {
280        assert!(matches!(
281            classify(ASYNC_MODULE, "future.read"),
282            AsyncClassification::Declined(d) if d.family == Some(AsyncFamily::Future)
283        ));
284        assert!(matches!(
285            classify(ASYNC_MODULE, "waitable-set.wait"),
286            AsyncClassification::Declined(d) if d.family == Some(AsyncFamily::WaitableTask)
287        ));
288        assert!(matches!(
289            classify(ASYNC_MODULE, "task.return"),
290            AsyncClassification::Declined(d) if d.family == Some(AsyncFamily::WaitableTask)
291        ));
292    }
293
294    /// An unknown intrinsic in the async namespace is declined (not blindly
295    /// lowered).
296    #[test]
297    fn unknown_async_intrinsic_is_declined() {
298        match classify(ASYNC_MODULE, "quantum.entangle") {
299            AsyncClassification::Declined(d) => {
300                assert_eq!(d.family, None);
301                assert!(d.reason.contains("unknown"));
302            }
303            other => panic!("unknown intrinsic should decline, got {other:?}"),
304        }
305    }
306
307    /// Byte-invisibility: a NON-async import is untouched (NotAsync). This is
308    /// what keeps frozen fixtures / ordinary modules bit-identical.
309    #[test]
310    fn non_async_import_is_untouched() {
311        assert_eq!(classify("env", "print_i32"), AsyncClassification::NotAsync);
312        assert_eq!(
313            classify("wasi:cli/stdout", "write"),
314            AsyncClassification::NotAsync
315        );
316        // Same field names under a DIFFERENT module are NOT async either.
317        assert_eq!(
318            classify("env", "stream.read"),
319            AsyncClassification::NotAsync
320        );
321    }
322
323    /// The per-op lowered set is exactly `error-context.drop` and nothing
324    /// else — non-vacuous in both directions.
325    #[test]
326    fn lowered_field_set_is_exact() {
327        assert_eq!(LOWERED_FIELDS, &["error-context.drop"]);
328        assert!(is_lowered_field("error-context.drop"));
329        for f in [
330            "error-context.new",
331            "error-context.debug-message",
332            "stream.read",
333            "future.read",
334            "waitable-set.wait",
335            "task.return",
336        ] {
337            assert!(!is_lowered_field(f), "{f} must not be lowered");
338        }
339    }
340}