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}