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.
This commit is contained in:
2026-05-13 11:02:37 -05:00
parent 983a0cc5a7
commit 8f7684a1bb
6 changed files with 1965 additions and 117 deletions

749
notes/iodriver-updates.md Normal file
View File

@@ -0,0 +1,749 @@
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:
```haskell
stepIO :: IOPermissions -> T -> IO Step
data Step
= Done T
| Continue T
```
to an explicit abstract machine:
```haskell
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:
```tricu
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:
```tricu
ask = _ : pair 6 t
local = f action : pair 7 (pair f action)
```
Host-side:
```haskell
data Action
= APure T
| ABind T T
| APutStr T
| AGetLine
| AReadFile T
| AWriteFile T T
| AAsk
| ALocal T T
deriving (Show)
```
Recommended tag allocation:
```text
0 = pure
1 = bind
2 = putStr
3 = getLine
4 = readFile
5 = writeFile
6 = ask
7 = local
```
State tags can come later:
```text
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:
```haskell
data Runtime = Runtime
{ rtPerms :: IOPermissions
, rtEnv :: T
}
deriving (Show)
```
Later this can become:
```haskell
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:
```haskell
data Frame
= BindFrame T
| LocalFrame T
deriving (Show)
```
Frame meanings:
```text
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:
```haskell
data Machine = Machine
{ machineRuntime :: Runtime
, machineCurrent :: T
, machineFrames :: [Frame]
}
deriving (Show)
```
Frames should be treated as a stack, with the head as the top:
```haskell
push frame machine = machine { machineFrames = frame : machineFrames machine }
```
---
## New step result
Replace the current `Step` with machine-oriented stepping:
```haskell
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:
```haskell
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:
```haskell
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:
```haskell
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:
```haskell
ABind left k
```
do not recursively step `left`, and do not rebuild `Bind left' k`.
Instead:
```haskell
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:
```haskell
AAsk
```
produce the current Reader environment:
```haskell
finishValue machine (rtEnv (machineRuntime machine))
```
or equivalently:
```haskell
Continue machine { machineCurrent = pureAction currentEnv }
```
Prefer `finishValue` because it avoids an extra step.
### `local`
For:
```haskell
ALocal f action
```
do:
```haskell
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:
```tricu
local f (
local g ask
)
```
becomes:
```text
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:
```haskell
APutStr str ->
case decodeString str "PutStr" of
Right s -> do
putStr s
finishValue machine Leaf
Left _ ->
finishValue machine (errResult 6)
```
```haskell
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`:
```haskell
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:
```haskell
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:
```haskell
runIO :: IOPermissions -> T -> IO T
runIO perms action =
runIOWithEnv perms Leaf action
```
If State is added in the same branch, prefer:
```haskell
runIOWith :: IOPermissions -> T -> T -> T -> IO (T, T)
```
where:
```text
permissions
initial reader env
initial state
action
```
returns:
```text
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:
```haskell
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:
```haskell
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:
```tricu
ask = _ : pair 6 t
local = f action : pair 7 (pair f action)
```
No new bind is required.
Example usage:
```tricu
program =
bind ask (env :
putStrLn env)
```
Example `local` usage:
```tricu
program =
bind ask (outer :
bind (local (env : append env "-inner")
(bind ask (inner :
pure inner)))
(result :
bind ask (after :
pure result)))
```
Expected behavior:
```text
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:
```tricu
io (bind ask (env : pure env))
```
Run with env:
```text
"dev"
```
Expected result:
```text
"dev"
```
### 2. `local` transforms environment
Program:
```tricu
io (
local (env : append env "-local")
(bind ask (env : pure env))
)
```
Initial env:
```text
"root"
```
Expected result:
```text
"root-local"
```
### 3. `local` restores environment afterward
Program structure:
```tricu
bind ask (before :
bind (local transform scopedAsk) (inside :
bind ask (after :
pure (pair before (pair inside after)))))
```
Initial env:
```text
"root"
```
Expected:
```text
pair "root" (pair "root-local" "root")
```
### 4. nested `local` composes correctly
Program:
```tricu
local f (
local g ask
)
```
Initial env:
```text
"root"
```
Example:
```tricu
f = x : append x "-f"
g = x : append x "-g"
```
Expected inner ask:
```text
"root-f-g"
```
Also verify after both locals, environment is restored by doing a final `ask`.
### 5. `local` result passes through bind correctly
Program:
```tricu
bind
(local transform (pure "value"))
(x : pure x)
```
Expected:
```text
"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:
```tricu
local transform (
bind ask (env :
bind (putStrLn env) (_ :
pure env))
)
```
Expected:
```text
prints transformed env
returns transformed env
```
Then optionally ask after local to verify restoration.
---
## Invariants to preserve
The implementation should maintain these invariants:
```text
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:
```text
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:
```haskell
Machine runtime current frames
```
when a task blocks, then resume it later by restoring the same `Machine`.
Do not implement any of these now:
```haskell
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
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.