Files
tricu/notes/iodriver-updates.md
James Eversole 8f7684a1bb CPS IO -> Async Interaction Tree Effect Runtime
I'm deeply satisfied to be building an interaction tree runtime where
the interaction trees are themselves computed via and represented by
trees. It's trees all the way down.
2026-05-13 16:33:30 -05:00

14 KiB

Below is the implementation handoff for replacing the current recursive/rebuilding IO small-step interpreter with an explicit machine stack, primarily to support Reader via ask and local, while setting up the right shape for eventual async.

Goal

Refactor IODriver from this model:

stepIO :: IOPermissions -> T -> IO Step

data Step
  = Done T
  | Continue T

to an explicit abstract machine:

Machine = Runtime + current action + continuation frames

This is required because local is dynamically scoped. It needs to modify the Reader environment for a sub-computation, then restore the previous environment exactly when that sub-computation completes. The current “rebuild Bind left' k” approach has nowhere to store that restoration behavior.

This change should support:

ask
local

now, and keep the structure compatible with future async suspension/resumption.

Do not implement async in this pass.


New action tags

Extend the tricu IO action language with Reader tags:

ask   = _        : pair 6 t
local = f action : pair 7 (pair f action)

Host-side:

data Action
  = APure T
  | ABind T T
  | APutStr T
  | AGetLine
  | AReadFile T
  | AWriteFile T T
  | AAsk
  | ALocal T T
  deriving (Show)

Recommended tag allocation:

0 = pure
1 = bind
2 = putStr
3 = getLine
4 = readFile
5 = writeFile
6 = ask
7 = local

State tags can come later:

8 = get
9 = put

Do not add bindR, bindS, or bindRS yet. Reader is being added as an effect inside the existing IO action language, so the existing IO bind remains the only sequencing operator.


New runtime model

Add a runtime context:

data Runtime = Runtime
  { rtPerms :: IOPermissions
  , rtEnv   :: T
  }
  deriving (Show)

Later this can become:

data Runtime = Runtime
  { rtPerms :: IOPermissions
  , rtEnv   :: T
  , rtState :: T
  }

but for this pass, keep it minimal unless State is implemented at the same time.

Add continuation frames:

data Frame
  = BindFrame T
  | LocalFrame T
  deriving (Show)

Frame meanings:

BindFrame k:
  When the current action produces value x, continue with apply k x.

LocalFrame oldEnv:
  When the current action produces value x, restore oldEnv, then continue with x.

Add the machine state:

data Machine = Machine
  { machineRuntime :: Runtime
  , machineCurrent :: T
  , machineFrames  :: [Frame]
  }
  deriving (Show)

Frames should be treated as a stack, with the head as the top:

push frame machine = machine { machineFrames = frame : machineFrames machine }

New step result

Replace the current Step with machine-oriented stepping:

data Step
  = Halt Runtime T
  | Continue Machine
  deriving (Show)

Halt runtime value means the entire IO program is done.

Continue machine means the machine can take another step.


Core stepping semantics

The central function should become:

stepMachine :: Machine -> IO Step

It should decode machineCurrent.

pure

When the current action is APure value, do not immediately halt. First inspect the frame stack.

Pseudo-code:

finishValue :: Machine -> T -> IO Step
finishValue machine value =
  case machineFrames machine of
    [] ->
      pure (Halt (machineRuntime machine) value)

    BindFrame k : rest ->
      pure (Continue machine
        { machineCurrent = apply k value
        , machineFrames = rest
        })

    LocalFrame oldEnv : rest ->
      let runtime' = (machineRuntime machine) { rtEnv = oldEnv }
      in pure (Continue machine
        { machineRuntime = runtime'
        , machineCurrent = pureAction value
        , machineFrames = rest
        })

You will need a helper:

pureAction :: T -> T
pureAction x = Fork (ofNumber 0) x

This is important: restoring a LocalFrame should not discard the value. It restores the environment and re-enters the machine as pure value, allowing the next frame to receive the value.

bind

For:

ABind left k

do not recursively step left, and do not rebuild Bind left' k.

Instead:

Continue machine
  { machineCurrent = left
  , machineFrames  = BindFrame k : machineFrames machine
  }

This is the major refactor. Continuations move out of the tree and into the frame stack.

ask

For:

AAsk

produce the current Reader environment:

