marianoguerra/pure-py/eval does not have a README file

    Dispatch

    What a match statement does with a value.

    Host

    pub struct Host {
    write : (String) -> Unit
    argv : Array[String]
    modules : Map[String,
    HashMap
    [String,
    Value
    ]]
    call : async (String, Array[
    Value
    ]) ->
    Outcome
    noraise
    redefined : Map[String,
    Value
    ]
    }

    What the host supplies: everything about a PurePy run that is not pure.

    PurePy is a pure language, but a RUN is not a pure thing: it prints, it reads its arguments, it loads modules, and -- when it is embedded -- it calls out to whatever the application wants to expose. Those four are the whole of it, and they are all here, so an embedder can see the surface at once instead of discovering it a call at a time.

    • Output. write receives what print produced, one call per print, while the run is still going. A host that streams to a terminal writes it out; a host that wants a transcript accumulates.
    • Arguments. argv is what sys.argv reads. argv[0] is the program's own name, as in Python.
    • Modules. modules are importable modules the host defines, beyond the five the specification predefines. Their members are values, and a member may be a function the host implements.
    • Foreign calls. call answers a call to one of those functions. It is given the name the host registered and the arguments the guest passed, and returns an Outcome: a value, an abort, or -- for anything it does not recognise or cannot do -- an operation the semantics does not cover.

      It is async, so it may also answer LATER: a host that has to read a socket, await a promise or ask a person suspends, and the whole run parks where it stood -- mid-expression, inside a call, anywhere -- and resumes on the value the host eventually supplies. Nothing about the guest changes: PurePy has no await, and a program cannot tell a call that answered late from one that answered at once. See run_with for how a synchronous embedder drives a run that can park.

    • Redefinitions. redefined replaces members of the modules the SPECIFICATION predefines -- builtins.print, sys.exit, math.sqrt -- with values of the host's own. A redefined print is an ordinary host function: it is handed the arguments the guest passed, as VALUES rather than as the text write would have received, and it answers like any other.

      It is the one part of a host that changes what a program MEANS, and so it is the one part that costs something: a run that redefines a builtin is not a run CPython is the oracle for. Everything else here leaves the language exactly as the specification has it.

    Everything else about a run is already a value: the program is a SourceTree, so a host that keeps its guest code in a database, a zip file or a string never touches the filesystem.

    Note what is NOT here. There is no way for the host to mutate a guest value, because there is no mutation; no way to install a callback the guest invokes implicitly, because there are no hooks -- a redefinition is not one, any more than store.get is, since the guest calls a NAME and gets whatever the host bound to it; and no ambient authority at all -- a guest can reach exactly the modules the host handed it. A host that gives no modules gives a program that can only compute and print.

    Suspension adds nothing to that list. A parked run is not a concurrent one: there is one guest, it is at exactly one point, and the host holds the only continuation. Resuming it twice is the host resuming it twice, which is the host's bug and not an escape from the semantics.

    Host::member_names

    fn Host::member_names(self : Host) -> Map[String, Array[String]]

    The names of each host module's members.

    The CHECKER needs these and not the values: a from hostmod import f has to type-check before it can run, and the checker has no business knowing what f is. This is the one place the two sides of a host module meet.

    Host::module_names

    fn Host::module_names(self : Host) -> Array[String]

    The modules a program may import that have no source: the five the specification predefines, and the host's own.

    Host::new

    fn Host::new(write? : (String) -> Unit, argv? : Array[String], modules? : Array[HostModule], call? : async (String, Array[
    Value
    ]) ->
    Outcome
    noraise, redefined? : Array[(String,
    Value
    )]) -> Host

    A host.

    Every part has a default that does nothing observable: output is dropped, sys.argv is empty, no modules are defined, nothing is redefined, and a foreign call is an operation the semantics does not cover. A host supplies the parts it wants.

    redefined is given as ("builtins.print", value) pairs: a predefined module's name, a dot, and the member within it. A key naming nothing is silently no redefinition at all, which is what unknown_redefinitions is for.

    A call that answers on the spot is written exactly as it was before it could do otherwise: a plain function is a valid async one.

    Host::unknown_redefinitions

    fn Host::unknown_redefinitions(self : Host, profile? :
    Profile
    ) -> Array[String]

    The redefinitions that name nothing.

    A redefinition is matched by NAME, so a key nobody has -- builtins.pirnt, or a module that is the host's own rather than the specification's -- lands in no environment and the run proceeds as though the host had said nothing at all. That is a silence, and this is how a host breaks it: assert this is empty, once, beside the run.

    The profile has to be the one the run will use, because a profile decides which names builtins has -- builtins.sorted is nothing under @profile.core and a real member under a profile that asked for it.

    HostModule

    pub(all) struct HostModule {
    name : String
    members : Array[(String,
    Value
    )]
    }

    A module the host defines: a name the guest can import, and its members.

    A member is any Value. @value.host_fn("name") makes one the guest can call and the host answers by name.

    Interp

    pub struct Interp {
    tree :
    SourceTree

    host : Host
    loaded : Map[String,
    HashMap
    [String,
    Value
    ]]
    loading : Array[String]
    depth : Int
    max_depth : Int
    fuel : Int
    max_steps : Int
    profile :
    Profile

    in_module : String
    abort_at : Site?
    }

    Interp::apply

    eval-call-lambda, eval-call-def, eval-call-prim and their arity rules.

    An entry into the machine, so that an embedder calling a guest function gets the same unbounded recursion a guest call inside the machine gets. The depth this call takes is counted there, by the call boundary it pushes.

    Interp::dispatch

    dispatch(ρ, v, p⃗, s⃗): the first case whose pattern matches, with its bindings; no bindings and pass if none does.

    Interp::eval_expr

    Expression evaluation: Figures 4.5 to 4.10.

    Every rule that needs a condition -- if, and, or, a conditional expression, assert -- requires an actual True or False. Python's truthiness is not PurePy's, so if 5: has no rule and the run is undefined. That is one of the conformance suite's dynamically excluded tests, and it is the reason Stuck is threaded everywhere.

    Where an abort happened is recorded by sited, at each of the six places an expression can produce one. Not by wrapping this function: it recurses and it is async, so a step after the recursive call is a continuation allocated per expression rather than a tail call, and that measured 21% on a program that does nothing but call.

    Interp::eval_seq

    Statement evaluation: Figures 4.3 and 4.4.

    A sequence's result is the bindings the SEQUENCE made, not the whole environment: eval-seq composes assigns ρ' with what follows, and the caller overrides its own environment with the result. That is what keeps a function body's locals out of its caller.

    Interp::load

    Module and program evaluation: Figures 4.11 to 4.13.

    Loading mirrors the static rules: the import prefix first, then the body, under builtins. An import loads the ancestors of its target from the root down, each before its descendant.

    The one departure is the cache, and it is about OUTPUT rather than values. The spec says loading is deterministic and needs no cache, which is true of what a module loads TO; it is not true of what loading PRINTS. Two imports of a module that prints would print twice, and CPython prints once. The suite has a test for it.

    Interp::match_pattern

    Pattern matching: Figure 4.2.

    The rules are narrower than "anything else fails", and the narrowness is deliberate. eval-pat-list-no fires when the value is NEITHER a list nor a tuple, or is a list of the wrong length -- so a list pattern against a TUPLE is covered by no rule at all and the run is undefined, while a tuple pattern against an integer is an honest no-match. A literal pattern against a list reaches eq between unrelated kinds and is undefined too. The conformance suite has a test for each of the three.

    Interp::new

    fn Interp::new(tree :
    SourceTree
    , host? : Host, max_depth? : Int, profile? :
    Profile
    , max_steps? : Int) -> Interp

    Interp::predefined

    The environment a predefined module loads to, or None if q is not one.

    typing exposes Any alone. Figure 2.7 also lists Callable; the reference checker does not, and the signature and the environment have to agree or a from-import would type-check and then fail to run.

    The host's redefinitions are laid over the result and not mixed into it, for which see Interp::redefine.

    Interp::resolve_class

    resolve-class(ρ, q): the class a qualified name stands for.

    Interp::run

    async fn Interp::run(self : Interp) -> RunResult noraise

    ⇒ κ (Figure 4.13): evaluate a program by loading its main module.

    RunResult

    pub(all) enum RunResult {
    Finished
    Terminated(
    Termination
    , Site?)
    Undefined(String)
    Suspended
    } derive(
    Debug
    )

    How a run ended, or that it has not ended yet.

    Sink

    pub struct Sink {
    buf : StringBuilder
    }

    Somewhere to put output when the caller just wants the transcript.

    run_program uses one of these; run_with does not, because a host that supplies its own write has somewhere better to put it.

    Sink::new

    fn Sink::new() -> Sink

    Sink::text

    fn Sink::text(self : Sink) -> String

    Sink::write

    fn Sink::write(self : Sink, text : String) -> Unit

    Site

    pub(all) struct Site {
    in_module : String
    span :
    Span

    } derive(Eq,
    Debug
    )

    Where an abort happened: the module, and the span within it.

    The specification says an implementation "must agree on which kind a run yields, though how it reports one is not prescribed" (operational-semantics.tex). This is reporting, then, and not semantics: a Site never changes which Termination a run yields, and two runs that abort at different places with the same kind still agree.

    Site::to_display

    fn Site::to_display(self : Site) -> String

    module:line:col, the site as a person reads it.

    default_max_depth

    let default_max_depth : Int

    How deep a PurePy call stack may go by default.

    PurePy has no loops, so recursion is the only iteration a guest has, and this is the bound on it. Past it a run ends undefined -- a call stackdeeper than 10000 -- which is an answer a person can read, a page can render and a program can be rewritten around.

    One number, on every backend. It used to be two, 500 on a native thread and 12 on the three that run on a JavaScript engine, because the evaluator recursed and the real limit was the HOST's stack: a JavaScript engine holds two orders of magnitude fewer frames than a native thread, and overflowing one throws a RangeError no MoonBit code can catch, taking a browser tab with it. That number could not be chosen, only measured -- and re-measured whenever the evaluator changed shape, since a frame is sized by the whole function.

    The evaluator keeps its continuation on the heap now (machine.mbt), so the host's stack does not grow with the guest's recursion and there is nothing left for the backend to decide. Ten thousand is a policy: deep enough that no reasonable program meets it, shallow enough that a runaway stops in an array of ten thousand frames rather than in an out-of-memory. A caller who wants a different one passes max_depth.

    default_max_steps

    let default_max_steps : Int

    How many steps a run may take by default.

    A step is one move of the machine in machine.mbt: evaluate a sub-expression, hand a value to a frame, enter a call, bind a statement. max_depth bounds how DEEP a guest may go and this bounds how LONG it may take, which are different runaways and only one of them used to have an answer.

    PurePy has no loops, so for a long time the only way to run forever was to recurse, and max_depth caught that. It is not the only way: a comprehension over a long sequence loops without recursing, and used to run to completion however long that took -- which on a browser tab is a page that stops answering, with no message and nothing to interrupt it.

    A hundred million is a policy, like max_depth's ten thousand: far more than any program a person is waiting on, few enough that a runaway ends in an answer rather than in a hang. A host that knows better passes max_steps -- a notebook cell wants a much smaller one, and a batch job on a native thread may want none of it.

    It is DETERMINISTIC, which a wall-clock timeout would not be. Two runs of the same program over the same host answers take the same number of steps and stop in the same place, so a guest that hits the limit hits it reproducibly and a test can pin it.

    region_bindings

    {f⃗: v⃗}: every definition of a region, as closures over the same environment.

    in_module is the module the region was written in, which the closures carry so that an abort inside one is reported against the right file.

    run_program

    fn run_program(tree :
    SourceTree
    , argv? : Array[String], max_depth? : Int, profile? :
    Profile
    , max_steps? : Int) -> (String, RunResult)

    Evaluate a program and answer with its transcript and how it ended.

    The simple path: output is collected and handed back. A caller that wants output as it happens, its own modules, or its own functions supplies a Host and uses run_with.

    The host this builds answers no foreign call, so this run cannot suspend and the result is never Suspended. The transcript is complete.

    run_with

    fn run_with(tree :
    SourceTree
    , host : Host, max_depth? : Int, done? : (RunResult) -> Unit, profile? :
    Profile
    , max_steps? : Int) -> RunResult

    Evaluate a program against a host, from a synchronous caller.

    Output goes wherever the host sends it, so nothing is returned but how the run ended.

    A host call may suspend -- park the run and answer later -- and a synchronous caller cannot wait for it. So this returns when the run ends OR when it suspends, whichever comes first, and there are two ways to learn the answer:

    • The return value is the answer when the run ended here, and Suspended when it did not.
    • done is called exactly once with the answer whenever the run really ends: before this returns when nothing suspended, and from inside the host's own continuation when something did.

    A host that always answers immediately can ignore done and read the return value, which is what every caller before suspension existed did. A host that suspends should pass done, because the value returned here is not the answer.

    An embedder that is itself asynchronous does not need any of this: Interp::run is an async function and can simply be awaited.