Skip to main content

aver/types/checker/
effect_classification.rs

1//! Oracle v1 effect classification.
2//!
3//! For each built-in effect method covered by `aver proof`, this module
4//! records:
5//!
6//! - Which proof dimension(s) it belongs to (snapshot / generative / output,
7//!   and the combination `generative + output` used by e.g. `Http.get`).
8//! - For snapshot and generative, the corresponding capability/oracle
9//!   signature that lifted specs bind via `given name: E.m = [...]`.
10//!
11//! Output-only effects (for example `Console.print`, `Time.sleep`, and
12//! terminal drawing calls) are classified but do not have an oracle signature:
13//! they append to the per-branch trace segment and are asserted about via the
14//! trace API, not by binding an oracle in `given`.
15//!
16//! The table is the single source of truth consumed by:
17//!
18//! - `given`-clause type inference (`given rnd: Random.int` → oracle type
19//!   `(BranchPath, Int, Int, Int) -> Int`).
20//! - Lifting of effectful function bodies at proof-export time.
21//! - Rejection diagnostics for unclassified effects.
22//!
23//! Source of runtime signatures: `src/services/*.rs` and `docs/services.md`.
24//! Keep this table synchronized with the real built-ins.
25
26use super::super::Type;
27use crate::types::branch_path;
28
29/// Proof dimension(s) an effect participates in. `!`-combinations are
30/// modelled directly rather than as flags for readability at call sites.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum EffectDimension {
33    /// Stable within a run. Modelled as a plain reader function.
34    Snapshot,
35    /// Fresh value per call. Modelled as a branch-indexed oracle.
36    Generative,
37    /// Trace-appending side-effect only. No oracle; assertions via trace API.
38    Output,
39    /// Both generative (response value from oracle) and output (request
40    /// emitted to trace). Used by request/operation-style effects such as
41    /// `Http.*`, mutating `Disk.*`, and one-shot `Tcp.*`.
42    GenerativeOutput,
43}
44
45/// Classification of one effect method. `runtime_params` and
46/// `runtime_return` mirror the surface signature at call sites in user
47/// code; oracle signatures are derived from them (see [`oracle_signature`]).
48#[derive(Debug, Clone)]
49pub struct EffectClassification {
50    pub method: &'static str,
51    pub dimension: EffectDimension,
52    pub runtime_params: &'static [RuntimeType],
53    pub runtime_return: RuntimeType,
54}
55
56/// Compact carrier for runtime signature components — kept separate from
57/// the full [`Type`] enum so the static table can live as a const array.
58/// Converted into [`Type`] on demand via [`runtime_type`].
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum RuntimeType {
61    Unknown,
62    Unit,
63    Int,
64    Float,
65    Str,
66    Bool,
67    OptionStr,
68    ListStr,
69    ResultUnitStr,
70    ResultStrStr,
71    ResultListStrStr,
72    HttpResponseResult,
73    /// `List<Header>` — the headers argument on `Http.post/put/patch`.
74    ListHeader,
75    /// `Terminal.Size` record — the return of `Terminal.size`.
76    TerminalSize,
77}
78
79impl RuntimeType {
80    fn as_type(self) -> Type {
81        match self {
82            RuntimeType::Unknown => Type::Unknown,
83            RuntimeType::Unit => Type::Unit,
84            RuntimeType::Int => Type::Int,
85            RuntimeType::Float => Type::Float,
86            RuntimeType::Str => Type::Str,
87            RuntimeType::Bool => Type::Bool,
88            RuntimeType::OptionStr => Type::Option(Box::new(Type::Str)),
89            RuntimeType::ListStr => Type::List(Box::new(Type::Str)),
90            RuntimeType::ResultUnitStr => Type::Result(Box::new(Type::Unit), Box::new(Type::Str)),
91            RuntimeType::ResultStrStr => Type::Result(Box::new(Type::Str), Box::new(Type::Str)),
92            RuntimeType::ResultListStrStr => Type::Result(
93                Box::new(Type::List(Box::new(Type::Str))),
94                Box::new(Type::Str),
95            ),
96            RuntimeType::HttpResponseResult => Type::Result(
97                Box::new(Type::Named("HttpResponse".to_string())),
98                Box::new(Type::Str),
99            ),
100            RuntimeType::ListHeader => Type::List(Box::new(Type::Named("Header".to_string()))),
101            RuntimeType::TerminalSize => Type::Named("Terminal.Size".to_string()),
102        }
103    }
104}
105
106fn runtime_type(rt: RuntimeType) -> Type {
107    rt.as_type()
108}
109
110/// Full classification table. This is the closed set for Oracle v1.
111const CLASSIFICATIONS: &[EffectClassification] = &[
112    // Snapshot
113    EffectClassification {
114        method: "Args.get",
115        dimension: EffectDimension::Snapshot,
116        runtime_params: &[],
117        runtime_return: RuntimeType::ListStr,
118    },
119    EffectClassification {
120        method: "Env.get",
121        dimension: EffectDimension::Snapshot,
122        runtime_params: &[RuntimeType::Str],
123        runtime_return: RuntimeType::OptionStr,
124    },
125    // Terminal.size: stable within a verify scope (a resize while
126    // proving is not modelled). Snapshot-shape oracle: () -> Terminal.Size.
127    EffectClassification {
128        method: "Terminal.size",
129        dimension: EffectDimension::Snapshot,
130        runtime_params: &[],
131        runtime_return: RuntimeType::TerminalSize,
132    },
133    // Generative
134    EffectClassification {
135        method: "Random.int",
136        dimension: EffectDimension::Generative,
137        runtime_params: &[RuntimeType::Int, RuntimeType::Int],
138        runtime_return: RuntimeType::Int,
139    },
140    EffectClassification {
141        method: "Random.float",
142        dimension: EffectDimension::Generative,
143        runtime_params: &[],
144        runtime_return: RuntimeType::Float,
145    },
146    EffectClassification {
147        method: "Time.now",
148        dimension: EffectDimension::Generative,
149        runtime_params: &[],
150        runtime_return: RuntimeType::Str,
151    },
152    EffectClassification {
153        method: "Time.unixMs",
154        dimension: EffectDimension::Generative,
155        runtime_params: &[],
156        runtime_return: RuntimeType::Int,
157    },
158    EffectClassification {
159        method: "Disk.readText",
160        dimension: EffectDimension::Generative,
161        runtime_params: &[RuntimeType::Str],
162        runtime_return: RuntimeType::ResultStrStr,
163    },
164    EffectClassification {
165        method: "Disk.exists",
166        dimension: EffectDimension::Generative,
167        runtime_params: &[RuntimeType::Str],
168        runtime_return: RuntimeType::Bool,
169    },
170    EffectClassification {
171        method: "Disk.listDir",
172        dimension: EffectDimension::Generative,
173        runtime_params: &[RuntimeType::Str],
174        runtime_return: RuntimeType::ResultListStrStr,
175    },
176    EffectClassification {
177        method: "Console.readLine",
178        dimension: EffectDimension::Generative,
179        runtime_params: &[],
180        runtime_return: RuntimeType::ResultStrStr,
181    },
182    // Generative + output (Http)
183    EffectClassification {
184        method: "Http.get",
185        dimension: EffectDimension::GenerativeOutput,
186        runtime_params: &[RuntimeType::Str],
187        runtime_return: RuntimeType::HttpResponseResult,
188    },
189    EffectClassification {
190        method: "Http.head",
191        dimension: EffectDimension::GenerativeOutput,
192        runtime_params: &[RuntimeType::Str],
193        runtime_return: RuntimeType::HttpResponseResult,
194    },
195    EffectClassification {
196        method: "Http.delete",
197        dimension: EffectDimension::GenerativeOutput,
198        runtime_params: &[RuntimeType::Str],
199        runtime_return: RuntimeType::HttpResponseResult,
200    },
201    // Http.post/.put/.patch — four-arg form `(url, body, contentType, headers)`.
202    EffectClassification {
203        method: "Http.post",
204        dimension: EffectDimension::GenerativeOutput,
205        runtime_params: &[
206            RuntimeType::Str,
207            RuntimeType::Str,
208            RuntimeType::Str,
209            RuntimeType::ListHeader,
210        ],
211        runtime_return: RuntimeType::HttpResponseResult,
212    },
213    EffectClassification {
214        method: "Http.put",
215        dimension: EffectDimension::GenerativeOutput,
216        runtime_params: &[
217            RuntimeType::Str,
218            RuntimeType::Str,
219            RuntimeType::Str,
220            RuntimeType::ListHeader,
221        ],
222        runtime_return: RuntimeType::HttpResponseResult,
223    },
224    EffectClassification {
225        method: "Http.patch",
226        dimension: EffectDimension::GenerativeOutput,
227        runtime_params: &[
228            RuntimeType::Str,
229            RuntimeType::Str,
230            RuntimeType::Str,
231            RuntimeType::ListHeader,
232        ],
233        runtime_return: RuntimeType::HttpResponseResult,
234    },
235    // Disk writes/deletes are modelled like HTTP writes: the operation is
236    // emitted to the trace, and success/failure comes from the oracle. Oracle
237    // does not assert persistent filesystem state after the operation.
238    EffectClassification {
239        method: "Disk.writeText",
240        dimension: EffectDimension::GenerativeOutput,
241        runtime_params: &[RuntimeType::Str, RuntimeType::Str],
242        runtime_return: RuntimeType::ResultUnitStr,
243    },
244    EffectClassification {
245        method: "Disk.appendText",
246        dimension: EffectDimension::GenerativeOutput,
247        runtime_params: &[RuntimeType::Str, RuntimeType::Str],
248        runtime_return: RuntimeType::ResultUnitStr,
249    },
250    EffectClassification {
251        method: "Disk.delete",
252        dimension: EffectDimension::GenerativeOutput,
253        runtime_params: &[RuntimeType::Str],
254        runtime_return: RuntimeType::ResultUnitStr,
255    },
256    EffectClassification {
257        method: "Disk.deleteDir",
258        dimension: EffectDimension::GenerativeOutput,
259        runtime_params: &[RuntimeType::Str],
260        runtime_return: RuntimeType::ResultUnitStr,
261    },
262    EffectClassification {
263        method: "Disk.makeDir",
264        dimension: EffectDimension::GenerativeOutput,
265        runtime_params: &[RuntimeType::Str],
266        runtime_return: RuntimeType::ResultUnitStr,
267    },
268    // One-shot TCP operations — request is trace output, response comes from oracle.
269    EffectClassification {
270        method: "Tcp.send",
271        dimension: EffectDimension::GenerativeOutput,
272        runtime_params: &[RuntimeType::Str, RuntimeType::Int, RuntimeType::Str],
273        runtime_return: RuntimeType::ResultStrStr,
274    },
275    EffectClassification {
276        method: "Tcp.ping",
277        dimension: EffectDimension::GenerativeOutput,
278        runtime_params: &[RuntimeType::Str, RuntimeType::Int],
279        runtime_return: RuntimeType::ResultUnitStr,
280    },
281    // Output-only — no oracle signature, but classified for completeness.
282    EffectClassification {
283        method: "Console.print",
284        dimension: EffectDimension::Output,
285        runtime_params: &[RuntimeType::Unknown],
286        runtime_return: RuntimeType::Unit,
287    },
288    EffectClassification {
289        method: "Console.error",
290        dimension: EffectDimension::Output,
291        runtime_params: &[RuntimeType::Unknown],
292        runtime_return: RuntimeType::Unit,
293    },
294    EffectClassification {
295        method: "Console.warn",
296        dimension: EffectDimension::Output,
297        runtime_params: &[RuntimeType::Unknown],
298        runtime_return: RuntimeType::Unit,
299    },
300    EffectClassification {
301        method: "Time.sleep",
302        dimension: EffectDimension::Output,
303        runtime_params: &[RuntimeType::Int],
304        runtime_return: RuntimeType::Unit,
305    },
306    EffectClassification {
307        method: "Terminal.clear",
308        dimension: EffectDimension::Output,
309        runtime_params: &[],
310        runtime_return: RuntimeType::Unit,
311    },
312    EffectClassification {
313        method: "Terminal.moveTo",
314        dimension: EffectDimension::Output,
315        runtime_params: &[RuntimeType::Int, RuntimeType::Int],
316        runtime_return: RuntimeType::Unit,
317    },
318    EffectClassification {
319        method: "Terminal.print",
320        dimension: EffectDimension::Output,
321        runtime_params: &[RuntimeType::Unknown],
322        runtime_return: RuntimeType::Unit,
323    },
324    EffectClassification {
325        method: "Terminal.readKey",
326        dimension: EffectDimension::Generative,
327        runtime_params: &[],
328        runtime_return: RuntimeType::OptionStr,
329    },
330    EffectClassification {
331        method: "Terminal.hideCursor",
332        dimension: EffectDimension::Output,
333        runtime_params: &[],
334        runtime_return: RuntimeType::Unit,
335    },
336    EffectClassification {
337        method: "Terminal.showCursor",
338        dimension: EffectDimension::Output,
339        runtime_params: &[],
340        runtime_return: RuntimeType::Unit,
341    },
342    EffectClassification {
343        method: "Terminal.flush",
344        dimension: EffectDimension::Output,
345        runtime_params: &[],
346        runtime_return: RuntimeType::Unit,
347    },
348];
349
350/// Classify a built-in effect method, if it's in Oracle v1's closed set.
351pub fn classify(method: &str) -> Option<&'static EffectClassification> {
352    CLASSIFICATIONS.iter().find(|c| c.method == method)
353}
354
355/// Closed Oracle v1 proof-subset table, exposed for proof metadata
356/// generation. Callers must treat the returned slice as read-only metadata.
357pub fn classifications_for_proof_subset() -> &'static [EffectClassification] {
358    CLASSIFICATIONS
359}
360
361/// Return `true` if the given name refers to an effect covered by Oracle v1.
362pub fn is_classified(method: &str) -> bool {
363    classify(method).is_some()
364}
365
366/// Oracle signature for use in lifted specs.
367///
368/// - Snapshot: capability reader — unchanged from runtime signature,
369///   wrapped in a function type. `Args.get` → `() -> List<String>`.
370/// - Generative / GenerativeOutput: branch-indexed oracle —
371///   `(BranchPath, Int, <runtime_params...>) -> <runtime_return>`.
372/// - Output: `None` — output effects don't bind oracles (trace API
373///   handles assertions about emissions).
374pub fn oracle_signature(method: &str) -> Option<Type> {
375    let c = classify(method)?;
376    match c.dimension {
377        EffectDimension::Output => None,
378        EffectDimension::Snapshot => {
379            let params: Vec<Type> = c.runtime_params.iter().copied().map(runtime_type).collect();
380            Some(Type::Fn(
381                params,
382                Box::new(runtime_type(c.runtime_return)),
383                vec![],
384            ))
385        }
386        EffectDimension::Generative | EffectDimension::GenerativeOutput => {
387            let mut params = vec![Type::Named(branch_path::TYPE_NAME.to_string()), Type::Int];
388            params.extend(c.runtime_params.iter().copied().map(runtime_type));
389            Some(Type::Fn(
390                params,
391                Box::new(runtime_type(c.runtime_return)),
392                vec![],
393            ))
394        }
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn classify_returns_none_for_unknown() {
404        assert!(classify("Nope.missing").is_none());
405        assert!(classify("Args.set").is_none());
406    }
407
408    #[test]
409    fn args_get_is_snapshot() {
410        let c = classify("Args.get").unwrap();
411        assert_eq!(c.dimension, EffectDimension::Snapshot);
412    }
413
414    #[test]
415    fn random_int_is_generative() {
416        let c = classify("Random.int").unwrap();
417        assert_eq!(c.dimension, EffectDimension::Generative);
418    }
419
420    #[test]
421    fn http_get_is_generative_output() {
422        let c = classify("Http.get").unwrap();
423        assert_eq!(c.dimension, EffectDimension::GenerativeOutput);
424    }
425
426    #[test]
427    fn disk_write_text_is_generative_output() {
428        let c = classify("Disk.writeText").unwrap();
429        assert_eq!(c.dimension, EffectDimension::GenerativeOutput);
430    }
431
432    #[test]
433    fn console_print_is_output() {
434        let c = classify("Console.print").unwrap();
435        assert_eq!(c.dimension, EffectDimension::Output);
436    }
437
438    #[test]
439    fn console_read_line_is_generative() {
440        let c = classify("Console.readLine").unwrap();
441        assert_eq!(c.dimension, EffectDimension::Generative);
442    }
443
444    #[test]
445    fn time_sleep_is_output() {
446        let c = classify("Time.sleep").unwrap();
447        assert_eq!(c.dimension, EffectDimension::Output);
448    }
449
450    #[test]
451    fn terminal_read_key_is_generative() {
452        let c = classify("Terminal.readKey").unwrap();
453        assert_eq!(c.dimension, EffectDimension::Generative);
454    }
455
456    #[test]
457    fn oracle_signature_for_random_int_is_branch_indexed() {
458        let sig = oracle_signature("Random.int").unwrap();
459        // (BranchPath, Int, Int, Int) -> Int
460        match sig {
461            Type::Fn(params, ret, _) => {
462                assert_eq!(params.len(), 4);
463                assert!(matches!(params[0], Type::Named(ref n) if n == "BranchPath"));
464                assert_eq!(params[1], Type::Int);
465                assert_eq!(params[2], Type::Int);
466                assert_eq!(params[3], Type::Int);
467                assert_eq!(*ret, Type::Int);
468            }
469            other => panic!("expected Fn, got {:?}", other),
470        }
471    }
472
473    #[test]
474    fn oracle_signature_for_random_float_is_branch_indexed_no_extra_args() {
475        let sig = oracle_signature("Random.float").unwrap();
476        // (BranchPath, Int) -> Float
477        match sig {
478            Type::Fn(params, ret, _) => {
479                assert_eq!(params.len(), 2);
480                assert!(matches!(params[0], Type::Named(ref n) if n == "BranchPath"));
481                assert_eq!(params[1], Type::Int);
482                assert_eq!(*ret, Type::Float);
483            }
484            other => panic!("expected Fn, got {:?}", other),
485        }
486    }
487
488    #[test]
489    fn oracle_signature_for_args_get_is_capability_reader() {
490        let sig = oracle_signature("Args.get").unwrap();
491        // () -> List<String>   (snapshot: not branch-indexed)
492        match sig {
493            Type::Fn(params, ret, _) => {
494                assert!(params.is_empty());
495                assert_eq!(*ret, Type::List(Box::new(Type::Str)));
496            }
497            other => panic!("expected Fn, got {:?}", other),
498        }
499    }
500
501    #[test]
502    fn oracle_signature_for_env_get_is_capability_reader() {
503        let sig = oracle_signature("Env.get").unwrap();
504        // String -> Option<String>
505        match sig {
506            Type::Fn(params, ret, _) => {
507                assert_eq!(params, vec![Type::Str]);
508                assert_eq!(*ret, Type::Option(Box::new(Type::Str)));
509            }
510            other => panic!("expected Fn, got {:?}", other),
511        }
512    }
513
514    #[test]
515    fn oracle_signature_for_http_get_is_branch_indexed() {
516        let sig = oracle_signature("Http.get").unwrap();
517        // (BranchPath, Int, String) -> Result<HttpResponse, String>
518        match sig {
519            Type::Fn(params, ret, _) => {
520                assert_eq!(params.len(), 3);
521                assert!(matches!(params[0], Type::Named(ref n) if n == "BranchPath"));
522                assert_eq!(params[1], Type::Int);
523                assert_eq!(params[2], Type::Str);
524                match *ret {
525                    Type::Result(ok, err) => {
526                        assert!(matches!(*ok, Type::Named(ref n) if n == "HttpResponse"));
527                        assert_eq!(*err, Type::Str);
528                    }
529                    other => panic!("expected Result, got {:?}", other),
530                }
531            }
532            other => panic!("expected Fn, got {:?}", other),
533        }
534    }
535
536    #[test]
537    fn oracle_signature_for_console_read_line_is_branch_indexed() {
538        let sig = oracle_signature("Console.readLine").unwrap();
539        // (BranchPath, Int) -> Result<String, String>
540        match sig {
541            Type::Fn(params, ret, _) => {
542                assert_eq!(params.len(), 2);
543                assert!(matches!(params[0], Type::Named(ref n) if n == "BranchPath"));
544                assert_eq!(params[1], Type::Int);
545                assert_eq!(*ret, Type::Result(Box::new(Type::Str), Box::new(Type::Str)));
546            }
547            other => panic!("expected Fn, got {:?}", other),
548        }
549    }
550
551    #[test]
552    fn oracle_signature_for_disk_list_dir_returns_result_list_string() {
553        let sig = oracle_signature("Disk.listDir").unwrap();
554        // (BranchPath, Int, String) -> Result<List<String>, String>
555        match sig {
556            Type::Fn(params, ret, _) => {
557                assert_eq!(params.len(), 3);
558                assert!(matches!(params[0], Type::Named(ref n) if n == "BranchPath"));
559                assert_eq!(params[1], Type::Int);
560                assert_eq!(params[2], Type::Str);
561                assert_eq!(
562                    *ret,
563                    Type::Result(
564                        Box::new(Type::List(Box::new(Type::Str))),
565                        Box::new(Type::Str)
566                    )
567                );
568            }
569            other => panic!("expected Fn, got {:?}", other),
570        }
571    }
572
573    #[test]
574    fn oracle_signature_for_tcp_ping_returns_result_unit_string() {
575        let sig = oracle_signature("Tcp.ping").unwrap();
576        // (BranchPath, Int, String, Int) -> Result<Unit, String>
577        match sig {
578            Type::Fn(params, ret, _) => {
579                assert_eq!(params.len(), 4);
580                assert!(matches!(params[0], Type::Named(ref n) if n == "BranchPath"));
581                assert_eq!(params[1], Type::Int);
582                assert_eq!(params[2], Type::Str);
583                assert_eq!(params[3], Type::Int);
584                assert_eq!(
585                    *ret,
586                    Type::Result(Box::new(Type::Unit), Box::new(Type::Str))
587                );
588            }
589            other => panic!("expected Fn, got {:?}", other),
590        }
591    }
592
593    #[test]
594    fn oracle_signature_for_disk_write_text_returns_result_unit_string() {
595        let sig = oracle_signature("Disk.writeText").unwrap();
596        // (BranchPath, Int, String, String) -> Result<Unit, String>
597        match sig {
598            Type::Fn(params, ret, _) => {
599                assert_eq!(params.len(), 4);
600                assert!(matches!(params[0], Type::Named(ref n) if n == "BranchPath"));
601                assert_eq!(params[1], Type::Int);
602                assert_eq!(params[2], Type::Str);
603                assert_eq!(params[3], Type::Str);
604                assert_eq!(
605                    *ret,
606                    Type::Result(Box::new(Type::Unit), Box::new(Type::Str))
607                );
608            }
609            other => panic!("expected Fn, got {:?}", other),
610        }
611    }
612
613    #[test]
614    fn oracle_signature_for_output_effect_is_none() {
615        assert!(oracle_signature("Console.print").is_none());
616        assert!(oracle_signature("Console.error").is_none());
617        assert!(oracle_signature("Console.warn").is_none());
618        assert!(oracle_signature("Time.sleep").is_none());
619        assert!(oracle_signature("Terminal.print").is_none());
620    }
621
622    #[test]
623    fn is_classified_covers_full_v1_set() {
624        for name in &[
625            "Args.get",
626            "Env.get",
627            "Random.int",
628            "Random.float",
629            "Time.now",
630            "Time.unixMs",
631            "Time.sleep",
632            "Disk.readText",
633            "Disk.exists",
634            "Disk.listDir",
635            "Disk.writeText",
636            "Disk.appendText",
637            "Disk.delete",
638            "Disk.deleteDir",
639            "Disk.makeDir",
640            "Console.readLine",
641            "Http.get",
642            "Http.head",
643            "Http.delete",
644            "Http.post",
645            "Http.put",
646            "Http.patch",
647            "Tcp.send",
648            "Tcp.ping",
649            "Console.print",
650            "Console.error",
651            "Console.warn",
652            "Terminal.clear",
653            "Terminal.moveTo",
654            "Terminal.print",
655            "Terminal.readKey",
656            "Terminal.hideCursor",
657            "Terminal.showCursor",
658            "Terminal.flush",
659        ] {
660            assert!(is_classified(name), "{} should be classified", name);
661        }
662    }
663
664    #[test]
665    fn oracle_signature_for_http_post_has_four_runtime_params() {
666        let sig = oracle_signature("Http.post").unwrap();
667        // (BranchPath, Int, Str, Str, Str, List<Header>) -> Result<HttpResponse, String>
668        match sig {
669            Type::Fn(params, ret, _) => {
670                assert_eq!(params.len(), 6);
671                assert!(matches!(params[0], Type::Named(ref n) if n == "BranchPath"));
672                assert_eq!(params[1], Type::Int);
673                assert_eq!(params[2], Type::Str);
674                assert_eq!(params[3], Type::Str);
675                assert_eq!(params[4], Type::Str);
676                match &params[5] {
677                    Type::List(inner) => {
678                        assert!(matches!(&**inner, Type::Named(n) if n == "Header"));
679                    }
680                    other => panic!("expected List<Header>, got {:?}", other),
681                }
682                match *ret {
683                    Type::Result(ok, err) => {
684                        assert!(matches!(*ok, Type::Named(ref n) if n == "HttpResponse"));
685                        assert_eq!(*err, Type::Str);
686                    }
687                    other => panic!("expected Result, got {:?}", other),
688                }
689            }
690            other => panic!("expected Fn, got {:?}", other),
691        }
692    }
693
694    #[test]
695    fn ambient_protocol_and_modal_effects_not_classified() {
696        // These remain replay-only in v1.
697        for name in &[
698            "Env.set",
699            "Tcp.connect",
700            "Tcp.writeLine",
701            "Tcp.readLine",
702            "Tcp.close",
703            "HttpServer.listen",
704            "Terminal.enableRawMode",
705            "Terminal.disableRawMode",
706            "Terminal.setColor",
707            "Terminal.resetColor",
708        ] {
709            assert!(!is_classified(name), "{} should NOT be classified", name);
710        }
711    }
712
713    #[test]
714    fn terminal_size_is_snapshot() {
715        let c = classify("Terminal.size").expect("Terminal.size should be classified");
716        assert_eq!(c.dimension, EffectDimension::Snapshot);
717        assert!(c.runtime_params.is_empty());
718        assert_eq!(c.runtime_return, RuntimeType::TerminalSize);
719
720        // Oracle signature: () -> Terminal.Size
721        let sig = oracle_signature("Terminal.size").unwrap();
722        match sig {
723            Type::Fn(params, ret, effects) => {
724                assert!(params.is_empty());
725                assert_eq!(*ret, Type::Named("Terminal.Size".to_string()));
726                assert!(effects.is_empty());
727            }
728            other => panic!("expected Fn, got {:?}", other),
729        }
730    }
731}