finishValue machine (rtEnv (machineRuntime machine))

or equivalently:

Continue machine { machineCurrent = pureAction currentEnv }

Prefer finishValue because it avoids an extra step.

local

For:

ALocal f action

do:

let runtime = machineRuntime machine
    oldEnv  = rtEnv runtime
    newEnv  = apply f oldEnv
    runtime' = runtime { rtEnv = newEnv }

Continue machine
  { machineRuntime = runtime'
  , machineCurrent = action
  , machineFrames  = LocalFrame oldEnv : machineFrames machine
  }

This is the central correctness point.

local enters a scoped environment by pushing a restoration frame. When the scoped action finishes, LocalFrame oldEnv restores the previous environment and passes the produced value onward.

Nested local works naturally because frames stack:

local f (
  local g ask
)

becomes:

push LocalFrame env0
set env = f env0

push LocalFrame env1
set env = g env1

ask returns env2

pop LocalFrame env1
restore env1

pop LocalFrame env0
restore env0

Normal IO actions

For host IO actions, perform the side effect and then call finishValue.

Examples:

APutStr str ->
  case decodeString str "PutStr" of
    Right s -> do
      putStr s
      finishValue machine Leaf
    Left _ ->
      finishValue machine (errResult 6)
AReadFile path ->
  case decodeString path "ReadFile" of
    Right p -> do
      result <- ...
      finishValue machine result
    Left _ ->
      finishValue machine (errResult 6)

Important: IO actions should no longer return Done value directly. They should return a value to the frame stack via finishValue.


Decode changes

Extend decodeAction:

decodeAction :: T -> Either String Action
decodeAction tree =
  case tree of
    Fork tag payload ->
      case toNumber tag of
        Right 0 -> Right (APure payload)

        Right 1 -> case payload of
          Fork left k -> Right (ABind left k)
          _ -> Left "Invalid Bind: expected pair action continuation"

        Right 2 -> Right (APutStr payload)

        Right 3 -> Right AGetLine

        Right 4 -> Right (AReadFile payload)

        Right 5 -> case payload of
          Fork path contents -> Right (AWriteFile path contents)
          _ -> Left "Invalid WriteFile: expected pair path contents"

        Right 6 -> Right AAsk

        Right 7 -> case payload of
          Fork f action -> Right (ALocal f action)
          _ -> Left "Invalid Local: expected pair function action"

        Right n -> Left $ "Unknown IO action tag: " ++ show n

        Left err -> Left $ "Invalid action tag: " ++ err

    _ ->
      Left $ "Invalid action tree: expected pair tag payload, got " ++ show tree

Runner API

Add a new Reader-aware runner:

runIOWithEnv :: IOPermissions -> T -> T -> IO T
runIOWithEnv perms env action = loop initialMachine
  where
    initialMachine = Machine
      { machineRuntime = Runtime
          { rtPerms = perms
          , rtEnv = env
          }
      , machineCurrent = action
      , machineFrames = []
      }

    loop machine = do
      step <- stepMachine machine
      case step of
        Halt _ value       -> pure value
        Continue machine'  -> loop machine'

Keep the existing API as a compatibility wrapper:

runIO :: IOPermissions -> T -> IO T
runIO perms action =
  runIOWithEnv perms Leaf action

If State is added in the same branch, prefer:

runIOWith :: IOPermissions -> T -> T -> T -> IO (T, T)

where:

permissions
initial reader env
initial state
action

returns:

final result
final state

But if this handoff is only for Reader, use runIOWithEnv.


Permission helpers

The current permission helper functions can mostly stay as-is, but they should read permissions from runtime:

let perms = rtPerms (machineRuntime machine)

The current helpers are nested inside stepIO. After the refactor, either:

  1. keep them in a where block under stepMachine, or
  2. lift them to top-level helper functions.

Prefer lifting pure/reusable helpers to top-level if this file is getting large:

decodeString :: T -> String -> Either String String
canonicalizeSafe :: FilePath -> IO (Either String FilePath)
pathAllowed :: FilePath -> [FilePath] -> IO Bool
tryReadFile :: FilePath -> IO T
tryWriteFile :: FilePath -> String -> IO T
okResult :: T -> T
errResult :: Integer -> T
ioErrorCode :: IOException -> Integer

This will make stepMachine much easier to read.


io.tri changes

Add the Reader constructors:

