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.
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:
- keep them in a
whereblock understepMachine, or - 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.
Recommended implementation order
- Add
Runtime,Frame, andMachine. - Add
pureAction. - Replace
StepwithHalt Runtime T | Continue Machine. - Implement
finishValue. - Rewrite
ABindto pushBindFrame. - Rewrite existing primitive IO actions to call
finishValue. - Add
AAskandALocal. - Add
runIOWithEnv. - Rewrite
runIOas a wrapper. - Add
askandlocaltoio.tri. - Add Reader behavior tests.
- 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.