ask   = _        : pair 6 t
local = f action : pair 7 (pair f action)

No new bind is required.

Example usage:

program =
  bind ask (env :
    putStrLn env)

Example local usage:

program =
  bind ask (outer :
  bind (local (env : append env "-inner")
        (bind ask (inner :
          pure inner)))
    (result :
      bind ask (after :
        pure result)))

Expected behavior:

outer ask sees original env
inner ask sees transformed env
after ask sees original env again

Tests to add

Add tests around behavior, not implementation details.

1. ask returns initial environment

Program:

io (bind ask (env : pure env))

Run with env:

"dev"

Expected result:

"dev"

2. local transforms environment

Program:

io (
  local (env : append env "-local")
    (bind ask (env : pure env))
)

Initial env:

"root"

Expected result:

"root-local"

3. local restores environment afterward

Program structure:

bind ask (before :
bind (local transform scopedAsk) (inside :
bind ask (after :
pure (pair before (pair inside after)))))

Initial env:

"root"

Expected:

pair "root" (pair "root-local" "root")

4. nested local composes correctly

Program:

local f (
  local g ask
)

Initial env:

"root"

Example:

f = x : append x "-f"
g = x : append x "-g"

Expected inner ask:

"root-f-g"

Also verify after both locals, environment is restored by doing a final ask.

5. local result passes through bind correctly

Program:

bind
  (local transform (pure "value"))
  (x : pure x)

Expected:

"value"

This catches a common bug where LocalFrame restores env but loses the value.

6. IO still works through bind

Existing IO tests should continue passing unchanged through runIO.

7. IO inside local

Program:

local transform (
  bind ask (env :
  bind (putStrLn env) (_ :
  pure env))
)

Expected:

prints transformed env
returns transformed env

Then optionally ask after local to verify restoration.


Invariants to preserve

The implementation should maintain these invariants:

1. The current action is always the next instruction to evaluate.

2. The frame stack contains all pending continuations and cleanup scopes.

3. Bind does not recursively step its left side.
   It pushes BindFrame and switches current to the left action.

4. local does not run its action to completion.
   It pushes LocalFrame and switches current to the scoped action.

5. Only LocalFrame restores Reader environment.

6. State, when added later, should not be restored by LocalFrame.

7. Existing runIO behavior remains source-compatible.

Common failure modes

The likely bugs are:

Bug: local leaks environment.
Cause: setting rtEnv but never restoring it.
Fix: push LocalFrame oldEnv and restore in finishValue.

Bug: local restores environment but loses result.
Cause: popping LocalFrame and halting directly.
Fix: after restoration, continue with pureAction value.

Bug: bind continuations run under the wrong env.
Cause: LocalFrame and BindFrame pop order is wrong.
Fix: use stack head as top. Push LocalFrame when entering local; push BindFrame when entering bind. Pop exactly one frame when a value is produced.

Bug: existing IO bind tests fail.
Cause: IO actions halt instead of passing result to finishValue.
Fix: every completed primitive action should call finishValue.

Bug: nested binds still rebuild trees.
Cause: old ABind logic left in place.
Fix: ABind should only push BindFrame and switch current to left.

Async relevance, but not implementation

This machine representation is intentionally compatible with async.

A future scheduler can store:

Machine runtime current frames

when a task blocks, then resume it later by restoring the same Machine.

Do not implement any of these now:

AFork
AAwait
ASleep
TaskId
Scheduler
Runnable queue
Blocked table

But avoid designs that would make future suspension impossible, especially recursive “run sub-computation to completion” implementations of local. The point of the frame machine is that every effect remains small-step and resumable.


  1. Add Runtime, Frame, and Machine.
  2. Add pureAction.
  3. Replace Step with Halt Runtime T | Continue Machine.
  4. Implement finishValue.
  5. Rewrite ABind to push BindFrame.
  6. Rewrite existing primitive IO actions to call finishValue.
  7. Add AAsk and ALocal.
  8. Add runIOWithEnv.
  9. Rewrite runIO as a wrapper.
  10. Add ask and local to io.tri.
  11. Add Reader behavior tests.
  12. Run all existing IO tests and confirm no regressions.

The key handoff instruction is: implement local as a continuation frame, not as a recursive nested run. This keeps the interpreter genuinely small-step and gives the eventual async runtime the exact representation it will need for suspension and resumption.