feat: L1 prototype
This commit is contained in:
commit
f20876af86
20 changed files with 10480 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
300
Cargo.lock
generated
Normal file
300
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crossterm_winapi",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "l0-cpu-runtime"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"crossterm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.182"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
7
Cargo.toml
Normal file
7
Cargo.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[package]
|
||||
name = "l0-cpu-runtime"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
crossterm = "0.28"
|
||||
36
examples/counter.l1
Normal file
36
examples/counter.l1
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
actor counter {
|
||||
state {
|
||||
count: u64 = 0
|
||||
}
|
||||
|
||||
window count_view : (count)
|
||||
readers(display)
|
||||
|
||||
on Increment(amount: u64) {
|
||||
count = count + amount
|
||||
}
|
||||
}
|
||||
|
||||
leaf ticker {
|
||||
process {
|
||||
forward(counter, Increment(1))
|
||||
}
|
||||
}
|
||||
|
||||
leaf display {
|
||||
reads counter.count_view
|
||||
process {
|
||||
read(counter.count_view.count)
|
||||
}
|
||||
}
|
||||
|
||||
pipeline main {
|
||||
ticker -> counter -> display
|
||||
}
|
||||
|
||||
core main {
|
||||
actors: [counter]
|
||||
leaves: [ticker, display]
|
||||
pipelines: [main]
|
||||
steps: 5
|
||||
}
|
||||
50
examples/product.l1
Normal file
50
examples/product.l1
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// Product tracker: accumulates quantity and total cost from purchase events.
|
||||
//
|
||||
// Each step, a purchase of 3 units at price 7 arrives.
|
||||
// The actor multiplies price * quantity to get the line cost,
|
||||
// then adds it to the running total.
|
||||
//
|
||||
// After 4 steps:
|
||||
// count = 4 * 3 = 12
|
||||
// total = 4 * (7 * 3) = 84
|
||||
// emitted values: [21, 42, 63, 84]
|
||||
|
||||
actor ledger {
|
||||
state {
|
||||
count: u64 = 0
|
||||
total: u64 = 0
|
||||
}
|
||||
|
||||
window summary : (count, total)
|
||||
readers(report)
|
||||
|
||||
on Purchase(price: u64, qty: u64) {
|
||||
count = count + qty
|
||||
total = total + (price * qty)
|
||||
}
|
||||
}
|
||||
|
||||
leaf buy {
|
||||
process {
|
||||
forward(ledger, Purchase(7, 3))
|
||||
}
|
||||
}
|
||||
|
||||
leaf report {
|
||||
reads ledger.summary
|
||||
process {
|
||||
read(ledger.summary.total)
|
||||
emit(total)
|
||||
}
|
||||
}
|
||||
|
||||
pipeline main {
|
||||
buy -> ledger -> report
|
||||
}
|
||||
|
||||
core main {
|
||||
actors: [ledger]
|
||||
leaves: [buy, report]
|
||||
pipelines: [main]
|
||||
steps: 4
|
||||
}
|
||||
37
examples/window.l1
Normal file
37
examples/window.l1
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
actor accumulator {
|
||||
state {
|
||||
total: u64 = 0
|
||||
}
|
||||
|
||||
window total_view : (total)
|
||||
readers(observe)
|
||||
|
||||
on Add(value: u64) {
|
||||
total = total + value
|
||||
}
|
||||
}
|
||||
|
||||
leaf source {
|
||||
process {
|
||||
forward(accumulator, Add(10))
|
||||
}
|
||||
}
|
||||
|
||||
leaf observe {
|
||||
reads accumulator.total_view
|
||||
process {
|
||||
read(accumulator.total_view.total)
|
||||
emit(total)
|
||||
}
|
||||
}
|
||||
|
||||
pipeline main {
|
||||
source -> accumulator -> observe
|
||||
}
|
||||
|
||||
core main {
|
||||
actors: [accumulator]
|
||||
leaves: [source, observe]
|
||||
pipelines: [main]
|
||||
steps: 3
|
||||
}
|
||||
865
spec.md
Normal file
865
spec.md
Normal file
|
|
@ -0,0 +1,865 @@
|
|||
# Compiler Spec: L3 → L0
|
||||
|
||||
*A four-layer compiler for ML training pipelines, from intent to bare metal.*
|
||||
|
||||
Each layer is a closed DSL with finite forms. Each has its own IR suited to the decisions made at that level. Optimization happens *between* layers during lowering and *within* layers as IR→IR rewrites. Never at the DSL surface.
|
||||
|
||||
Lowering between layers is **search**: enumerate valid candidates at the target layer, score them, pick the best. If search is slow, the representation is wrong.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ L3 Spec what you want │
|
||||
├───────────────── search ────────────────────┤
|
||||
│ L2 Method how, algorithmically │
|
||||
├───────────────── search ────────────────────┤
|
||||
│ L1 Placement where, on what │
|
||||
├───────────────── emit ──────────────────────┤
|
||||
│ L0 Machine explicit execution │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Codegen PTX / LLVM IR / x86 / ... │
|
||||
└─────────────────────────────────────────────┘
|
||||
↕ rewrites within each layer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zen
|
||||
|
||||
- Read little; write little
|
||||
- Correctness via DSL model, not assertions
|
||||
- Every stage as simple as possible; all mutations checkable
|
||||
- Unbounded execution = specification error
|
||||
- Optimize between stages, closer to machine; not at DSL level
|
||||
- Never sacrifice performance for correctness; change the representation
|
||||
- Specify via properties: liveness, safety, consistency, resource bounds
|
||||
|
||||
The last point is structural. Every verification table in this document classifies its checks under exactly these four property kinds. If a check doesn't fit one of them, it doesn't belong.
|
||||
|
||||
### Property Definitions
|
||||
|
||||
**Safety** — references resolve, types match, nothing dangling. *"You will not read garbage."*
|
||||
|
||||
**Consistency** — declared constraints agree with each other and with structure. *"Your spec doesn't contradict itself."*
|
||||
|
||||
**Liveness** — every resource produced is consumed; every buffer freed; no dead allocations. *"Nothing is wasted."*
|
||||
|
||||
**Resource bound** — memory, compute, time, space are all finite and known. *"You will not run forever or exhaust the machine."*
|
||||
|
||||
### Verification Without Solvers
|
||||
|
||||
The restricted DSL makes each check a specific algorithm. Conservation is a graph walk. Separation is set disjointness. Boundedness is constant arithmetic. If the DSL allowed recursion, unbounded loops, or dynamic allocation, we'd need SAT solvers. Because it doesn't, we don't.
|
||||
|
||||
---
|
||||
|
||||
## L3: Spec
|
||||
|
||||
### Purpose
|
||||
|
||||
Declare *what* you want. No algorithms, no hardware, no shapes beyond the data itself. This is a contract that all lower layers must satisfy.
|
||||
|
||||
### DSL Forms
|
||||
|
||||
```
|
||||
data <n> {
|
||||
<field> : <domain>
|
||||
...
|
||||
}
|
||||
|
||||
model <n> {
|
||||
in: <data_ref>
|
||||
out: <domain>
|
||||
properties: [<constraint>, ...]
|
||||
}
|
||||
|
||||
loss <n> {
|
||||
<term_name> : <loss_kind>(<args>), weight: <float>
|
||||
...
|
||||
combine: <combinator>
|
||||
}
|
||||
|
||||
goal: minimize(<loss_ref>) | maximize(<ref>) | pareto([<refs>])
|
||||
```
|
||||
|
||||
### Domain Types
|
||||
|
||||
```
|
||||
tensor(dtype, shape) fixed shape
|
||||
sequence(domain, max_len) variable-length, bounded
|
||||
table(col: domain, ...) tabular
|
||||
dist(n) probability distribution over n classes
|
||||
scalar(dtype) single value
|
||||
```
|
||||
|
||||
### Model Constraints
|
||||
|
||||
These are not implementations. They bound what L2 may produce.
|
||||
|
||||
```
|
||||
depth(n) max composed stages
|
||||
width(n) max hidden dimension
|
||||
causal output_t depends only on input_≤t
|
||||
autoregressive output is fed back as input
|
||||
equivariant(group) symmetry constraint
|
||||
param_budget(n) max parameter count
|
||||
latency_budget(ms) max forward pass time (needs L1 info)
|
||||
```
|
||||
|
||||
### Loss Kinds
|
||||
|
||||
```
|
||||
divergence(predicted, target) ce, kl, etc. (choice is L2)
|
||||
norm(target, p) Lp on params or activations
|
||||
constraint(predicate) hard constraint → penalty
|
||||
reconstruction(input, decoded) autoencoders
|
||||
contrastive(anchor, positive, negative) representation learning
|
||||
custom(name, reads: [refs]) escape hatch, user-defined
|
||||
```
|
||||
|
||||
### IR
|
||||
|
||||
```
|
||||
L3_IR = {
|
||||
data: Map<Name, DataDecl>
|
||||
models: Map<Name, ModelDecl>
|
||||
losses: Map<Name, LossDecl>
|
||||
goal: Goal
|
||||
hints: Map<Path, EscapeHatch>
|
||||
}
|
||||
```
|
||||
|
||||
All refs resolved. All domains concrete. No optionals. A total description.
|
||||
|
||||
### Verification
|
||||
|
||||
| Property | Check | Catches |
|
||||
|----------------|------------------------|-----------------------------------------------------------|
|
||||
| Safety | Ref resolution | Dangling references to data fields, model outputs |
|
||||
| Consistency | Domain compatibility | Model input domain ≠ data field domain |
|
||||
| Safety | Loss signature | Loss term reads something that doesn't exist |
|
||||
| Consistency | Constraint agreement | `depth(2)` + `autoregressive` on feedforward output |
|
||||
| Resource bound | Finiteness | Everything bounded, no recursion at this level |
|
||||
|
||||
All checks decidable. All linear in program size.
|
||||
|
||||
### Optimization
|
||||
|
||||
**None.** L3 is a spec. You don't optimize a spec. You may *simplify* it — merge redundant loss terms, canonicalize domains — but no semantic changes.
|
||||
|
||||
### Trace — MNIST at L3
|
||||
|
||||
```
|
||||
data mnist {
|
||||
images : tensor(f32, [28, 28])
|
||||
labels : tensor(u8, [1])
|
||||
}
|
||||
|
||||
model classify {
|
||||
in: mnist.images
|
||||
out: dist(10)
|
||||
properties: [depth(3), width(512), param_budget(500K)]
|
||||
}
|
||||
|
||||
loss objective {
|
||||
ce: divergence(classify.out, mnist.labels), weight: 1.0
|
||||
sparse: norm(classify.params, p=1), weight: 1e-4
|
||||
combine: weighted_sum
|
||||
}
|
||||
|
||||
goal: minimize(objective)
|
||||
```
|
||||
|
||||
This is the entire program. Everything below exists to make it run.
|
||||
|
||||
---
|
||||
|
||||
## L3 → L2 Lowering
|
||||
|
||||
Given an L3 spec, enumerate valid L2 programs and pick the best.
|
||||
|
||||
The search space is combinatorial over (sample) × (architecture) × (backward) × (update). Architecture is the big one — it's a DAG construction problem. The search is structured, not brute force:
|
||||
|
||||
From `depth(3)` + `sequential`: 3-layer chain. Width search over powers-of-2 up to `width(n)`. If `causal`, only causal attention. If `autoregressive`, add feedback edges. Backward filtered by architecture compatibility. Update mostly independent. Sample constrained by data domain.
|
||||
|
||||
Cost model scores without lowering to L1: param count, FLOPs per step, memory high-water mark, known convergence priors.
|
||||
|
||||
If search produces >10K candidates: **representation too loose.** Add constraints at L3 or tighten the enumeration grammar.
|
||||
|
||||
---
|
||||
|
||||
## L2: Method
|
||||
|
||||
### Purpose
|
||||
|
||||
Choose *how* to achieve the spec. Algorithms, architecture, training strategy. Still no hardware, no memory, no scheduling.
|
||||
|
||||
### DSL Forms
|
||||
|
||||
```
|
||||
methods for <model_ref> {
|
||||
sample: <sample_method>
|
||||
infer: <infer_method>
|
||||
backward: <backward_method>
|
||||
update: <update_method>
|
||||
}
|
||||
```
|
||||
|
||||
#### Sample Methods
|
||||
|
||||
```
|
||||
epoch_shuffle(batch: n, drop_last: bool)
|
||||
stream_window(window: n, stride: n)
|
||||
curriculum([{ filter: <pred>, duration: n_steps }, ...])
|
||||
replay(buffer: n, priority: <kind>)
|
||||
distributed_shard(shards: n, method: <inner>)
|
||||
```
|
||||
|
||||
#### Infer Methods (Architecture)
|
||||
|
||||
```
|
||||
sequential([<layer>, ...])
|
||||
residual([<block>, ...])
|
||||
diffusion_chain(steps: n, noise: <schedule>, denoiser: <infer>)
|
||||
recurrent(cell: <infer>, steps: n | until(<pred>))
|
||||
graph(nodes: <infer>, edges: <adjacency>)
|
||||
mixture(experts: [<infer>], gate: <infer>)
|
||||
```
|
||||
|
||||
#### Layer Primitives
|
||||
|
||||
```
|
||||
linear(in, out)
|
||||
conv(in_ch, out_ch, kernel, stride, padding)
|
||||
attn(heads, dim, causal: bool)
|
||||
norm(kind: layer | group(n) | rms)
|
||||
act(fn: relu | gelu | silu | swish)
|
||||
embed(vocab, dim)
|
||||
pool(kind: avg | max, kernel)
|
||||
dropout(p) training only
|
||||
```
|
||||
|
||||
#### Backward Methods
|
||||
|
||||
```
|
||||
autodiff(mode: reverse | forward, checkpoint: [<layer_ref>])
|
||||
evolution(pop: n, sigma: f, selection: <kind>)
|
||||
dfa(feedback_dim: n)
|
||||
reinforce(baseline: <kind>)
|
||||
straight_through(estimator: <kind>)
|
||||
zeroth_order(samples: n, sigma: f)
|
||||
hybrid(primary: <backward>, aux: <backward>, blend: <schedule>)
|
||||
```
|
||||
|
||||
`autodiff` is an L2 declaration. L2→L1 lowering expands it into an explicit backward DAG in the tensor graph. At L1 and below, forward and backward are both explicit — no compiler magic.
|
||||
|
||||
#### Update Methods
|
||||
|
||||
```
|
||||
sgd(lr)
|
||||
adam(lr, beta1, beta2, eps)
|
||||
muon(lr, ns_steps)
|
||||
lion(lr, beta1, beta2)
|
||||
schedule(warmup: n, decay: <kind>, inner: <update>)
|
||||
gradient_clip(max_norm: f, inner: <update>)
|
||||
ema(decay: f, inner: <update>)
|
||||
```
|
||||
|
||||
### IR
|
||||
|
||||
```
|
||||
L2_IR = {
|
||||
sample: SampleNode
|
||||
infer: InferGraph DAG of layer nodes
|
||||
backward: BackwardDecl
|
||||
update: UpdateChain
|
||||
shapes: Map<NodeId, Shape> propagated during verification
|
||||
hints: Map<Path, EscapeHatch>
|
||||
}
|
||||
```
|
||||
|
||||
The InferGraph is a DAG, not a sequence. `sequential` and `residual` are sugar that lower into the DAG form. This makes fusion and rewrite uniform.
|
||||
|
||||
**Key principle:** `recurrent(... steps: n)` is finite. `recurrent(... until(pred))` is a specification error unless `pred` is proven terminating with a maximum bound. We do not allow unbounded execution.
|
||||
|
||||
### Verification
|
||||
|
||||
| Property | Check | Catches |
|
||||
|----------------|--------------------------|--------------------------------------------------------|
|
||||
| Consistency | Shape propagation | Mismatched shapes on infer DAG edges |
|
||||
| Consistency | Spec constraint check | depth, width, param_budget violated |
|
||||
| Consistency | Backward compatibility | Backward method can't handle infer graph structure |
|
||||
| Consistency | Update shape match | Update produces params of wrong shape |
|
||||
| Consistency | Sample/data match | Batch shape inconsistent with data domains |
|
||||
| Resource bound | Finiteness | DAG is acyclic (recurrent unrolled to fixed depth) |
|
||||
| Resource bound | Resource estimation | Rough FLOPs, memory — feeds L1 search cost model |
|
||||
|
||||
### Optimization (IR→IR rewrites)
|
||||
|
||||
Semantics-preserving transformations on L2 IR: layer fusion (`linear → act` becomes `fused_linear_act`), redundant norm elimination, checkpoint selection for memory/compute tradeoff, architecture search pruning of dominated candidates.
|
||||
|
||||
### Trace — MNIST at L2
|
||||
|
||||
```
|
||||
methods for classify {
|
||||
sample: epoch_shuffle(batch: 128)
|
||||
|
||||
infer: sequential [
|
||||
linear(784, 256), norm(layer), gelu,
|
||||
linear(256, 256), norm(layer), gelu,
|
||||
linear(256, 10), softmax
|
||||
]
|
||||
|
||||
backward: autodiff(mode: reverse)
|
||||
update: schedule(warmup: 1000, decay: cosine,
|
||||
inner: adam(lr: 3e-4))
|
||||
}
|
||||
```
|
||||
|
||||
Search chose this from L3 constraints: 3-layer sequential (from `depth(3)`), widths 256 (under `width(512)`), ~200K params (under `param_budget(500K)`). `softmax` output satisfies `dist(10)`.
|
||||
|
||||
---
|
||||
|
||||
## L2 → L1 Lowering
|
||||
|
||||
Given an L2 method and a hardware description, produce a valid L1 placement. Search decides: which compute goes on which node, how to partition across accelerators, pipeline depth, where to stage data, whether to fuse or split stages.
|
||||
|
||||
Cost model uses hardware specs: transfer time = data_size / bandwidth, compute time = FLOPs / throughput, memory pressure = peak live tensors, pipeline bubble ratio = (depth − 1) / (depth × stages).
|
||||
|
||||
If placement search explodes, either the hardware description is too ambiguous or the L2 method has too many equivalent decompositions.
|
||||
|
||||
Three things happen during this lowering that don't happen at other boundaries:
|
||||
|
||||
1. **Backward expansion.** If L2 declares `autodiff(mode: reverse)`, the lowering derives the backward pass and writes it as explicit tensor ops in the L1 tensor graph. At L1, no operation is implicit.
|
||||
|
||||
2. **Update materialization.** L2's `adam(lr, beta1, beta2, eps)` becomes an explicit actor handler with tensor arithmetic at L1.
|
||||
|
||||
3. **Data binding.** L3's abstract data declarations gain physical storage locations (`@ ssd_0, path: "..."`).
|
||||
|
||||
---
|
||||
|
||||
## L1: Placement
|
||||
|
||||
### Purpose
|
||||
|
||||
Map the L2 method onto specific hardware. This is the layer where the systems-level DSL lives. It answers two questions: **where** does each computation live (topology + placement), and **when** does it execute (schedule + dataflow).
|
||||
|
||||
Six declaration forms: system, data, actor, stage, tensor_graph, pipeline. Plus a schedule block.
|
||||
|
||||
### DSL Forms — Topology
|
||||
|
||||
```
|
||||
system <n> {
|
||||
node <id> { kind: <storage|memory|compute>, <properties> }
|
||||
...
|
||||
edges {
|
||||
<id> -> <id> : bandwidth <rate> // unidirectional
|
||||
<id> <-> <id> : bandwidth <rate> // bidirectional
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Node kinds:
|
||||
- `storage` — persistent (SSD, disk). Source of data.
|
||||
- `memory` — volatile (RAM, HBM). Has `capacity`.
|
||||
- `compute` — processing (CPU, GPU). Has `isa`, and optionally `vram`, `cores`, `sm`, etc.
|
||||
|
||||
### DSL Forms — Data Sources
|
||||
|
||||
```
|
||||
data <n> {
|
||||
<field> : <domain> @ <storage_node>, path: <string>
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Binds L3's abstract data declarations to physical storage. The `@` annotation names the topology node where the data lives at rest.
|
||||
|
||||
### DSL Forms — Actors
|
||||
|
||||
Actors own mutable state. They process messages one at a time — serialized, no concurrent access to their state.
|
||||
|
||||
```
|
||||
actor <n> on <node> {
|
||||
state {
|
||||
<field> : <type>, <init>
|
||||
...
|
||||
}
|
||||
|
||||
window <n> : (<fields>)
|
||||
readers(<stage_or_graph_refs>)
|
||||
|
||||
on <MessageType>(<args>) {
|
||||
<update logic>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Windows** grant read-only access to actor state from stages or tensor graphs. The actor's message handler is the only writer. Safety requires that readers and writers never execute concurrently — enforced by data dependency chains within a step and actor serialization across steps.
|
||||
|
||||
### DSL Forms — Stages
|
||||
|
||||
Stages are stateless dataflow processors. They receive buffers, do work, and forward results. Every buffer received must be consumed exactly once — forwarded or freed. No exceptions.
|
||||
|
||||
```
|
||||
stage <n> on <node> {
|
||||
reads: <refs> // optional window reads
|
||||
|
||||
fn process(<args>) -> forward(<target>, <outputs>)
|
||||
| Drop(<buf>) {
|
||||
<body>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Buffer types carry region provenance: `buf<ram_0>`, `buf<gpu_0>`. Every forwarding site is checked: target exists, arity matches, types match, region provenance matches the target's placement.
|
||||
|
||||
### DSL Forms — Tensor Graphs
|
||||
|
||||
Lazy DAGs of primitive tensor ops. Run on accelerators. No control flow, no mutation, no side effects — pure compute.
|
||||
|
||||
```
|
||||
tensor_graph <n> on <node> {
|
||||
reads: <window_refs>
|
||||
|
||||
fn compute(<inputs>) -> (<outputs>) {
|
||||
let t0 = tensor_matmul(x, w)
|
||||
let t1 = tensor_add(t0, tensor_broadcast(b, shape))
|
||||
...
|
||||
return (loss, gw, gb)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `reads` clause grants access to actor state via a window — same mechanism as stages. The body is a sequence of `let` bindings over primitive tensor ops. No autodiff — both forward and backward are explicit. Movement ops (reshape, permute, broadcast) are free — metadata only.
|
||||
|
||||
**Primitive tensor ops:**
|
||||
|
||||
```
|
||||
tensor_matmul(a, b) matrix multiply
|
||||
tensor_add(a, b) elementwise add
|
||||
tensor_sub(a, b) elementwise subtract
|
||||
tensor_mul(a, b) elementwise multiply
|
||||
tensor_scale(a, scalar) scalar multiply
|
||||
tensor_broadcast(a, shape) expand dimensions
|
||||
tensor_transpose(a) transpose
|
||||
tensor_reduce_mean(a) reduce mean (all axes)
|
||||
tensor_reduce_sum(a, axis: n) reduce sum on axis
|
||||
tensor_const(v) scalar constant
|
||||
```
|
||||
|
||||
### DSL Forms — Pipelines
|
||||
|
||||
Ordered chains of stages and tensor graphs.
|
||||
|
||||
```
|
||||
pipeline <n> {
|
||||
<stage_or_graph> -> <stage_or_graph> -> ...
|
||||
}
|
||||
```
|
||||
|
||||
### DSL Forms — Schedule
|
||||
|
||||
```
|
||||
schedule {
|
||||
pipelines: [<refs>]
|
||||
actors: [<refs>]
|
||||
pipeline_depth: <n>
|
||||
}
|
||||
```
|
||||
|
||||
No explicit phases. Execution order is inferred from `forward()` edges — the dependency DAG. `pipeline_depth` controls overlap: at depth 2, step N+1's early stages can overlap with step N's late stages. The actor is the serialization fence between steps.
|
||||
|
||||
### IR
|
||||
|
||||
```
|
||||
L1_IR = {
|
||||
topology: Graph<NodeId, Edge> hardware graph
|
||||
data_sources: Map<Name, DataDecl>
|
||||
placement: Map<L2_NodeId, HW_NodeId> where each computation lives
|
||||
actors: Map<Name, ActorDecl>
|
||||
stages: Map<Name, StageDecl>
|
||||
tensor_graphs: Map<Name, TensorDAG> lazy compute DAGs
|
||||
pipelines: [Pipeline] ordered stage/graph chains
|
||||
schedule: ScheduleDecl
|
||||
hints: Map<Path, EscapeHatch>
|
||||
}
|
||||
```
|
||||
|
||||
Every cross-reference is resolved during parsing. `demux` reading `connection_manager.routes` becomes a pointer within the tree. If a name is unresolved, the parser rejects. If a syntax rule is violated, the parser rejects. Semantic validity is the verifier's job.
|
||||
|
||||
### Verification — Structural (Phase 1)
|
||||
|
||||
Reads the IR. Does **not** transform it. If it also rewrote things, we'd need to verify the verifier's transformations. Keeping it read-only keeps it auditable.
|
||||
|
||||
| Property | Check | Catches |
|
||||
|----------------|-----------------------------|---------------------------------------------------------------|
|
||||
| Safety | Topology consistency | Edges reference nonexistent nodes; no path for transfers |
|
||||
| Safety | Ref resolution | Dangling window reads, missing forward targets |
|
||||
| Safety | Forwarding signatures | Arity mismatch, type mismatch, region provenance mismatch |
|
||||
| Safety | Window separation | Reader and writer may execute concurrently |
|
||||
| Consistency | Tensor shape propagation | Mismatched shapes in tensor graph DAG |
|
||||
| Consistency | Region provenance | `buf<ram_0>` forwarded to a `gpu_0` tensor graph |
|
||||
| Liveness | Buffer conservation | Buffer allocated but never freed or forwarded |
|
||||
| Liveness | Tensor reachability | DAG node neither consumed nor named as output |
|
||||
| Resource bound | Persistent memory bounded | Actor state exceeds node capacity |
|
||||
| Resource bound | DAG finiteness | Tensor graph or pipeline contains a cycle |
|
||||
|
||||
**Conservation check per stage:** build the control flow graph, walk every path tracking live `Buffer` resources. Every path must end with an empty live set. If any path ends with a live buffer, the verifier rejects with the specific path and buffer name.
|
||||
|
||||
**Separation check:** within a step, the data dependency chain ensures the window reader (tensor graph) completes before the writer (actor handler) fires. At `pipeline_depth > 1`, cross-step safety requires that step N's actor update completes before step N+1's reader begins — the actor serializes this.
|
||||
|
||||
### Optimization (IR→IR rewrites)
|
||||
|
||||
Stage fusion (merge adjacent stages on same node), pipeline rebalancing, tensor graph rewriting (fuse ops, eliminate copies), communication optimization (overlap compute and transfer), parallelism mapping (data/tensor/pipeline parallel as topology + placement rewrites).
|
||||
|
||||
### Trace — MNIST at L1 (abbreviated)
|
||||
|
||||
```
|
||||
system single_gpu {
|
||||
node ssd_0 { kind: storage }
|
||||
node ram_0 { kind: memory, capacity: 32GB }
|
||||
node cpu_0 { kind: compute, isa: x86_64, cores: 8 }
|
||||
node gpu_0 { kind: compute, isa: cuda, vram: 24GB, sm: 84 }
|
||||
|
||||
edges {
|
||||
ssd_0 -> ram_0 : bandwidth 2GB/s
|
||||
ram_0 <-> cpu_0 : bandwidth 50GB/s
|
||||
cpu_0 <-> gpu_0 : bandwidth 32GB/s
|
||||
}
|
||||
}
|
||||
|
||||
actor model_state on gpu_0 {
|
||||
state {
|
||||
w : tensor(f32, [784, 256]), init: kaiming
|
||||
b : tensor(f32, [256]), init: zeros
|
||||
}
|
||||
|
||||
window weights : (w, b)
|
||||
readers(forward_backward)
|
||||
|
||||
on GradUpdate(gw, gb) {
|
||||
let lr = 3e-4
|
||||
w = tensor_sub(w, tensor_scale(gw, lr))
|
||||
b = tensor_sub(b, tensor_scale(gb, lr))
|
||||
}
|
||||
}
|
||||
|
||||
stage load_data on cpu_0 { ... }
|
||||
stage transfer_to_gpu on cpu_0 { ... }
|
||||
tensor_graph forward_backward on gpu_0 {
|
||||
reads: model_state.weights
|
||||
fn compute(x, y) -> (loss, gw, gb) { ... }
|
||||
}
|
||||
stage transfer_grads on cpu_0 { ... }
|
||||
|
||||
pipeline train { load_data -> transfer_to_gpu -> forward_backward }
|
||||
pipeline grad_return { transfer_grads }
|
||||
|
||||
schedule {
|
||||
pipelines: [train, grad_return]
|
||||
actors: [model_state]
|
||||
pipeline_depth: 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## L1 → L0 Lowering
|
||||
|
||||
Not search — **emission.** The verified L1 IR is lowered into L0 through three compiler passes, then split into two code paths.
|
||||
|
||||
### Pass 1: Memory Planning
|
||||
|
||||
Compiler-derived, not user-declared. Three strategies selected by lifetime analysis:
|
||||
|
||||
| Strategy | Use | Lifetime |
|
||||
|----------------|--------------------------|----------------------------------|
|
||||
| **Persistent** | Actor state (w, b) | Init → program end |
|
||||
| **Arena** | Tensor intermediates | Graph start → graph end, bulk reset |
|
||||
| **Pool** | Transfer staging buffers | alloc → free, freelist |
|
||||
|
||||
### Pass 2: Stream Inference
|
||||
|
||||
Each step's GPU work gets a stream. At `pipeline_depth: N`, N streams. Cross-stream dependencies become sync events.
|
||||
|
||||
```
|
||||
stream_0: step N → K0, K1, ..., sgd_kernel
|
||||
stream_1: step N+1 → K0, K1, ..., sgd_kernel
|
||||
|
||||
sync: event recorded after sgd_kernel on stream_0
|
||||
waited before K0 on stream_1
|
||||
```
|
||||
|
||||
### Pass 3: Kernel Scheduling & Fusion
|
||||
|
||||
Tensor graph DAG nodes are grouped into kernels. Fusion rules: elementwise ops fuse freely; reduction forces a kernel boundary; different iteration spaces cannot fuse.
|
||||
|
||||
### Dual Emission
|
||||
|
||||
The verified L1 IR splits into two code paths:
|
||||
|
||||
**Path A — Control.** Stages and actor handlers emit to **Control IR** (Machine IR). This is the CPU-side orchestration: data loading, transfers, message dispatch, pipeline coordination.
|
||||
|
||||
**Path B — Compute.** Tensor graphs emit to **Compute IR** (UOps). This is the accelerator-side math: matmuls, reductions, elementwise, fused kernels.
|
||||
|
||||
Both paths feed into the Phase 2 (concrete) verifier before codegen.
|
||||
|
||||
---
|
||||
|
||||
## L0: Machine
|
||||
|
||||
### Purpose
|
||||
|
||||
Fully explicit execution plan. Every buffer, every kernel, every stream, every byte. No decisions left. This is what actually runs.
|
||||
|
||||
### Control IR
|
||||
|
||||
Ten operations over named memory regions. SSA registers. No flat address space — every load and store names its region, so the backend knows all aliasing without analysis.
|
||||
|
||||
```
|
||||
load.{sz} rgn[off] read from named region
|
||||
store.{sz} rgn[off], val write to region (rw only)
|
||||
hash val → reg deterministic hash for map indexing
|
||||
arith {op} a, b → reg add, sub, mul, and, or, shift
|
||||
cmp {op} a, b → reg eq, neq, lt, gt → 1-bit result
|
||||
branch reg → blk, blk conditional jump
|
||||
jump → blk unconditional jump
|
||||
queue_push rgn, val push descriptor to queue
|
||||
queue_pop rgn → reg pop descriptor from queue
|
||||
terminate end of execution unit
|
||||
```
|
||||
|
||||
**Regions** are named, sized, access-controlled memory. Not a flat address space — specific bounded things. This preserves aliasing knowledge all the way to codegen. A flat address space throws this away and forces the backend to re-derive it.
|
||||
|
||||
Why not target C? C implies abstractions we don't use: a call stack (we don't recurse), a general pointer model (we have named regions), function boundaries (we inline everything). The C compiler then has to *re-discover* structure we already know. It may fail and generate worse code. More importantly, C is a black box: we lose visibility into what actually executes.
|
||||
|
||||
### Compute IR (UOps)
|
||||
|
||||
Atomic operations inside a kernel. Each is a typed node `(op, dtype, src[], arg)` forming a DAG rooted at STORE nodes.
|
||||
|
||||
| Category | Ops |
|
||||
|--------------|------------------------------------------------------|
|
||||
| Memory | `LOAD` `STORE` `INDEX` |
|
||||
| Arithmetic | `ADD` `MUL` `MULACC` `NEG` `RECIPROCAL` `MAX` `MOD` |
|
||||
| Compare | `CMPLT` `CMPNE` `WHERE` |
|
||||
| Transcendent | `EXP2` `LOG2` `SIN` `SQRT` |
|
||||
| Control | `RANGE` `END` `IF` `ENDIF` `BARRIER` |
|
||||
| Type | `CONST` `CAST` `BITCAST` `VECTORIZE` `GEP` |
|
||||
| Special | `WMMA` `REDUCE` `CUSTOM` |
|
||||
|
||||
~25 ops. Complete for any dense numeric kernel. `CUSTOM` covers sparse, scatter, and target-specific intrinsics.
|
||||
|
||||
### IR
|
||||
|
||||
```
|
||||
L0_IR = {
|
||||
memory_plan: Map<BufferId, MemorySlot>
|
||||
kernel_plan: [KernelDecl]
|
||||
stream_plan: Map<StreamId, [KernelId]>
|
||||
sync_plan: [SyncPoint]
|
||||
transfer_plan: [Transfer]
|
||||
init_plan: [InitOp]
|
||||
control_blocks: [Block] Machine IR blocks
|
||||
}
|
||||
|
||||
KernelDecl = {
|
||||
id: KernelId
|
||||
inputs: [BufferId]
|
||||
outputs: [BufferId]
|
||||
ops: [UOp] the kernel body
|
||||
grid: (x, y, z)
|
||||
block: (x, y, z)
|
||||
shared_mem: bytes
|
||||
}
|
||||
|
||||
MemorySlot = {
|
||||
pool: PoolId which physical memory
|
||||
offset: bytes
|
||||
size: bytes
|
||||
lifetime: (first_use, last_use)
|
||||
}
|
||||
```
|
||||
|
||||
### Verification — Concrete (Phase 2)
|
||||
|
||||
After memory planning, stream inference, and kernel scheduling. Pure read-only checks on the concrete plan.
|
||||
|
||||
| Property | Check | Catches |
|
||||
|----------------|----------------------------|--------------------------------------------------------|
|
||||
| Safety | Kernel correctness | UOp sequences don't type-check, out-of-bounds access |
|
||||
| Safety | Buffer lifetime safety | Overlapping writes to same memory slot |
|
||||
| Safety | Stream hazard check | Shared mutable region + different streams, no sync |
|
||||
| Liveness | Sync completeness | Cross-stream dependency missing a barrier |
|
||||
| Resource bound | Memory capacity | Sum of live slots > pool capacity at any time t |
|
||||
| Resource bound | Pool exhaustion | No headroom for transient allocations at pipeline depth |
|
||||
|
||||
### Optimization (IR→IR rewrites)
|
||||
|
||||
Closest to the metal. Most aggressive. Kernel fusion (merge adjacent kernels sharing buffers), memory packing (minimize peak by reordering lifetimes), launch config tuning (grid/block for occupancy), instruction selection (UOp → target ISA), register pressure management, prefetch insertion.
|
||||
|
||||
---
|
||||
|
||||
## Codegen
|
||||
|
||||
L0 IR → target code. This is the only target-dependent phase in the entire compiler.
|
||||
|
||||
### Control Path
|
||||
|
||||
Machine IR → target backend. The 10 ops translate mechanically.
|
||||
|
||||
| Backend | Characteristics |
|
||||
|------------|--------------------------------------------------------------------------------|
|
||||
| **x86-64** | Each op → 1–3 instructions. ~5 live values, no spills. Regions → register pointers. |
|
||||
| **LLVM** | Regions become pointer arguments with `!noalias` metadata. LLVM gets perfect aliasing. |
|
||||
| **eBPF** | Machine IR's restrictions (no loops, bounded access, linear resources) already satisfy the eBPF verifier. Nearly 1:1. |
|
||||
| **FPGA** | Each block → combinational pipeline stage. Regions → BRAM ports. `queue_push` → FIFO write-enable. `terminate` → valid signal. |
|
||||
|
||||
### Compute Path
|
||||
|
||||
UOps → target ISA. Three passes:
|
||||
|
||||
1. **Instruction selection** — maps UOps to target ISA. `MULACC` → `fma.rn.f32` on PTX, `vfmadd` on x86.
|
||||
2. **Register allocation** — handles spill/fill and manages pressure per-target. GPU has thousands of registers (occupancy-sensitive), CPU has 16 GPRs (spill-heavy).
|
||||
3. **ISA emission** — produces final text or binary. Warp divergence on GPU means `IF/ENDIF` UOps have different cost profiles than on CPU. Vectorization width is target-dependent.
|
||||
|
||||
### Bounded Complexity
|
||||
|
||||
Codegen is deferred complexity, not buried complexity. The contract is clear: L0 IR is target-independent. Codegen is the *only* target-dependent phase. The UOp set is small enough that adding a new backend means writing ~25 emission rules plus register allocation for that target. This is a bounded, mechanical task — not an open research problem. The Machine IR is similarly small: 10 ops, each mapping to a handful of target instructions.
|
||||
|
||||
---
|
||||
|
||||
## Linking & Runtime
|
||||
|
||||
### Binary Structure
|
||||
|
||||
One binary. CPU object + accelerator blobs + metadata.
|
||||
|
||||
```
|
||||
.text schedule_loop, stage_pipeline, actor_dispatch, platform_primitives
|
||||
.rodata kernel cubins, memory_layout_table, stream_descriptors, schedule_descriptor
|
||||
.data pool_metadata, arena_metadata
|
||||
.bss gpu_context, stream_handles, event_handles
|
||||
```
|
||||
|
||||
### Platform Primitives
|
||||
|
||||
Hand-written per platform. The generated code calls into these. The emitter produces control flow + kernels. The runtime provides the operations.
|
||||
|
||||
| Primitive | Linux + CUDA |
|
||||
|---------------------|-----------------------------------|
|
||||
| `region_alloc` | Pool: atomic pop. Arena: bump. |
|
||||
| `region_free` | Pool: atomic push. Arena: no-op. |
|
||||
| `dma_ssd_to_ram` | `io_uring` / mmap+prefault |
|
||||
| `pcie_dma_h2d` | `cuMemcpyHtoDAsync` |
|
||||
| `pcie_dma_d2h` | `cuMemcpyDtoHAsync` |
|
||||
| `gpu_dispatch` | `cuLaunchKernel` |
|
||||
| `gpu_sync` | `cuStreamSynchronize` |
|
||||
| `gpu_event_*` | `cuEventRecord / cuStreamWaitEvent` |
|
||||
| `queue_push/pop` | Ring buffer in shared memory |
|
||||
|
||||
Machine IR and kernel descriptors are target-independent. Only backends and primitive implementations change per platform.
|
||||
|
||||
### Runtime Error Policy
|
||||
|
||||
All correctness errors caught at compile time by the two-phase verifier. The binary assumes all static proofs hold. Hardware faults crash the process — outside scope of generated code.
|
||||
|
||||
---
|
||||
|
||||
## Escape Hatches
|
||||
|
||||
At any layer, annotate with `#[<target_layer>::<directive>]`:
|
||||
|
||||
```
|
||||
// At L3: force a method choice
|
||||
model classify {
|
||||
...
|
||||
#[l2::backward(evolution(pop: 50))]
|
||||
}
|
||||
|
||||
// At L2: force a placement
|
||||
infer: sequential [
|
||||
linear(784, 256),
|
||||
#[l1::on(gpu_1)]
|
||||
linear(256, 10)
|
||||
]
|
||||
|
||||
// At L2: force a kernel
|
||||
infer: sequential [
|
||||
linear(784, 256),
|
||||
#[l0::kernel("fused_gelu.ptx")]
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
The escape hatch **must pass verification at its target layer.** You can inline PTX, but it must have the right buffer types. The compiler checks the boundary. If it fails, the error points at the annotation — the user broke the contract.
|
||||
|
||||
An escape hatch is **opaque to optimization** at layers above its target. `#[l0::kernel(...)]` cannot be fused with adjacent ops at L1. It's a black box with a typed boundary. This is the price of escaping.
|
||||
|
||||
---
|
||||
|
||||
## Search Architecture
|
||||
|
||||
Every lowering boundary uses the same pattern:
|
||||
|
||||
```
|
||||
lower(source_ir, target_constraints) -> Result<target_ir, SearchFailure>
|
||||
|
||||
1. enumerate(source_ir, target_grammar) -> [candidate]
|
||||
2. filter(candidates, verify_target) -> [valid]
|
||||
3. score(valid, cost_model_target) -> [(candidate, cost)]
|
||||
4. select(scored) -> target_ir | SearchFailure
|
||||
|
||||
SearchFailure =
|
||||
NoCandidates(reason) constraints too tight
|
||||
| TooManyCandidates(count) representation too loose
|
||||
| Timeout(budget_exhausted) problem is genuinely hard
|
||||
```
|
||||
|
||||
SearchFailure is a diagnostic, not an error. It tells you what to fix: tighten constraints, loosen them, or redesign the layer boundary.
|
||||
|
||||
---
|
||||
|
||||
## Data Ingestion
|
||||
|
||||
Data flows from storage to device memory before any kernel touches it:
|
||||
|
||||
```
|
||||
Storage → decode (CPU) → Host Memory → batch → Staging → DMA → Device
|
||||
```
|
||||
|
||||
The contract boundaries are clean: L2 says *what* to sample (epoch shuffle, curriculum, etc.), L1 says *where* to stage it (which memory pool, which node), L0 says *how* bytes move (DMA ops, transfer plan). Format-specific decoders (JPEG, video, custom binary) are pluggable at the L1/L0 boundary — they're `CUSTOM` UOps or host-side init ops.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
**1. Recurrence and variable-length.** Current stance: everything finite, `until(pred)` is spec error without a proven bound. But transformers process variable-length sequences. Is `sequence(domain, max_len)` at L3 enough? Or do we need a richer notion?
|
||||
|
||||
**2. Multi-model composition.** GAN = generator + discriminator. Distillation = teacher + student. RL = policy + value + environment. L3 currently has one model per spec. Do we want multiple models with relationships, or a separate composition layer?
|
||||
|
||||
**3. Training loop structure.** L3 says `goal: minimize(loss)`. Real training has phases — pretrain, finetune, RLHF. Epochs, checkpointing, evaluation. Probably L2 as a `schedule`, but it needs design.
|
||||
|
||||
**4. Cost model grounding.** L2 cost model is heuristic. L1 uses hardware specs. Neither is calibrated. Do we want empirical calibration — run a few kernels, measure, update?
|
||||
|
||||
**5. Incremental compilation.** If you change one loss term at L3, do you re-search all of L2? Probably: hash each IR node, re-lower only the subgraph whose inputs changed.
|
||||
|
||||
**6. The host language.** Rust for the compiler (algebraic types = IR correctness for free). But the DSL parser, the REPL, the interactive experience — Python bindings? LSP?
|
||||
|
||||
---
|
||||
|
||||
## What Is Resolved
|
||||
|
||||
- Layer count and purpose (4 layers, clear separation of concerns)
|
||||
- L1 is the systems-level DSL: actors, stages, pipelines, windows, tensor graphs, topology
|
||||
- Dual emission from L1: stages/actors → Control IR, tensor graphs → Compute IR (UOps)
|
||||
- Two-phase verification: structural (pre-scheduling, at L1) and concrete (post-scheduling, at L0)
|
||||
- Verification at every layer, classified by four property kinds
|
||||
- Named memory regions in Control IR — aliasing known to codegen without analysis
|
||||
- Memory planning as a compiler pass (persistent, arena, pool), not user-declared
|
||||
- Stream inference from pipeline_depth + placement
|
||||
- Escape hatch semantics (typed, opaque, verified at target)
|
||||
- Codegen contract (target-independent L0 IR, target-dependent emission only)
|
||||
- Data ingestion contract (L2 what, L1 where, L0 how)
|
||||
- Search architecture (uniform enumerate → filter → score → select at every boundary)
|
||||
- Platform primitives as the narrow runtime interface
|
||||
- No autodiff at L1 — backward is explicit; autodiff is an L2 method expanded during lowering
|
||||
- Dependency-inferred scheduling — no user-specified phases
|
||||
1246
src/codegen.rs
Normal file
1246
src/codegen.rs
Normal file
File diff suppressed because it is too large
Load diff
770
src/emit.rs
Normal file
770
src/emit.rs
Normal file
|
|
@ -0,0 +1,770 @@
|
|||
use crate::l0_ir::*;
|
||||
use crate::l1_ir::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Errors during L1 → L0 emission.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct EmitError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EmitError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "emit error: {}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EmitError {}
|
||||
|
||||
/// Tracks SSA register allocation during emission.
|
||||
struct RegAlloc {
|
||||
next: u32,
|
||||
}
|
||||
|
||||
impl RegAlloc {
|
||||
fn new() -> Self {
|
||||
RegAlloc { next: 0 }
|
||||
}
|
||||
|
||||
fn alloc(&mut self) -> Register {
|
||||
let r = Register(self.next);
|
||||
self.next += 1;
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// Context for emission — holds lookup tables derived from L1 IR.
|
||||
struct EmitContext<'a> {
|
||||
actors: HashMap<&'a str, &'a ActorDecl>,
|
||||
leaves: HashMap<&'a str, &'a LeafDecl>,
|
||||
}
|
||||
|
||||
impl<'a> EmitContext<'a> {
|
||||
fn new(program: &'a L1Program) -> Self {
|
||||
let actors = program.actors.iter().map(|a| (a.name.as_str(), a)).collect();
|
||||
let leaves = program.leaves.iter().map(|l| (l.name.as_str(), l)).collect();
|
||||
EmitContext { actors, leaves }
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower a verified L1 program to L0 IR.
|
||||
///
|
||||
/// For each core, produces an L0Program with:
|
||||
/// - Named memory regions (persistent state, message queues, step counter, output buffer)
|
||||
/// - Control blocks (entry, loop_check, step, exit)
|
||||
///
|
||||
/// Currently emits for the first core only (single-core MVP).
|
||||
pub fn emit(program: &L1Program) -> Result<L0Program, EmitError> {
|
||||
if program.cores.is_empty() {
|
||||
return Err(EmitError {
|
||||
message: "no cores defined".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let ctx = EmitContext::new(program);
|
||||
let core = &program.cores[0];
|
||||
|
||||
// Pass 1: Memory planning — allocate regions
|
||||
let mut regions = Vec::new();
|
||||
let mut actor_state_regions: HashMap<&str, String> = HashMap::new();
|
||||
let mut actor_field_offsets: HashMap<(&str, &str), usize> = HashMap::new();
|
||||
let mut queue_regions: HashMap<&str, String> = HashMap::new();
|
||||
|
||||
// Allocate state regions for each actor
|
||||
for actor_name in &core.actors {
|
||||
let actor = ctx.actors.get(actor_name.as_str()).ok_or_else(|| EmitError {
|
||||
message: format!("actor '{}' not found", actor_name),
|
||||
})?;
|
||||
|
||||
let region_name = format!("{}_state", actor.name);
|
||||
let mut offset = 0usize;
|
||||
for field in &actor.state {
|
||||
let size = field_type_size(&field.ty);
|
||||
actor_field_offsets.insert((actor.name.as_str(), field.name.as_str()), offset);
|
||||
offset += size;
|
||||
}
|
||||
|
||||
let total_size = offset.max(8); // At least 8 bytes
|
||||
regions.push(Region {
|
||||
name: region_name.clone(),
|
||||
size: total_size,
|
||||
access: AccessMode::ReadWrite,
|
||||
kind: RegionKind::State,
|
||||
});
|
||||
actor_state_regions.insert(actor.name.as_str(), region_name);
|
||||
}
|
||||
|
||||
// Allocate queue regions for message passing between leaves and actors
|
||||
for actor_name in &core.actors {
|
||||
let actor = ctx.actors.get(actor_name.as_str()).unwrap();
|
||||
for handler in &actor.handlers {
|
||||
let region_name = format!("{}_{}_queue", actor.name, handler.message_type.to_lowercase());
|
||||
// Queue region: ring buffer. Each message arg is 8 bytes, plus head/tail pointers (16 bytes).
|
||||
let msg_size = handler.args.len() * 8;
|
||||
let queue_capacity = 16; // Support up to 16 messages
|
||||
let queue_size = 16 + msg_size * queue_capacity; // 16 bytes for head+tail, rest for data
|
||||
regions.push(Region {
|
||||
name: region_name.clone(),
|
||||
size: queue_size,
|
||||
access: AccessMode::ReadWrite,
|
||||
kind: RegionKind::Queue,
|
||||
});
|
||||
queue_regions.insert(actor.name.as_str(), region_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Step counter region
|
||||
let step_counter_region = "step_counter".to_string();
|
||||
regions.push(Region {
|
||||
name: step_counter_region.clone(),
|
||||
size: 8,
|
||||
access: AccessMode::ReadWrite,
|
||||
kind: RegionKind::Control,
|
||||
});
|
||||
|
||||
// Check if any leaf uses emit — if so, allocate output region
|
||||
let has_output = core.leaves.iter().any(|leaf_name| {
|
||||
if let Some(leaf) = ctx.leaves.get(leaf_name.as_str()) {
|
||||
leaf.actions.iter().any(|a| matches!(a, LeafAction::Emit(_)))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
let output_region = if has_output {
|
||||
let name = "output".to_string();
|
||||
// Output buffer uses queue layout: 16 bytes for head+tail, then 8 bytes per slot
|
||||
let size = 16 + (core.steps as usize) * 8;
|
||||
regions.push(Region {
|
||||
name: name.clone(),
|
||||
size,
|
||||
access: AccessMode::ReadWrite,
|
||||
kind: RegionKind::Output,
|
||||
});
|
||||
Some(name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Pass 2: Control block generation
|
||||
let mut regs = RegAlloc::new();
|
||||
let mut blocks = Vec::new();
|
||||
|
||||
// --- Entry block: initialize all regions ---
|
||||
let mut entry_ops = Vec::new();
|
||||
let zero = regs.alloc();
|
||||
entry_ops.push(Op::LoadImm { value: 0, dest: zero });
|
||||
|
||||
// Initialize actor state fields
|
||||
for actor_name in &core.actors {
|
||||
let actor = ctx.actors.get(actor_name.as_str()).unwrap();
|
||||
let state_region = actor_state_regions.get(actor.name.as_str()).unwrap();
|
||||
|
||||
for field in &actor.state {
|
||||
let offset = actor_field_offsets[&(actor.name.as_str(), field.name.as_str())];
|
||||
if field.init == 0 {
|
||||
entry_ops.push(Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: state_region.clone(),
|
||||
offset,
|
||||
src: zero,
|
||||
});
|
||||
} else {
|
||||
let init_reg = regs.alloc();
|
||||
entry_ops.push(Op::LoadImm { value: field.init, dest: init_reg });
|
||||
entry_ops.push(Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: state_region.clone(),
|
||||
offset,
|
||||
src: init_reg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize step counter to 0
|
||||
entry_ops.push(Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: step_counter_region.clone(),
|
||||
offset: 0,
|
||||
src: zero,
|
||||
});
|
||||
|
||||
// Initialize output count to 0 if output region exists
|
||||
if let Some(ref out_rgn) = output_region {
|
||||
entry_ops.push(Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: out_rgn.clone(),
|
||||
offset: 0,
|
||||
src: zero,
|
||||
});
|
||||
}
|
||||
|
||||
entry_ops.push(Op::Jump { target: "loop_check".to_string() });
|
||||
blocks.push(Block { name: "entry".to_string(), ops: entry_ops });
|
||||
|
||||
// --- Loop check block: compare step counter to bound ---
|
||||
let mut check_ops = Vec::new();
|
||||
let step_val = regs.alloc();
|
||||
check_ops.push(Op::Load {
|
||||
size: OpSize::B64,
|
||||
region: step_counter_region.clone(),
|
||||
offset: 0,
|
||||
dest: step_val,
|
||||
});
|
||||
let limit = regs.alloc();
|
||||
check_ops.push(Op::LoadImm { value: core.steps, dest: limit });
|
||||
let cmp_result = regs.alloc();
|
||||
check_ops.push(Op::Cmp {
|
||||
kind: CmpKind::Lt,
|
||||
lhs: step_val,
|
||||
rhs: limit,
|
||||
dest: cmp_result,
|
||||
});
|
||||
check_ops.push(Op::Branch {
|
||||
cond: cmp_result,
|
||||
true_target: "step".to_string(),
|
||||
false_target: "exit".to_string(),
|
||||
});
|
||||
blocks.push(Block { name: "loop_check".to_string(), ops: check_ops });
|
||||
|
||||
// --- Step block: encode pipeline execution ---
|
||||
let mut step_ops = Vec::new();
|
||||
|
||||
// Process pipelines in order. For each pipeline, walk the stages
|
||||
// and generate ops according to the dataflow.
|
||||
for pipe_name in &core.pipelines {
|
||||
let pipeline = program.pipelines.iter()
|
||||
.find(|p| p.name == *pipe_name)
|
||||
.ok_or_else(|| EmitError {
|
||||
message: format!("pipeline '{}' not found", pipe_name),
|
||||
})?;
|
||||
|
||||
for stage_name in &pipeline.stages {
|
||||
// Is it a leaf?
|
||||
if let Some(leaf) = ctx.leaves.get(stage_name.as_str()) {
|
||||
emit_leaf_actions(
|
||||
leaf,
|
||||
&ctx,
|
||||
&mut step_ops,
|
||||
&mut regs,
|
||||
&actor_state_regions,
|
||||
&actor_field_offsets,
|
||||
&queue_regions,
|
||||
&output_region,
|
||||
);
|
||||
}
|
||||
// Is it an actor? Process message handlers (pop from queue, dispatch)
|
||||
else if let Some(actor) = ctx.actors.get(stage_name.as_str()) {
|
||||
emit_actor_dispatch(
|
||||
actor,
|
||||
&mut step_ops,
|
||||
&mut regs,
|
||||
&actor_state_regions,
|
||||
&actor_field_offsets,
|
||||
&queue_regions,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Increment step counter
|
||||
let cur_step = regs.alloc();
|
||||
step_ops.push(Op::Load {
|
||||
size: OpSize::B64,
|
||||
region: step_counter_region.clone(),
|
||||
offset: 0,
|
||||
dest: cur_step,
|
||||
});
|
||||
let next_step = regs.alloc();
|
||||
step_ops.push(Op::ArithImm {
|
||||
kind: ArithKind::Add,
|
||||
lhs: cur_step,
|
||||
imm: 1,
|
||||
dest: next_step,
|
||||
});
|
||||
step_ops.push(Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: step_counter_region.clone(),
|
||||
offset: 0,
|
||||
src: next_step,
|
||||
});
|
||||
step_ops.push(Op::Jump { target: "loop_check".to_string() });
|
||||
|
||||
blocks.push(Block { name: "step".to_string(), ops: step_ops });
|
||||
|
||||
// --- Exit block ---
|
||||
blocks.push(Block {
|
||||
name: "exit".to_string(),
|
||||
ops: vec![Op::Terminate],
|
||||
});
|
||||
|
||||
Ok(L0Program { regions, blocks })
|
||||
}
|
||||
|
||||
/// Emit ops for a leaf's actions.
|
||||
fn emit_leaf_actions(
|
||||
leaf: &LeafDecl,
|
||||
_ctx: &EmitContext,
|
||||
ops: &mut Vec<Op>,
|
||||
regs: &mut RegAlloc,
|
||||
actor_state_regions: &HashMap<&str, String>,
|
||||
actor_field_offsets: &HashMap<(&str, &str), usize>,
|
||||
queue_regions: &HashMap<&str, String>,
|
||||
output_region: &Option<String>,
|
||||
) {
|
||||
for action in &leaf.actions {
|
||||
match action {
|
||||
LeafAction::Forward { target, args, .. } => {
|
||||
// Emit each argument value and push to the target actor's queue
|
||||
if let Some(queue_rgn) = queue_regions.get(target.as_str()) {
|
||||
for arg in args {
|
||||
let val_reg = emit_expr(arg, ops, regs);
|
||||
ops.push(Op::QueuePush {
|
||||
region: queue_rgn.clone(),
|
||||
src: val_reg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
LeafAction::ReadWindow { actor, field, .. } => {
|
||||
// Load from actor state region through the window
|
||||
if let Some(state_rgn) = actor_state_regions.get(actor.as_str()) {
|
||||
let offset = actor_field_offsets
|
||||
.get(&(actor.as_str(), field.as_str()))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
let _val = regs.alloc();
|
||||
ops.push(Op::Load {
|
||||
size: OpSize::B64,
|
||||
region: state_rgn.clone(),
|
||||
offset,
|
||||
dest: _val,
|
||||
});
|
||||
}
|
||||
}
|
||||
LeafAction::Emit(expr) => {
|
||||
if let Some(ref out_rgn) = output_region {
|
||||
// Evaluate the emit expression
|
||||
let val_reg = emit_leaf_emit_expr(
|
||||
expr,
|
||||
ops,
|
||||
regs,
|
||||
actor_state_regions,
|
||||
actor_field_offsets,
|
||||
&leaf.reads,
|
||||
);
|
||||
|
||||
// Push value to output region (queue layout: head at [0] tracks count)
|
||||
ops.push(Op::QueuePush {
|
||||
region: out_rgn.clone(),
|
||||
src: val_reg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit ops for an actor's message handler dispatch.
|
||||
fn emit_actor_dispatch(
|
||||
actor: &ActorDecl,
|
||||
ops: &mut Vec<Op>,
|
||||
regs: &mut RegAlloc,
|
||||
actor_state_regions: &HashMap<&str, String>,
|
||||
actor_field_offsets: &HashMap<(&str, &str), usize>,
|
||||
queue_regions: &HashMap<&str, String>,
|
||||
) {
|
||||
let state_rgn = match actor_state_regions.get(actor.name.as_str()) {
|
||||
Some(r) => r,
|
||||
None => return,
|
||||
};
|
||||
let queue_rgn = match queue_regions.get(actor.name.as_str()) {
|
||||
Some(r) => r,
|
||||
None => return,
|
||||
};
|
||||
|
||||
for handler in &actor.handlers {
|
||||
// Pop arguments from queue
|
||||
let mut arg_regs: HashMap<&str, Register> = HashMap::new();
|
||||
for (arg_name, _) in &handler.args {
|
||||
let reg = regs.alloc();
|
||||
ops.push(Op::QueuePop {
|
||||
region: queue_rgn.clone(),
|
||||
dest: reg,
|
||||
});
|
||||
arg_regs.insert(arg_name.as_str(), reg);
|
||||
}
|
||||
|
||||
// Execute handler body
|
||||
emit_handler_body(
|
||||
&handler.body,
|
||||
ops,
|
||||
regs,
|
||||
state_rgn,
|
||||
actor.name.as_str(),
|
||||
actor_field_offsets,
|
||||
&arg_regs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit ops for a handler body expression.
|
||||
fn emit_handler_body(
|
||||
expr: &Expr,
|
||||
ops: &mut Vec<Op>,
|
||||
regs: &mut RegAlloc,
|
||||
state_region: &str,
|
||||
actor_name: &str,
|
||||
field_offsets: &HashMap<(&str, &str), usize>,
|
||||
arg_regs: &HashMap<&str, Register>,
|
||||
) {
|
||||
match expr {
|
||||
Expr::Assign(target, value) => {
|
||||
let val_reg = emit_handler_expr(value, ops, regs, state_region, actor_name, field_offsets, arg_regs);
|
||||
if let Some(&offset) = field_offsets.get(&(actor_name, target.as_str())) {
|
||||
ops.push(Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: state_region.to_string(),
|
||||
offset,
|
||||
src: val_reg,
|
||||
});
|
||||
}
|
||||
}
|
||||
Expr::Block(exprs) => {
|
||||
for e in exprs {
|
||||
emit_handler_body(e, ops, regs, state_region, actor_name, field_offsets, arg_regs);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Expression with side effects — evaluate for value
|
||||
emit_handler_expr(expr, ops, regs, state_region, actor_name, field_offsets, arg_regs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit ops for a handler expression, returning the register holding the result.
|
||||
fn emit_handler_expr(
|
||||
expr: &Expr,
|
||||
ops: &mut Vec<Op>,
|
||||
regs: &mut RegAlloc,
|
||||
state_region: &str,
|
||||
actor_name: &str,
|
||||
field_offsets: &HashMap<(&str, &str), usize>,
|
||||
arg_regs: &HashMap<&str, Register>,
|
||||
) -> Register {
|
||||
match expr {
|
||||
Expr::Lit(val) => {
|
||||
let r = regs.alloc();
|
||||
ops.push(Op::LoadImm { value: *val, dest: r });
|
||||
r
|
||||
}
|
||||
Expr::Var(name) => {
|
||||
// Check if it's a handler argument
|
||||
if let Some(®) = arg_regs.get(name.as_str()) {
|
||||
return reg;
|
||||
}
|
||||
// Otherwise it's a state field — load it
|
||||
if let Some(&offset) = field_offsets.get(&(actor_name, name.as_str())) {
|
||||
let r = regs.alloc();
|
||||
ops.push(Op::Load {
|
||||
size: OpSize::B64,
|
||||
region: state_region.to_string(),
|
||||
offset,
|
||||
dest: r,
|
||||
});
|
||||
r
|
||||
} else {
|
||||
// Unknown variable — emit a load from offset 0 as fallback
|
||||
let r = regs.alloc();
|
||||
ops.push(Op::LoadImm { value: 0, dest: r });
|
||||
r
|
||||
}
|
||||
}
|
||||
Expr::Arith(op, lhs, rhs) => {
|
||||
let l = emit_handler_expr(lhs, ops, regs, state_region, actor_name, field_offsets, arg_regs);
|
||||
let r = emit_handler_expr(rhs, ops, regs, state_region, actor_name, field_offsets, arg_regs);
|
||||
let dest = regs.alloc();
|
||||
let kind = match op {
|
||||
ArithOp::Add => ArithKind::Add,
|
||||
ArithOp::Sub => ArithKind::Sub,
|
||||
ArithOp::Mul => ArithKind::Mul,
|
||||
};
|
||||
ops.push(Op::Arith { kind, lhs: l, rhs: r, dest });
|
||||
dest
|
||||
}
|
||||
Expr::Assign(target, value) => {
|
||||
let val_reg = emit_handler_expr(value, ops, regs, state_region, actor_name, field_offsets, arg_regs);
|
||||
if let Some(&offset) = field_offsets.get(&(actor_name, target.as_str())) {
|
||||
ops.push(Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: state_region.to_string(),
|
||||
offset,
|
||||
src: val_reg,
|
||||
});
|
||||
}
|
||||
val_reg
|
||||
}
|
||||
Expr::Block(exprs) => {
|
||||
let mut last_reg = regs.alloc();
|
||||
ops.push(Op::LoadImm { value: 0, dest: last_reg });
|
||||
for e in exprs {
|
||||
last_reg = emit_handler_expr(e, ops, regs, state_region, actor_name, field_offsets, arg_regs);
|
||||
}
|
||||
last_reg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a simple literal or variable expression for a leaf's forward arguments.
|
||||
fn emit_expr(expr: &Expr, ops: &mut Vec<Op>, regs: &mut RegAlloc) -> Register {
|
||||
match expr {
|
||||
Expr::Lit(val) => {
|
||||
let r = regs.alloc();
|
||||
ops.push(Op::LoadImm { value: *val, dest: r });
|
||||
r
|
||||
}
|
||||
Expr::Var(_) => {
|
||||
// Leaves are stateless — variables in forward args should only be literals
|
||||
let r = regs.alloc();
|
||||
ops.push(Op::LoadImm { value: 0, dest: r });
|
||||
r
|
||||
}
|
||||
Expr::Arith(op, lhs, rhs) => {
|
||||
let l = emit_expr(lhs, ops, regs);
|
||||
let r = emit_expr(rhs, ops, regs);
|
||||
let dest = regs.alloc();
|
||||
let kind = match op {
|
||||
ArithOp::Add => ArithKind::Add,
|
||||
ArithOp::Sub => ArithKind::Sub,
|
||||
ArithOp::Mul => ArithKind::Mul,
|
||||
};
|
||||
ops.push(Op::Arith { kind, lhs: l, rhs: r, dest });
|
||||
dest
|
||||
}
|
||||
_ => {
|
||||
let r = regs.alloc();
|
||||
ops.push(Op::LoadImm { value: 0, dest: r });
|
||||
r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit expression for a leaf's emit action. Handles variable references that
|
||||
/// may refer to window-read state fields.
|
||||
fn emit_leaf_emit_expr(
|
||||
expr: &Expr,
|
||||
ops: &mut Vec<Op>,
|
||||
regs: &mut RegAlloc,
|
||||
actor_state_regions: &HashMap<&str, String>,
|
||||
actor_field_offsets: &HashMap<(&str, &str), usize>,
|
||||
reads: &Option<ReadClause>,
|
||||
) -> Register {
|
||||
match expr {
|
||||
Expr::Lit(val) => {
|
||||
let r = regs.alloc();
|
||||
ops.push(Op::LoadImm { value: *val, dest: r });
|
||||
r
|
||||
}
|
||||
Expr::Var(name) => {
|
||||
// If the leaf has a reads clause, the variable might refer to a field
|
||||
// read through the window. Look it up in the actor's state.
|
||||
if let Some(reads) = reads {
|
||||
if let Some(state_rgn) = actor_state_regions.get(reads.actor.as_str()) {
|
||||
if let Some(&offset) = actor_field_offsets.get(&(reads.actor.as_str(), name.as_str())) {
|
||||
let r = regs.alloc();
|
||||
ops.push(Op::Load {
|
||||
size: OpSize::B64,
|
||||
region: state_rgn.clone(),
|
||||
offset,
|
||||
dest: r,
|
||||
});
|
||||
return r;
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = regs.alloc();
|
||||
ops.push(Op::LoadImm { value: 0, dest: r });
|
||||
r
|
||||
}
|
||||
Expr::Arith(op, lhs, rhs) => {
|
||||
let l = emit_leaf_emit_expr(lhs, ops, regs, actor_state_regions, actor_field_offsets, reads);
|
||||
let r = emit_leaf_emit_expr(rhs, ops, regs, actor_state_regions, actor_field_offsets, reads);
|
||||
let dest = regs.alloc();
|
||||
let kind = match op {
|
||||
ArithOp::Add => ArithKind::Add,
|
||||
ArithOp::Sub => ArithKind::Sub,
|
||||
ArithOp::Mul => ArithKind::Mul,
|
||||
};
|
||||
ops.push(Op::Arith { kind, lhs: l, rhs: r, dest });
|
||||
dest
|
||||
}
|
||||
_ => {
|
||||
let r = regs.alloc();
|
||||
ops.push(Op::LoadImm { value: 0, dest: r });
|
||||
r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the size in bytes of a field type.
|
||||
fn field_type_size(ty: &FieldType) -> usize {
|
||||
match ty {
|
||||
FieldType::U64 | FieldType::I64 | FieldType::F64 => 8,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::l1_ir::tests::{build_counter_program, build_window_program};
|
||||
|
||||
#[test]
|
||||
fn emit_counter_produces_regions_and_blocks() {
|
||||
let program = build_counter_program();
|
||||
let l0 = emit(&program).expect("emission should succeed");
|
||||
|
||||
// Should have regions: counter_state, increment_queue, step_counter
|
||||
assert!(l0.regions.iter().any(|r| r.name == "counter_state" && r.kind == RegionKind::State));
|
||||
assert!(l0.regions.iter().any(|r| r.kind == RegionKind::Queue));
|
||||
assert!(l0.regions.iter().any(|r| r.name == "step_counter" && r.kind == RegionKind::Control));
|
||||
|
||||
// Should have 4 blocks: entry, loop_check, step, exit
|
||||
assert_eq!(l0.blocks.len(), 4);
|
||||
assert_eq!(l0.blocks[0].name, "entry");
|
||||
assert_eq!(l0.blocks[1].name, "loop_check");
|
||||
assert_eq!(l0.blocks[2].name, "step");
|
||||
assert_eq!(l0.blocks[3].name, "exit");
|
||||
|
||||
// Exit block has terminate
|
||||
assert!(l0.blocks[3].ops.iter().any(|op| matches!(op, Op::Terminate)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_counter_has_explicit_ops() {
|
||||
let program = build_counter_program();
|
||||
let l0 = emit(&program).expect("emission should succeed");
|
||||
let text = format!("{}", l0);
|
||||
|
||||
// Verify all expected control IR ops appear
|
||||
assert!(text.contains("load.64"), "missing load ops");
|
||||
assert!(text.contains("store.64"), "missing store ops");
|
||||
assert!(text.contains("cmp.lt"), "missing cmp op");
|
||||
assert!(text.contains("branch"), "missing branch op");
|
||||
assert!(text.contains("jump"), "missing jump op");
|
||||
assert!(text.contains("queue_push"), "missing queue_push op");
|
||||
assert!(text.contains("queue_pop"), "missing queue_pop op");
|
||||
assert!(text.contains("terminate"), "missing terminate op");
|
||||
assert!(text.contains("add"), "missing arith add op");
|
||||
assert!(text.contains("imm #"), "missing load imm op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_counter_l0_display_is_readable() {
|
||||
let program = build_counter_program();
|
||||
let l0 = emit(&program).expect("emission should succeed");
|
||||
let text = format!("{}", l0);
|
||||
|
||||
assert!(text.contains("=== Regions ==="));
|
||||
assert!(text.contains("=== Blocks ==="));
|
||||
assert!(text.contains("region counter_state"));
|
||||
assert!(text.contains("step_counter"));
|
||||
assert!(text.contains("entry:"));
|
||||
assert!(text.contains("loop_check:"));
|
||||
assert!(text.contains("step:"));
|
||||
assert!(text.contains("exit:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_window_produces_output_region() {
|
||||
let program = build_window_program();
|
||||
let l0 = emit(&program).expect("emission should succeed");
|
||||
|
||||
// Window example has emit actions, so output region should exist
|
||||
assert!(l0.regions.iter().any(|r| r.name == "output" && r.kind == RegionKind::Output));
|
||||
assert!(l0.regions.iter().any(|r| r.name == "accumulator_state" && r.kind == RegionKind::State));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_window_has_queue_push_for_emit() {
|
||||
let program = build_window_program();
|
||||
let l0 = emit(&program).expect("emission should succeed");
|
||||
let text = format!("{}", l0);
|
||||
|
||||
// The emit action should produce a queue_push to the output region
|
||||
assert!(text.contains("queue_push output"), "emit should produce queue_push to output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_counter_matches_expected_structure() {
|
||||
// Build expected L0 IR by hand and compare against emitted output
|
||||
let program = build_counter_program();
|
||||
let l0 = emit(&program).expect("emission should succeed");
|
||||
|
||||
// Verify region structure
|
||||
assert_eq!(l0.regions.len(), 3); // state, queue, step_counter (no output for counter)
|
||||
|
||||
// Verify entry block initializes state to 0
|
||||
let entry = &l0.blocks[0];
|
||||
assert!(entry.ops.iter().any(|op| matches!(op, Op::LoadImm { value: 0, .. })));
|
||||
assert!(entry.ops.iter().any(|op| matches!(op, Op::Store { region, .. } if region == "counter_state")));
|
||||
assert!(entry.ops.iter().any(|op| matches!(op, Op::Store { region, .. } if region == "step_counter")));
|
||||
assert!(entry.ops.last().unwrap() == &Op::Jump { target: "loop_check".to_string() });
|
||||
|
||||
// Verify loop_check compares step counter
|
||||
let check = &l0.blocks[1];
|
||||
assert!(check.ops.iter().any(|op| matches!(op, Op::Load { region, .. } if region == "step_counter")));
|
||||
assert!(check.ops.iter().any(|op| matches!(op, Op::LoadImm { value: 5, .. })));
|
||||
assert!(check.ops.iter().any(|op| matches!(op, Op::Cmp { kind: CmpKind::Lt, .. })));
|
||||
assert!(check.ops.iter().any(|op| matches!(op, Op::Branch { true_target, false_target, .. }
|
||||
if true_target == "step" && false_target == "exit")));
|
||||
|
||||
// Verify step block has queue_push (ticker forward), queue_pop (handler), arith, store
|
||||
let step = &l0.blocks[2];
|
||||
assert!(step.ops.iter().any(|op| matches!(op, Op::QueuePush { .. })));
|
||||
assert!(step.ops.iter().any(|op| matches!(op, Op::QueuePop { .. })));
|
||||
assert!(step.ops.iter().any(|op| matches!(op, Op::Arith { kind: ArithKind::Add, .. })));
|
||||
|
||||
// Step counter increment
|
||||
assert!(step.ops.iter().any(|op| matches!(op, Op::ArithImm { kind: ArithKind::Add, imm: 1, .. })));
|
||||
assert!(step.ops.last().unwrap() == &Op::Jump { target: "loop_check".to_string() });
|
||||
|
||||
// Verify exit block
|
||||
assert_eq!(l0.blocks[3].ops, vec![Op::Terminate]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_from_parsed_counter() {
|
||||
let input = include_str!("../examples/counter.l1");
|
||||
let parsed = crate::parser::parse(input).expect("should parse");
|
||||
crate::verify_l1::verify(&parsed).expect("should verify");
|
||||
let l0 = emit(&parsed).expect("should emit");
|
||||
|
||||
assert!(l0.regions.iter().any(|r| r.name == "counter_state"));
|
||||
assert_eq!(l0.blocks.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_from_parsed_window() {
|
||||
let input = include_str!("../examples/window.l1");
|
||||
let parsed = crate::parser::parse(input).expect("should parse");
|
||||
crate::verify_l1::verify(&parsed).expect("should verify");
|
||||
let l0 = emit(&parsed).expect("should emit");
|
||||
|
||||
assert!(l0.regions.iter().any(|r| r.name == "accumulator_state"));
|
||||
assert!(l0.regions.iter().any(|r| r.name == "output"));
|
||||
assert_eq!(l0.blocks.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_error_on_empty_program() {
|
||||
let program = L1Program {
|
||||
actors: vec![],
|
||||
leaves: vec![],
|
||||
pipelines: vec![],
|
||||
cores: vec![],
|
||||
};
|
||||
let err = emit(&program).unwrap_err();
|
||||
assert!(err.message.contains("no cores"));
|
||||
}
|
||||
}
|
||||
556
src/interpret_l1.rs
Normal file
556
src/interpret_l1.rs
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
//! L1 Reference Interpreter — executes L1 IR directly without lowering to L0.
|
||||
//!
|
||||
//! This is the *reference semantics* for L1: the specification of what L1 means.
|
||||
//! The interpreter produces execution traces used by the translation validation
|
||||
//! engine (Stage 2) to verify that L0 lowering preserves semantics.
|
||||
|
||||
use crate::l1_ir::*;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::fmt;
|
||||
|
||||
/// A snapshot of one actor's state at a point in time.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ActorSnapshot {
|
||||
pub actor_name: String,
|
||||
pub fields: Vec<(String, u64)>,
|
||||
}
|
||||
|
||||
/// A message that was sent during execution.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Message {
|
||||
pub target: String,
|
||||
pub message_type: String,
|
||||
pub args: Vec<u64>,
|
||||
}
|
||||
|
||||
/// A window read performed during execution.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct WindowRead {
|
||||
pub actor: String,
|
||||
pub window: String,
|
||||
pub field: String,
|
||||
pub value: u64,
|
||||
}
|
||||
|
||||
/// A single step in the execution trace.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TraceStep {
|
||||
pub step_index: u64,
|
||||
pub actor_states: Vec<ActorSnapshot>,
|
||||
pub messages_sent: Vec<Message>,
|
||||
pub window_reads: Vec<WindowRead>,
|
||||
pub emitted_outputs: Vec<u64>,
|
||||
}
|
||||
|
||||
/// The complete execution trace produced by the interpreter.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ExecutionTrace {
|
||||
pub steps: Vec<TraceStep>,
|
||||
pub final_actor_states: Vec<ActorSnapshot>,
|
||||
pub all_outputs: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Errors from the L1 interpreter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InterpretError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for InterpretError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "interpret error: {}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InterpretError {}
|
||||
|
||||
/// Runtime state for the interpreter.
|
||||
struct InterpreterState {
|
||||
/// Current actor field values: actor_name -> field_name -> value
|
||||
actor_state: HashMap<String, HashMap<String, u64>>,
|
||||
/// Message queues: actor_name -> queue of (message_type, args)
|
||||
queues: HashMap<String, VecDeque<(String, Vec<u64>)>>,
|
||||
/// Accumulated output values
|
||||
outputs: Vec<u64>,
|
||||
}
|
||||
|
||||
impl InterpreterState {
|
||||
fn new(program: &L1Program) -> Self {
|
||||
let mut actor_state = HashMap::new();
|
||||
for actor in &program.actors {
|
||||
let mut fields = HashMap::new();
|
||||
for field in &actor.state {
|
||||
fields.insert(field.name.clone(), field.init);
|
||||
}
|
||||
actor_state.insert(actor.name.clone(), fields);
|
||||
}
|
||||
|
||||
let mut queues = HashMap::new();
|
||||
for actor in &program.actors {
|
||||
queues.insert(actor.name.clone(), VecDeque::new());
|
||||
}
|
||||
|
||||
InterpreterState {
|
||||
actor_state,
|
||||
queues,
|
||||
outputs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_actors(&self, program: &L1Program) -> Vec<ActorSnapshot> {
|
||||
program.actors.iter().map(|actor| {
|
||||
let fields = actor.state.iter().map(|f| {
|
||||
let val = self.actor_state
|
||||
.get(&actor.name)
|
||||
.and_then(|s| s.get(&f.name))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
(f.name.clone(), val)
|
||||
}).collect();
|
||||
ActorSnapshot {
|
||||
actor_name: actor.name.clone(),
|
||||
fields,
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret an L1 program directly, producing an execution trace.
|
||||
///
|
||||
/// Executes the first core's pipelines for the configured number of steps.
|
||||
/// Each step walks pipeline stages in order: leaves forward messages,
|
||||
/// actors dispatch handlers, windows are read, outputs are emitted.
|
||||
pub fn interpret(program: &L1Program) -> Result<ExecutionTrace, InterpretError> {
|
||||
if program.cores.is_empty() {
|
||||
return Err(InterpretError {
|
||||
message: "no cores defined".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let core = &program.cores[0];
|
||||
let actors: HashMap<&str, &ActorDecl> = program.actors.iter()
|
||||
.map(|a| (a.name.as_str(), a))
|
||||
.collect();
|
||||
let leaves: HashMap<&str, &LeafDecl> = program.leaves.iter()
|
||||
.map(|l| (l.name.as_str(), l))
|
||||
.collect();
|
||||
|
||||
let mut state = InterpreterState::new(program);
|
||||
let mut trace_steps = Vec::new();
|
||||
|
||||
for step_idx in 0..core.steps {
|
||||
let mut messages_sent = Vec::new();
|
||||
let mut window_reads = Vec::new();
|
||||
let mut step_outputs = Vec::new();
|
||||
|
||||
// Walk each pipeline's stages in order
|
||||
for pipe_name in &core.pipelines {
|
||||
let pipeline = program.pipelines.iter()
|
||||
.find(|p| p.name == *pipe_name)
|
||||
.ok_or_else(|| InterpretError {
|
||||
message: format!("pipeline '{}' not found", pipe_name),
|
||||
})?;
|
||||
|
||||
for stage_name in &pipeline.stages {
|
||||
if let Some(leaf) = leaves.get(stage_name.as_str()) {
|
||||
// Execute leaf actions
|
||||
execute_leaf(
|
||||
leaf,
|
||||
&actors,
|
||||
&mut state,
|
||||
&mut messages_sent,
|
||||
&mut window_reads,
|
||||
&mut step_outputs,
|
||||
);
|
||||
} else if let Some(actor) = actors.get(stage_name.as_str()) {
|
||||
// Dispatch actor messages
|
||||
execute_actor(actor, &mut state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.outputs.extend(&step_outputs);
|
||||
|
||||
trace_steps.push(TraceStep {
|
||||
step_index: step_idx,
|
||||
actor_states: state.snapshot_actors(program),
|
||||
messages_sent,
|
||||
window_reads,
|
||||
emitted_outputs: step_outputs,
|
||||
});
|
||||
}
|
||||
|
||||
let final_states = state.snapshot_actors(program);
|
||||
let all_outputs = state.outputs.clone();
|
||||
|
||||
Ok(ExecutionTrace {
|
||||
steps: trace_steps,
|
||||
final_actor_states: final_states,
|
||||
all_outputs,
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a leaf's actions.
|
||||
fn execute_leaf(
|
||||
leaf: &LeafDecl,
|
||||
_actors: &HashMap<&str, &ActorDecl>,
|
||||
state: &mut InterpreterState,
|
||||
messages_sent: &mut Vec<Message>,
|
||||
window_reads: &mut Vec<WindowRead>,
|
||||
step_outputs: &mut Vec<u64>,
|
||||
) {
|
||||
// Track values read through windows for use in emit expressions
|
||||
let mut read_values: HashMap<String, u64> = HashMap::new();
|
||||
|
||||
for action in &leaf.actions {
|
||||
match action {
|
||||
LeafAction::Forward { target, message_type, args } => {
|
||||
let arg_vals: Vec<u64> = args.iter()
|
||||
.map(|e| eval_const_expr(e))
|
||||
.collect();
|
||||
|
||||
// Enqueue message for the target actor
|
||||
if let Some(queue) = state.queues.get_mut(target.as_str()) {
|
||||
queue.push_back((message_type.clone(), arg_vals.clone()));
|
||||
}
|
||||
|
||||
messages_sent.push(Message {
|
||||
target: target.clone(),
|
||||
message_type: message_type.clone(),
|
||||
args: arg_vals,
|
||||
});
|
||||
}
|
||||
LeafAction::ReadWindow { actor, window, field } => {
|
||||
let value = state.actor_state
|
||||
.get(actor.as_str())
|
||||
.and_then(|s| s.get(field.as_str()))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
|
||||
read_values.insert(field.clone(), value);
|
||||
|
||||
window_reads.push(WindowRead {
|
||||
actor: actor.clone(),
|
||||
window: window.clone(),
|
||||
field: field.clone(),
|
||||
value,
|
||||
});
|
||||
}
|
||||
LeafAction::Emit(expr) => {
|
||||
let value = eval_emit_expr(expr, &read_values, &leaf.reads, &state.actor_state);
|
||||
step_outputs.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute an actor's message handlers by popping from its queue.
|
||||
fn execute_actor(actor: &ActorDecl, state: &mut InterpreterState) {
|
||||
let queue = match state.queues.get_mut(actor.name.as_str()) {
|
||||
Some(q) => q,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Process each handler — pop messages matching the handler's type
|
||||
for handler in &actor.handlers {
|
||||
if let Some((_msg_type, args)) = queue.pop_front() {
|
||||
// Build variable environment: handler args + current state fields
|
||||
let mut env: HashMap<String, u64> = HashMap::new();
|
||||
|
||||
// Bind handler arguments
|
||||
for (i, (arg_name, _)) in handler.args.iter().enumerate() {
|
||||
if i < args.len() {
|
||||
env.insert(arg_name.clone(), args[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Bind current state field values
|
||||
if let Some(fields) = state.actor_state.get(&actor.name) {
|
||||
for (name, val) in fields {
|
||||
env.insert(name.clone(), *val);
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate handler body
|
||||
eval_handler_body(&handler.body, &mut env);
|
||||
|
||||
// Write back state fields that were assigned
|
||||
if let Some(fields) = state.actor_state.get_mut(&actor.name) {
|
||||
for (name, val) in fields.iter_mut() {
|
||||
if let Some(&new_val) = env.get(name) {
|
||||
*val = new_val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a constant expression (for leaf forward args — leaves are stateless).
|
||||
fn eval_const_expr(expr: &Expr) -> u64 {
|
||||
match expr {
|
||||
Expr::Lit(v) => *v,
|
||||
Expr::Arith(op, lhs, rhs) => {
|
||||
let l = eval_const_expr(lhs);
|
||||
let r = eval_const_expr(rhs);
|
||||
apply_arith(*op, l, r)
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate an emit expression. Variables may refer to window-read fields.
|
||||
fn eval_emit_expr(
|
||||
expr: &Expr,
|
||||
read_values: &HashMap<String, u64>,
|
||||
reads: &Option<ReadClause>,
|
||||
actor_state: &HashMap<String, HashMap<String, u64>>,
|
||||
) -> u64 {
|
||||
match expr {
|
||||
Expr::Lit(v) => *v,
|
||||
Expr::Var(name) => {
|
||||
// First check if we have a value from a prior ReadWindow action
|
||||
if let Some(&val) = read_values.get(name.as_str()) {
|
||||
return val;
|
||||
}
|
||||
// Fall back to reading through the reads clause
|
||||
if let Some(rc) = reads {
|
||||
if let Some(fields) = actor_state.get(&rc.actor) {
|
||||
if let Some(&val) = fields.get(name.as_str()) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
Expr::Arith(op, lhs, rhs) => {
|
||||
let l = eval_emit_expr(lhs, read_values, reads, actor_state);
|
||||
let r = eval_emit_expr(rhs, read_values, reads, actor_state);
|
||||
apply_arith(*op, l, r)
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a handler body expression, mutating the environment for assignments.
|
||||
fn eval_handler_body(expr: &Expr, env: &mut HashMap<String, u64>) -> u64 {
|
||||
match expr {
|
||||
Expr::Lit(v) => *v,
|
||||
Expr::Var(name) => env.get(name.as_str()).copied().unwrap_or(0),
|
||||
Expr::Arith(op, lhs, rhs) => {
|
||||
let l = eval_handler_body(lhs, env);
|
||||
let r = eval_handler_body(rhs, env);
|
||||
apply_arith(*op, l, r)
|
||||
}
|
||||
Expr::Assign(target, value) => {
|
||||
let val = eval_handler_body(value, env);
|
||||
env.insert(target.clone(), val);
|
||||
val
|
||||
}
|
||||
Expr::Block(exprs) => {
|
||||
let mut last = 0;
|
||||
for e in exprs {
|
||||
last = eval_handler_body(e, env);
|
||||
}
|
||||
last
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an arithmetic operation.
|
||||
fn apply_arith(op: ArithOp, l: u64, r: u64) -> u64 {
|
||||
match op {
|
||||
ArithOp::Add => l.wrapping_add(r),
|
||||
ArithOp::Sub => l.wrapping_sub(r),
|
||||
ArithOp::Mul => l.wrapping_mul(r),
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ExecutionTrace {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for step in &self.steps {
|
||||
writeln!(f, "--- Step {} ---", step.step_index)?;
|
||||
for msg in &step.messages_sent {
|
||||
writeln!(f, " send {}({:?}) -> {}", msg.message_type, msg.args, msg.target)?;
|
||||
}
|
||||
for wr in &step.window_reads {
|
||||
writeln!(f, " read {}.{}.{} = {}", wr.actor, wr.window, wr.field, wr.value)?;
|
||||
}
|
||||
for out in &step.emitted_outputs {
|
||||
writeln!(f, " emit {}", out)?;
|
||||
}
|
||||
for snap in &step.actor_states {
|
||||
let fields: Vec<String> = snap.fields.iter()
|
||||
.map(|(n, v)| format!("{}={}", n, v))
|
||||
.collect();
|
||||
writeln!(f, " {} {{ {} }}", snap.actor_name, fields.join(", "))?;
|
||||
}
|
||||
}
|
||||
writeln!(f, "--- Final ---")?;
|
||||
for snap in &self.final_actor_states {
|
||||
let fields: Vec<String> = snap.fields.iter()
|
||||
.map(|(n, v)| format!("{}={}", n, v))
|
||||
.collect();
|
||||
writeln!(f, " {} {{ {} }}", snap.actor_name, fields.join(", "))?;
|
||||
}
|
||||
if !self.all_outputs.is_empty() {
|
||||
writeln!(f, " outputs: {:?}", self.all_outputs)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::l1_ir::tests::{build_counter_program, build_window_program};
|
||||
|
||||
#[test]
|
||||
fn interpret_counter_matches_l0_execution() {
|
||||
// Counter: 5 steps of Increment(1), expect count=5
|
||||
let program = build_counter_program();
|
||||
let trace = interpret(&program).expect("should interpret");
|
||||
|
||||
// Final state should match L0 execution
|
||||
assert_eq!(trace.final_actor_states.len(), 1);
|
||||
assert_eq!(trace.final_actor_states[0].actor_name, "counter");
|
||||
assert_eq!(trace.final_actor_states[0].fields, vec![("count".to_string(), 5)]);
|
||||
|
||||
// Should have 5 trace steps
|
||||
assert_eq!(trace.steps.len(), 5);
|
||||
|
||||
// No outputs (counter has no emit)
|
||||
assert!(trace.all_outputs.is_empty());
|
||||
|
||||
// Each step should send one Increment message
|
||||
for (i, step) in trace.steps.iter().enumerate() {
|
||||
assert_eq!(step.messages_sent.len(), 1);
|
||||
assert_eq!(step.messages_sent[0].target, "counter");
|
||||
assert_eq!(step.messages_sent[0].message_type, "Increment");
|
||||
assert_eq!(step.messages_sent[0].args, vec![1]);
|
||||
|
||||
// State after step i should be count = i+1
|
||||
assert_eq!(
|
||||
step.actor_states[0].fields,
|
||||
vec![("count".to_string(), (i as u64) + 1)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_window_matches_l0_execution() {
|
||||
// Window: 3 steps of Add(10), expect total=30, outputs=[10,20,30]
|
||||
let program = build_window_program();
|
||||
let trace = interpret(&program).expect("should interpret");
|
||||
|
||||
// Final state
|
||||
assert_eq!(trace.final_actor_states[0].actor_name, "accumulator");
|
||||
assert_eq!(trace.final_actor_states[0].fields, vec![("total".to_string(), 30)]);
|
||||
|
||||
// Outputs match L0 execution
|
||||
assert_eq!(trace.all_outputs, vec![10, 20, 30]);
|
||||
|
||||
// 3 trace steps
|
||||
assert_eq!(trace.steps.len(), 3);
|
||||
|
||||
// Each step emits one value
|
||||
for (i, step) in trace.steps.iter().enumerate() {
|
||||
assert_eq!(step.emitted_outputs, vec![(i as u64 + 1) * 10]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_product_matches_l0_execution() {
|
||||
// Product: 4 steps of Purchase(7, 3)
|
||||
// count = 4*3 = 12, total = 4*(7*3) = 84, outputs = [21, 42, 63, 84]
|
||||
let source = include_str!("../examples/product.l1");
|
||||
let program = crate::parser::parse(source).expect("should parse");
|
||||
let trace = interpret(&program).expect("should interpret");
|
||||
|
||||
// Final state
|
||||
let ledger = &trace.final_actor_states[0];
|
||||
assert_eq!(ledger.actor_name, "ledger");
|
||||
// Fields should be count=12, total=84
|
||||
let count = ledger.fields.iter().find(|(n, _)| n == "count").unwrap().1;
|
||||
let total = ledger.fields.iter().find(|(n, _)| n == "total").unwrap().1;
|
||||
assert_eq!(count, 12);
|
||||
assert_eq!(total, 84);
|
||||
|
||||
// Outputs
|
||||
assert_eq!(trace.all_outputs, vec![21, 42, 63, 84]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_counter_from_file_matches_pipeline() {
|
||||
// Cross-validate: L1 interpreter vs L0 pipeline execution
|
||||
let source = include_str!("../examples/counter.l1");
|
||||
let program = crate::parser::parse(source).expect("should parse");
|
||||
let trace = interpret(&program).expect("should interpret");
|
||||
|
||||
let (result, _) = crate::pipeline::run(source).expect("pipeline should run");
|
||||
let l0_count = result.read_u64("counter_state", 0).expect("read count");
|
||||
|
||||
let l1_count = trace.final_actor_states[0].fields[0].1;
|
||||
assert_eq!(l1_count, l0_count, "L1 interpreter and L0 pipeline must agree");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_window_from_file_matches_pipeline() {
|
||||
let source = include_str!("../examples/window.l1");
|
||||
let program = crate::parser::parse(source).expect("should parse");
|
||||
let trace = interpret(&program).expect("should interpret");
|
||||
|
||||
let (result, _) = crate::pipeline::run(source).expect("pipeline should run");
|
||||
let l0_total = result.read_u64("accumulator_state", 0).expect("read total");
|
||||
let l0_outputs = result.read_output_values();
|
||||
|
||||
let l1_total = trace.final_actor_states[0].fields[0].1;
|
||||
assert_eq!(l1_total, l0_total, "L1 and L0 final state must match");
|
||||
assert_eq!(trace.all_outputs, l0_outputs, "L1 and L0 outputs must match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_product_from_file_matches_pipeline() {
|
||||
let source = include_str!("../examples/product.l1");
|
||||
let program = crate::parser::parse(source).expect("should parse");
|
||||
let trace = interpret(&program).expect("should interpret");
|
||||
|
||||
let (result, _) = crate::pipeline::run(source).expect("pipeline should run");
|
||||
let l0_count = result.read_u64("ledger_state", 0).expect("read count");
|
||||
let l0_total = result.read_u64("ledger_state", 8).expect("read total");
|
||||
let l0_outputs = result.read_output_values();
|
||||
|
||||
let l1_count = trace.final_actor_states[0].fields.iter()
|
||||
.find(|(n, _)| n == "count").unwrap().1;
|
||||
let l1_total = trace.final_actor_states[0].fields.iter()
|
||||
.find(|(n, _)| n == "total").unwrap().1;
|
||||
|
||||
assert_eq!(l1_count, l0_count, "L1 and L0 count must match");
|
||||
assert_eq!(l1_total, l0_total, "L1 and L0 total must match");
|
||||
assert_eq!(trace.all_outputs, l0_outputs, "L1 and L0 outputs must match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_error_on_empty_program() {
|
||||
let program = L1Program {
|
||||
actors: vec![],
|
||||
leaves: vec![],
|
||||
pipelines: vec![],
|
||||
cores: vec![],
|
||||
};
|
||||
let err = interpret(&program).unwrap_err();
|
||||
assert!(err.message.contains("no cores"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_trace_display() {
|
||||
let program = build_counter_program();
|
||||
let trace = interpret(&program).expect("should interpret");
|
||||
let text = format!("{}", trace);
|
||||
assert!(text.contains("Step 0"));
|
||||
assert!(text.contains("Step 4"));
|
||||
assert!(text.contains("Final"));
|
||||
assert!(text.contains("counter"));
|
||||
assert!(text.contains("count=5"));
|
||||
}
|
||||
}
|
||||
603
src/l0_ir.rs
Normal file
603
src/l0_ir.rs
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
use std::fmt;
|
||||
|
||||
/// Access mode for a memory region.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum AccessMode {
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
impl fmt::Display for AccessMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
AccessMode::ReadOnly => write!(f, "ro"),
|
||||
AccessMode::ReadWrite => write!(f, "rw"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named, sized memory region with an access mode.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Region {
|
||||
pub name: String,
|
||||
pub size: usize,
|
||||
pub access: AccessMode,
|
||||
pub kind: RegionKind,
|
||||
}
|
||||
|
||||
/// The kind of region — distinguishes actor state, queues, and control data.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum RegionKind {
|
||||
/// Persistent actor state.
|
||||
State,
|
||||
/// Message queue (ring buffer).
|
||||
Queue,
|
||||
/// Step counter / control metadata.
|
||||
Control,
|
||||
/// Output/observation buffer.
|
||||
Output,
|
||||
}
|
||||
|
||||
impl fmt::Display for RegionKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
RegionKind::State => write!(f, "state"),
|
||||
RegionKind::Queue => write!(f, "queue"),
|
||||
RegionKind::Control => write!(f, "control"),
|
||||
RegionKind::Output => write!(f, "output"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Region {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "region {} : {} bytes, {}, {}", self.name, self.size, self.access, self.kind)
|
||||
}
|
||||
}
|
||||
|
||||
/// A memory slot within a region.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MemorySlot {
|
||||
pub region: String,
|
||||
pub offset: usize,
|
||||
pub size: usize,
|
||||
}
|
||||
|
||||
impl fmt::Display for MemorySlot {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}[{}..{}]", self.region, self.offset, self.offset + self.size)
|
||||
}
|
||||
}
|
||||
|
||||
/// SSA register.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct Register(pub u32);
|
||||
|
||||
impl fmt::Display for Register {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "r{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Size of a load/store operation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum OpSize {
|
||||
B8,
|
||||
B16,
|
||||
B32,
|
||||
B64,
|
||||
}
|
||||
|
||||
impl OpSize {
|
||||
pub fn bytes(&self) -> usize {
|
||||
match self {
|
||||
OpSize::B8 => 1,
|
||||
OpSize::B16 => 2,
|
||||
OpSize::B32 => 4,
|
||||
OpSize::B64 => 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for OpSize {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
OpSize::B8 => write!(f, "8"),
|
||||
OpSize::B16 => write!(f, "16"),
|
||||
OpSize::B32 => write!(f, "32"),
|
||||
OpSize::B64 => write!(f, "64"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arithmetic operation kind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ArithKind {
|
||||
Add,
|
||||
Sub,
|
||||
Mul,
|
||||
And,
|
||||
Or,
|
||||
Shl,
|
||||
}
|
||||
|
||||
impl fmt::Display for ArithKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ArithKind::Add => write!(f, "add"),
|
||||
ArithKind::Sub => write!(f, "sub"),
|
||||
ArithKind::Mul => write!(f, "mul"),
|
||||
ArithKind::And => write!(f, "and"),
|
||||
ArithKind::Or => write!(f, "or"),
|
||||
ArithKind::Shl => write!(f, "shl"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Comparison operation kind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum CmpKind {
|
||||
Eq,
|
||||
Neq,
|
||||
Lt,
|
||||
Gt,
|
||||
}
|
||||
|
||||
impl fmt::Display for CmpKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
CmpKind::Eq => write!(f, "eq"),
|
||||
CmpKind::Neq => write!(f, "neq"),
|
||||
CmpKind::Lt => write!(f, "lt"),
|
||||
CmpKind::Gt => write!(f, "gt"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 10 Control IR operations.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Op {
|
||||
/// load.{sz} rgn[off] -> reg
|
||||
Load {
|
||||
size: OpSize,
|
||||
region: String,
|
||||
offset: usize,
|
||||
dest: Register,
|
||||
},
|
||||
/// store.{sz} rgn[off], val
|
||||
Store {
|
||||
size: OpSize,
|
||||
region: String,
|
||||
offset: usize,
|
||||
src: Register,
|
||||
},
|
||||
/// hash val -> reg
|
||||
Hash {
|
||||
src: Register,
|
||||
dest: Register,
|
||||
},
|
||||
/// arith {op} a, b -> reg
|
||||
Arith {
|
||||
kind: ArithKind,
|
||||
lhs: Register,
|
||||
rhs: Register,
|
||||
dest: Register,
|
||||
},
|
||||
/// arith {op} a, imm -> reg (immediate variant)
|
||||
ArithImm {
|
||||
kind: ArithKind,
|
||||
lhs: Register,
|
||||
imm: u64,
|
||||
dest: Register,
|
||||
},
|
||||
/// cmp {op} a, b -> reg
|
||||
Cmp {
|
||||
kind: CmpKind,
|
||||
lhs: Register,
|
||||
rhs: Register,
|
||||
dest: Register,
|
||||
},
|
||||
/// cmp {op} a, imm -> reg (immediate variant)
|
||||
CmpImm {
|
||||
kind: CmpKind,
|
||||
lhs: Register,
|
||||
imm: u64,
|
||||
dest: Register,
|
||||
},
|
||||
/// branch reg -> blk_true, blk_false
|
||||
Branch {
|
||||
cond: Register,
|
||||
true_target: String,
|
||||
false_target: String,
|
||||
},
|
||||
/// jump -> blk
|
||||
Jump {
|
||||
target: String,
|
||||
},
|
||||
/// queue_push rgn, val
|
||||
QueuePush {
|
||||
region: String,
|
||||
src: Register,
|
||||
},
|
||||
/// queue_pop rgn -> reg
|
||||
QueuePop {
|
||||
region: String,
|
||||
dest: Register,
|
||||
},
|
||||
/// terminate
|
||||
Terminate,
|
||||
/// Load immediate value into register
|
||||
LoadImm {
|
||||
value: u64,
|
||||
dest: Register,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for Op {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Op::Load { size, region, offset, dest } =>
|
||||
write!(f, "{} = load.{} {}[{}]", dest, size, region, offset),
|
||||
Op::Store { size, region, offset, src } =>
|
||||
write!(f, "store.{} {}[{}], {}", size, region, offset, src),
|
||||
Op::Hash { src, dest } =>
|
||||
write!(f, "{} = hash {}", dest, src),
|
||||
Op::Arith { kind, lhs, rhs, dest } =>
|
||||
write!(f, "{} = {} {}, {}", dest, kind, lhs, rhs),
|
||||
Op::ArithImm { kind, lhs, imm, dest } =>
|
||||
write!(f, "{} = {} {}, #{}", dest, kind, lhs, imm),
|
||||
Op::Cmp { kind, lhs, rhs, dest } =>
|
||||
write!(f, "{} = cmp.{} {}, {}", dest, kind, lhs, rhs),
|
||||
Op::CmpImm { kind, lhs, imm, dest } =>
|
||||
write!(f, "{} = cmp.{} {}, #{}", dest, kind, lhs, imm),
|
||||
Op::Branch { cond, true_target, false_target } =>
|
||||
write!(f, "branch {} -> {}, {}", cond, true_target, false_target),
|
||||
Op::Jump { target } =>
|
||||
write!(f, "jump -> {}", target),
|
||||
Op::QueuePush { region, src } =>
|
||||
write!(f, "queue_push {}, {}", region, src),
|
||||
Op::QueuePop { region, dest } =>
|
||||
write!(f, "{} = queue_pop {}", dest, region),
|
||||
Op::Terminate =>
|
||||
write!(f, "terminate"),
|
||||
Op::LoadImm { value, dest } =>
|
||||
write!(f, "{} = imm #{}", dest, value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A basic block — a named sequence of operations.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Block {
|
||||
pub name: String,
|
||||
pub ops: Vec<Op>,
|
||||
}
|
||||
|
||||
impl fmt::Display for Block {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
writeln!(f, "{}:", self.name)?;
|
||||
for op in &self.ops {
|
||||
writeln!(f, " {}", op)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The complete L0 IR program.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct L0Program {
|
||||
pub regions: Vec<Region>,
|
||||
pub blocks: Vec<Block>,
|
||||
}
|
||||
|
||||
impl fmt::Display for L0Program {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
writeln!(f, "=== Regions ===")?;
|
||||
for region in &self.regions {
|
||||
writeln!(f, " {}", region)?;
|
||||
}
|
||||
writeln!(f, "=== Blocks ===")?;
|
||||
for block in &self.blocks {
|
||||
write!(f, "{}", block)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn construct_region() {
|
||||
let r = Region {
|
||||
name: "counter_state".to_string(),
|
||||
size: 8,
|
||||
access: AccessMode::ReadWrite,
|
||||
kind: RegionKind::State,
|
||||
};
|
||||
assert_eq!(r.name, "counter_state");
|
||||
assert_eq!(r.size, 8);
|
||||
assert_eq!(r.access, AccessMode::ReadWrite);
|
||||
assert_eq!(r.kind, RegionKind::State);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construct_memory_slot() {
|
||||
let slot = MemorySlot {
|
||||
region: "counter_state".to_string(),
|
||||
offset: 0,
|
||||
size: 8,
|
||||
};
|
||||
assert_eq!(format!("{}", slot), "counter_state[0..8]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construct_ops() {
|
||||
let load = Op::Load {
|
||||
size: OpSize::B64,
|
||||
region: "counter_state".to_string(),
|
||||
offset: 0,
|
||||
dest: Register(0),
|
||||
};
|
||||
assert_eq!(format!("{}", load), "r0 = load.64 counter_state[0]");
|
||||
|
||||
let store = Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: "counter_state".to_string(),
|
||||
offset: 0,
|
||||
src: Register(1),
|
||||
};
|
||||
assert_eq!(format!("{}", store), "store.64 counter_state[0], r1");
|
||||
|
||||
let arith = Op::Arith {
|
||||
kind: ArithKind::Add,
|
||||
lhs: Register(0),
|
||||
rhs: Register(1),
|
||||
dest: Register(2),
|
||||
};
|
||||
assert_eq!(format!("{}", arith), "r2 = add r0, r1");
|
||||
|
||||
let cmp = Op::Cmp {
|
||||
kind: CmpKind::Lt,
|
||||
lhs: Register(0),
|
||||
rhs: Register(1),
|
||||
dest: Register(2),
|
||||
};
|
||||
assert_eq!(format!("{}", cmp), "r2 = cmp.lt r0, r1");
|
||||
|
||||
let branch = Op::Branch {
|
||||
cond: Register(0),
|
||||
true_target: "loop".to_string(),
|
||||
false_target: "exit".to_string(),
|
||||
};
|
||||
assert_eq!(format!("{}", branch), "branch r0 -> loop, exit");
|
||||
|
||||
let jump = Op::Jump { target: "loop".to_string() };
|
||||
assert_eq!(format!("{}", jump), "jump -> loop");
|
||||
|
||||
let push = Op::QueuePush {
|
||||
region: "msg_queue".to_string(),
|
||||
src: Register(0),
|
||||
};
|
||||
assert_eq!(format!("{}", push), "queue_push msg_queue, r0");
|
||||
|
||||
let pop = Op::QueuePop {
|
||||
region: "msg_queue".to_string(),
|
||||
dest: Register(0),
|
||||
};
|
||||
assert_eq!(format!("{}", pop), "r0 = queue_pop msg_queue");
|
||||
|
||||
let hash = Op::Hash {
|
||||
src: Register(0),
|
||||
dest: Register(1),
|
||||
};
|
||||
assert_eq!(format!("{}", hash), "r1 = hash r0");
|
||||
|
||||
assert_eq!(format!("{}", Op::Terminate), "terminate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construct_block() {
|
||||
let block = Block {
|
||||
name: "entry".to_string(),
|
||||
ops: vec![
|
||||
Op::LoadImm { value: 0, dest: Register(0) },
|
||||
Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: "counter_state".to_string(),
|
||||
offset: 0,
|
||||
src: Register(0),
|
||||
},
|
||||
Op::Jump { target: "loop".to_string() },
|
||||
],
|
||||
};
|
||||
let text = format!("{}", block);
|
||||
assert!(text.contains("entry:"));
|
||||
assert!(text.contains("r0 = imm #0"));
|
||||
assert!(text.contains("store.64 counter_state[0], r0"));
|
||||
assert!(text.contains("jump -> loop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construct_counter_l0_ir() {
|
||||
// Build L0 IR that represents the counter example.
|
||||
let program = build_counter_l0();
|
||||
|
||||
assert_eq!(program.regions.len(), 3); // state, queue, step counter
|
||||
assert_eq!(program.blocks.len(), 4); // entry, loop_check, step, exit
|
||||
|
||||
let text = format!("{}", program);
|
||||
assert!(text.contains("=== Regions ==="));
|
||||
assert!(text.contains("counter_state"));
|
||||
assert!(text.contains("msg_queue"));
|
||||
assert!(text.contains("step_counter"));
|
||||
assert!(text.contains("=== Blocks ==="));
|
||||
assert!(text.contains("entry:"));
|
||||
assert!(text.contains("loop_check:"));
|
||||
assert!(text.contains("step:"));
|
||||
assert!(text.contains("exit:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_display() {
|
||||
let r = Region {
|
||||
name: "test_rgn".to_string(),
|
||||
size: 64,
|
||||
access: AccessMode::ReadOnly,
|
||||
kind: RegionKind::Queue,
|
||||
};
|
||||
assert_eq!(format!("{}", r), "region test_rgn : 64 bytes, ro, queue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l0_program_display_is_human_readable() {
|
||||
let program = build_counter_l0();
|
||||
let text = format!("{}", program);
|
||||
// Verify it's structured and readable, not just Debug output
|
||||
assert!(text.contains("=== Regions ==="));
|
||||
assert!(text.contains("=== Blocks ==="));
|
||||
assert!(text.contains("region counter_state : 8 bytes, rw, state"));
|
||||
assert!(text.contains("terminate"));
|
||||
}
|
||||
|
||||
pub fn build_counter_l0() -> L0Program {
|
||||
L0Program {
|
||||
regions: vec![
|
||||
Region {
|
||||
name: "counter_state".to_string(),
|
||||
size: 8,
|
||||
access: AccessMode::ReadWrite,
|
||||
kind: RegionKind::State,
|
||||
},
|
||||
Region {
|
||||
name: "msg_queue".to_string(),
|
||||
size: 128,
|
||||
access: AccessMode::ReadWrite,
|
||||
kind: RegionKind::Queue,
|
||||
},
|
||||
Region {
|
||||
name: "step_counter".to_string(),
|
||||
size: 8,
|
||||
access: AccessMode::ReadWrite,
|
||||
kind: RegionKind::Control,
|
||||
},
|
||||
],
|
||||
blocks: vec![
|
||||
Block {
|
||||
name: "entry".to_string(),
|
||||
ops: vec![
|
||||
// Initialize counter_state to 0
|
||||
Op::LoadImm { value: 0, dest: Register(0) },
|
||||
Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: "counter_state".to_string(),
|
||||
offset: 0,
|
||||
src: Register(0),
|
||||
},
|
||||
// Initialize step counter to 0
|
||||
Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: "step_counter".to_string(),
|
||||
offset: 0,
|
||||
src: Register(0),
|
||||
},
|
||||
Op::Jump { target: "loop_check".to_string() },
|
||||
],
|
||||
},
|
||||
Block {
|
||||
name: "loop_check".to_string(),
|
||||
ops: vec![
|
||||
// Load step counter
|
||||
Op::Load {
|
||||
size: OpSize::B64,
|
||||
region: "step_counter".to_string(),
|
||||
offset: 0,
|
||||
dest: Register(0),
|
||||
},
|
||||
// Compare with step limit (5)
|
||||
Op::LoadImm { value: 5, dest: Register(1) },
|
||||
Op::Cmp {
|
||||
kind: CmpKind::Lt,
|
||||
lhs: Register(0),
|
||||
rhs: Register(1),
|
||||
dest: Register(2),
|
||||
},
|
||||
Op::Branch {
|
||||
cond: Register(2),
|
||||
true_target: "step".to_string(),
|
||||
false_target: "exit".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
Block {
|
||||
name: "step".to_string(),
|
||||
ops: vec![
|
||||
// Leaf ticker: push Increment(1) to queue
|
||||
Op::LoadImm { value: 1, dest: Register(0) },
|
||||
Op::QueuePush {
|
||||
region: "msg_queue".to_string(),
|
||||
src: Register(0),
|
||||
},
|
||||
// Actor handler: pop from queue, load state, add, store
|
||||
Op::QueuePop {
|
||||
region: "msg_queue".to_string(),
|
||||
dest: Register(1),
|
||||
},
|
||||
Op::Load {
|
||||
size: OpSize::B64,
|
||||
region: "counter_state".to_string(),
|
||||
offset: 0,
|
||||
dest: Register(2),
|
||||
},
|
||||
Op::Arith {
|
||||
kind: ArithKind::Add,
|
||||
lhs: Register(2),
|
||||
rhs: Register(1),
|
||||
dest: Register(3),
|
||||
},
|
||||
Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: "counter_state".to_string(),
|
||||
offset: 0,
|
||||
src: Register(3),
|
||||
},
|
||||
// Window read (display leaf): load counter_state
|
||||
Op::Load {
|
||||
size: OpSize::B64,
|
||||
region: "counter_state".to_string(),
|
||||
offset: 0,
|
||||
dest: Register(4),
|
||||
},
|
||||
// Increment step counter
|
||||
Op::Load {
|
||||
size: OpSize::B64,
|
||||
region: "step_counter".to_string(),
|
||||
offset: 0,
|
||||
dest: Register(5),
|
||||
},
|
||||
Op::ArithImm {
|
||||
kind: ArithKind::Add,
|
||||
lhs: Register(5),
|
||||
imm: 1,
|
||||
dest: Register(6),
|
||||
},
|
||||
Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: "step_counter".to_string(),
|
||||
offset: 0,
|
||||
src: Register(6),
|
||||
},
|
||||
Op::Jump { target: "loop_check".to_string() },
|
||||
],
|
||||
},
|
||||
Block {
|
||||
name: "exit".to_string(),
|
||||
ops: vec![
|
||||
Op::Terminate,
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
499
src/l1_ir.rs
Normal file
499
src/l1_ir.rs
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
use std::fmt;
|
||||
|
||||
/// A typed field in an actor's state.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct StateField {
|
||||
pub name: String,
|
||||
pub ty: FieldType,
|
||||
pub init: u64,
|
||||
}
|
||||
|
||||
/// Supported field types for actor state.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum FieldType {
|
||||
U64,
|
||||
I64,
|
||||
F64,
|
||||
}
|
||||
|
||||
impl fmt::Display for FieldType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
FieldType::U64 => write!(f, "u64"),
|
||||
FieldType::I64 => write!(f, "i64"),
|
||||
FieldType::F64 => write!(f, "f64"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A window grants read-only access to actor state fields.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct WindowDecl {
|
||||
pub name: String,
|
||||
pub fields: Vec<String>,
|
||||
pub readers: Vec<String>,
|
||||
}
|
||||
|
||||
/// The body of a message handler expression.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Expr {
|
||||
/// A literal integer value.
|
||||
Lit(u64),
|
||||
/// Reference to a variable (state field or argument).
|
||||
Var(String),
|
||||
/// Arithmetic: op, lhs, rhs.
|
||||
Arith(ArithOp, Box<Expr>, Box<Expr>),
|
||||
/// Assignment: target field, value expression.
|
||||
Assign(String, Box<Expr>),
|
||||
/// Block of sequential expressions.
|
||||
Block(Vec<Expr>),
|
||||
}
|
||||
|
||||
/// Arithmetic operations available in handler bodies.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ArithOp {
|
||||
Add,
|
||||
Sub,
|
||||
Mul,
|
||||
}
|
||||
|
||||
impl fmt::Display for ArithOp {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ArithOp::Add => write!(f, "+"),
|
||||
ArithOp::Sub => write!(f, "-"),
|
||||
ArithOp::Mul => write!(f, "*"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A message type that an actor can handle.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MessageHandler {
|
||||
pub message_type: String,
|
||||
pub args: Vec<(String, FieldType)>,
|
||||
pub body: Expr,
|
||||
}
|
||||
|
||||
/// An actor declaration — owns mutable state, exposes windows, handles messages.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ActorDecl {
|
||||
pub name: String,
|
||||
pub state: Vec<StateField>,
|
||||
pub windows: Vec<WindowDecl>,
|
||||
pub handlers: Vec<MessageHandler>,
|
||||
}
|
||||
|
||||
/// Action a leaf can take in its process function.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum LeafAction {
|
||||
/// Forward a message to a target (actor or leaf).
|
||||
Forward {
|
||||
target: String,
|
||||
message_type: String,
|
||||
args: Vec<Expr>,
|
||||
},
|
||||
/// Read a value through a window.
|
||||
ReadWindow {
|
||||
actor: String,
|
||||
window: String,
|
||||
field: String,
|
||||
},
|
||||
/// Emit a value (for observation/output).
|
||||
Emit(Expr),
|
||||
}
|
||||
|
||||
/// A leaf declaration — stateless processor with a process function.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LeafDecl {
|
||||
pub name: String,
|
||||
pub reads: Option<ReadClause>,
|
||||
pub actions: Vec<LeafAction>,
|
||||
}
|
||||
|
||||
/// A reads clause specifying which window a leaf reads from.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ReadClause {
|
||||
pub actor: String,
|
||||
pub window: String,
|
||||
}
|
||||
|
||||
/// A pipeline declaration — ordered chain of leaf/actor references.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PipelineDecl {
|
||||
pub name: String,
|
||||
pub stages: Vec<String>,
|
||||
}
|
||||
|
||||
/// A core declaration — execution context for a single thread.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CoreDecl {
|
||||
pub name: String,
|
||||
pub actors: Vec<String>,
|
||||
pub leaves: Vec<String>,
|
||||
pub pipelines: Vec<String>,
|
||||
pub steps: u64,
|
||||
}
|
||||
|
||||
/// The top-level L1 program.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct L1Program {
|
||||
pub actors: Vec<ActorDecl>,
|
||||
pub leaves: Vec<LeafDecl>,
|
||||
pub pipelines: Vec<PipelineDecl>,
|
||||
pub cores: Vec<CoreDecl>,
|
||||
}
|
||||
|
||||
// --- Display implementations for human-readable L1 IR text ---
|
||||
|
||||
impl fmt::Display for StateField {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}: {} = {}", self.name, self.ty, self.init)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for WindowDecl {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "window {} : ({}) readers({})",
|
||||
self.name,
|
||||
self.fields.join(", "),
|
||||
self.readers.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Expr {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Expr::Lit(v) => write!(f, "{}", v),
|
||||
Expr::Var(name) => write!(f, "{}", name),
|
||||
Expr::Arith(op, lhs, rhs) => write!(f, "({} {} {})", lhs, op, rhs),
|
||||
Expr::Assign(target, val) => write!(f, "{} = {}", target, val),
|
||||
Expr::Block(exprs) => {
|
||||
for (i, e) in exprs.iter().enumerate() {
|
||||
if i > 0 { write!(f, "; ")?; }
|
||||
write!(f, "{}", e)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MessageHandler {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let args: Vec<String> = self.args.iter()
|
||||
.map(|(name, ty)| format!("{}: {}", name, ty))
|
||||
.collect();
|
||||
write!(f, "on {}({}) {{ {} }}", self.message_type, args.join(", "), self.body)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ActorDecl {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
writeln!(f, "actor {} {{", self.name)?;
|
||||
writeln!(f, " state {{")?;
|
||||
for field in &self.state {
|
||||
writeln!(f, " {}", field)?;
|
||||
}
|
||||
writeln!(f, " }}")?;
|
||||
for window in &self.windows {
|
||||
writeln!(f, " {}", window)?;
|
||||
}
|
||||
for handler in &self.handlers {
|
||||
writeln!(f, " {}", handler)?;
|
||||
}
|
||||
write!(f, "}}")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for LeafAction {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
LeafAction::Forward { target, message_type, args } => {
|
||||
let args_str: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
|
||||
write!(f, "forward({}, {}({}))", target, message_type, args_str.join(", "))
|
||||
}
|
||||
LeafAction::ReadWindow { actor, window, field } => {
|
||||
write!(f, "read({}.{}.{})", actor, window, field)
|
||||
}
|
||||
LeafAction::Emit(expr) => {
|
||||
write!(f, "emit({})", expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for LeafDecl {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "leaf {} {{", self.name)?;
|
||||
if let Some(reads) = &self.reads {
|
||||
write!(f, " reads {}.{};", reads.actor, reads.window)?;
|
||||
}
|
||||
for action in &self.actions {
|
||||
write!(f, " {};", action)?;
|
||||
}
|
||||
write!(f, " }}")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PipelineDecl {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "pipeline {} {{ {} }}", self.name, self.stages.join(" -> "))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CoreDecl {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
writeln!(f, "core {} {{", self.name)?;
|
||||
writeln!(f, " actors: [{}]", self.actors.join(", "))?;
|
||||
writeln!(f, " leaves: [{}]", self.leaves.join(", "))?;
|
||||
writeln!(f, " pipelines: [{}]", self.pipelines.join(", "))?;
|
||||
writeln!(f, " steps: {}", self.steps)?;
|
||||
write!(f, "}}")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for L1Program {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for actor in &self.actors {
|
||||
writeln!(f, "{}", actor)?;
|
||||
}
|
||||
for leaf in &self.leaves {
|
||||
writeln!(f, "{}", leaf)?;
|
||||
}
|
||||
for pipeline in &self.pipelines {
|
||||
writeln!(f, "{}", pipeline)?;
|
||||
}
|
||||
for core in &self.cores {
|
||||
writeln!(f, "{}", core)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn construct_counter_l1_ir() {
|
||||
let program = build_counter_program();
|
||||
|
||||
assert_eq!(program.actors.len(), 1);
|
||||
assert_eq!(program.actors[0].name, "counter");
|
||||
assert_eq!(program.actors[0].state.len(), 1);
|
||||
assert_eq!(program.actors[0].state[0].name, "count");
|
||||
assert_eq!(program.actors[0].state[0].ty, FieldType::U64);
|
||||
assert_eq!(program.actors[0].state[0].init, 0);
|
||||
|
||||
assert_eq!(program.actors[0].windows.len(), 1);
|
||||
assert_eq!(program.actors[0].windows[0].name, "count_view");
|
||||
assert_eq!(program.actors[0].windows[0].fields, vec!["count"]);
|
||||
assert_eq!(program.actors[0].windows[0].readers, vec!["display"]);
|
||||
|
||||
assert_eq!(program.actors[0].handlers.len(), 1);
|
||||
assert_eq!(program.actors[0].handlers[0].message_type, "Increment");
|
||||
|
||||
assert_eq!(program.leaves.len(), 2);
|
||||
assert_eq!(program.leaves[0].name, "ticker");
|
||||
assert_eq!(program.leaves[1].name, "display");
|
||||
|
||||
assert_eq!(program.pipelines.len(), 1);
|
||||
assert_eq!(program.pipelines[0].stages, vec!["ticker", "counter", "display"]);
|
||||
|
||||
assert_eq!(program.cores.len(), 1);
|
||||
assert_eq!(program.cores[0].steps, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construct_window_l1_ir() {
|
||||
let program = build_window_program();
|
||||
|
||||
assert_eq!(program.actors.len(), 1);
|
||||
assert_eq!(program.actors[0].name, "accumulator");
|
||||
assert_eq!(program.actors[0].state[0].name, "total");
|
||||
assert_eq!(program.actors[0].state[0].init, 0);
|
||||
|
||||
assert_eq!(program.actors[0].windows[0].readers, vec!["observe"]);
|
||||
|
||||
assert_eq!(program.leaves.len(), 2);
|
||||
assert_eq!(program.leaves[0].name, "source");
|
||||
assert_eq!(program.leaves[1].name, "observe");
|
||||
|
||||
assert_eq!(program.cores[0].steps, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l1_ir_display_counter() {
|
||||
let program = build_counter_program();
|
||||
let text = format!("{}", program);
|
||||
assert!(text.contains("actor counter"));
|
||||
assert!(text.contains("count: u64 = 0"));
|
||||
assert!(text.contains("window count_view"));
|
||||
assert!(text.contains("on Increment"));
|
||||
assert!(text.contains("leaf ticker"));
|
||||
assert!(text.contains("leaf display"));
|
||||
assert!(text.contains("pipeline main"));
|
||||
assert!(text.contains("core main"));
|
||||
assert!(text.contains("steps: 5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_field_display() {
|
||||
let field = StateField {
|
||||
name: "count".to_string(),
|
||||
ty: FieldType::U64,
|
||||
init: 42,
|
||||
};
|
||||
assert_eq!(format!("{}", field), "count: u64 = 42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expr_display() {
|
||||
let expr = Expr::Arith(
|
||||
ArithOp::Add,
|
||||
Box::new(Expr::Var("count".to_string())),
|
||||
Box::new(Expr::Lit(1)),
|
||||
);
|
||||
assert_eq!(format!("{}", expr), "(count + 1)");
|
||||
}
|
||||
|
||||
pub fn build_counter_program() -> L1Program {
|
||||
L1Program {
|
||||
actors: vec![ActorDecl {
|
||||
name: "counter".to_string(),
|
||||
state: vec![StateField {
|
||||
name: "count".to_string(),
|
||||
ty: FieldType::U64,
|
||||
init: 0,
|
||||
}],
|
||||
windows: vec![WindowDecl {
|
||||
name: "count_view".to_string(),
|
||||
fields: vec!["count".to_string()],
|
||||
readers: vec!["display".to_string()],
|
||||
}],
|
||||
handlers: vec![MessageHandler {
|
||||
message_type: "Increment".to_string(),
|
||||
args: vec![("amount".to_string(), FieldType::U64)],
|
||||
body: Expr::Assign(
|
||||
"count".to_string(),
|
||||
Box::new(Expr::Arith(
|
||||
ArithOp::Add,
|
||||
Box::new(Expr::Var("count".to_string())),
|
||||
Box::new(Expr::Var("amount".to_string())),
|
||||
)),
|
||||
),
|
||||
}],
|
||||
}],
|
||||
leaves: vec![
|
||||
LeafDecl {
|
||||
name: "ticker".to_string(),
|
||||
reads: None,
|
||||
actions: vec![LeafAction::Forward {
|
||||
target: "counter".to_string(),
|
||||
message_type: "Increment".to_string(),
|
||||
args: vec![Expr::Lit(1)],
|
||||
}],
|
||||
},
|
||||
LeafDecl {
|
||||
name: "display".to_string(),
|
||||
reads: Some(ReadClause {
|
||||
actor: "counter".to_string(),
|
||||
window: "count_view".to_string(),
|
||||
}),
|
||||
actions: vec![LeafAction::ReadWindow {
|
||||
actor: "counter".to_string(),
|
||||
window: "count_view".to_string(),
|
||||
field: "count".to_string(),
|
||||
}],
|
||||
},
|
||||
],
|
||||
pipelines: vec![PipelineDecl {
|
||||
name: "main".to_string(),
|
||||
stages: vec![
|
||||
"ticker".to_string(),
|
||||
"counter".to_string(),
|
||||
"display".to_string(),
|
||||
],
|
||||
}],
|
||||
cores: vec![CoreDecl {
|
||||
name: "main".to_string(),
|
||||
actors: vec!["counter".to_string()],
|
||||
leaves: vec!["ticker".to_string(), "display".to_string()],
|
||||
pipelines: vec!["main".to_string()],
|
||||
steps: 5,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_window_program() -> L1Program {
|
||||
L1Program {
|
||||
actors: vec![ActorDecl {
|
||||
name: "accumulator".to_string(),
|
||||
state: vec![StateField {
|
||||
name: "total".to_string(),
|
||||
ty: FieldType::U64,
|
||||
init: 0,
|
||||
}],
|
||||
windows: vec![WindowDecl {
|
||||
name: "total_view".to_string(),
|
||||
fields: vec!["total".to_string()],
|
||||
readers: vec!["observe".to_string()],
|
||||
}],
|
||||
handlers: vec![MessageHandler {
|
||||
message_type: "Add".to_string(),
|
||||
args: vec![("value".to_string(), FieldType::U64)],
|
||||
body: Expr::Assign(
|
||||
"total".to_string(),
|
||||
Box::new(Expr::Arith(
|
||||
ArithOp::Add,
|
||||
Box::new(Expr::Var("total".to_string())),
|
||||
Box::new(Expr::Var("value".to_string())),
|
||||
)),
|
||||
),
|
||||
}],
|
||||
}],
|
||||
leaves: vec![
|
||||
LeafDecl {
|
||||
name: "source".to_string(),
|
||||
reads: None,
|
||||
actions: vec![LeafAction::Forward {
|
||||
target: "accumulator".to_string(),
|
||||
message_type: "Add".to_string(),
|
||||
args: vec![Expr::Lit(10)],
|
||||
}],
|
||||
},
|
||||
LeafDecl {
|
||||
name: "observe".to_string(),
|
||||
reads: Some(ReadClause {
|
||||
actor: "accumulator".to_string(),
|
||||
window: "total_view".to_string(),
|
||||
}),
|
||||
actions: vec![
|
||||
LeafAction::ReadWindow {
|
||||
actor: "accumulator".to_string(),
|
||||
window: "total_view".to_string(),
|
||||
field: "total".to_string(),
|
||||
},
|
||||
LeafAction::Emit(Expr::Var("total".to_string())),
|
||||
],
|
||||
},
|
||||
],
|
||||
pipelines: vec![PipelineDecl {
|
||||
name: "main".to_string(),
|
||||
stages: vec![
|
||||
"source".to_string(),
|
||||
"accumulator".to_string(),
|
||||
"observe".to_string(),
|
||||
],
|
||||
}],
|
||||
cores: vec![CoreDecl {
|
||||
name: "main".to_string(),
|
||||
actors: vec!["accumulator".to_string()],
|
||||
leaves: vec!["source".to_string(), "observe".to_string()],
|
||||
pipelines: vec!["main".to_string()],
|
||||
steps: 3,
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/lib.rs
Normal file
11
src/lib.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
pub mod l1_ir;
|
||||
pub mod l0_ir;
|
||||
pub mod parser;
|
||||
pub mod verify_l1;
|
||||
pub mod emit;
|
||||
pub mod verify_l0;
|
||||
pub mod codegen;
|
||||
pub mod pipeline;
|
||||
pub mod interpret_l1;
|
||||
pub mod translate_validate;
|
||||
pub mod repl;
|
||||
78
src/main.rs
Normal file
78
src/main.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
use l0_cpu_runtime::{pipeline, repl};
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 2 {
|
||||
eprintln!("Usage: l0-cpu-runtime <file.l1> [--dump-ir]");
|
||||
eprintln!(" l0-cpu-runtime verify <file.l1>");
|
||||
eprintln!(" l0-cpu-runtime repl");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Handle "repl" subcommand
|
||||
if args[1] == "repl" {
|
||||
repl::run_repl();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle "verify" subcommand
|
||||
if args[1] == "verify" {
|
||||
if args.len() < 3 {
|
||||
eprintln!("Usage: l0-cpu-runtime verify <file.l1>");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let path = &args[2];
|
||||
let source = std::fs::read_to_string(path).unwrap_or_else(|e| {
|
||||
eprintln!("Error reading '{}': {}", path, e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
match pipeline::verify_translation(&source) {
|
||||
Ok(report) => {
|
||||
print!("{}", report);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Verification failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let path = &args[1];
|
||||
let source = std::fs::read_to_string(path).unwrap_or_else(|e| {
|
||||
eprintln!("Error reading '{}': {}", path, e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let dump = args.iter().any(|a| a == "--dump-ir");
|
||||
|
||||
if dump {
|
||||
match pipeline::dump_ir(&source) {
|
||||
Ok(text) => print!("{}", text),
|
||||
Err(e) => {
|
||||
eprintln!("Pipeline error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match pipeline::run(&source) {
|
||||
Ok((result, _ir)) => {
|
||||
println!("Execution complete.");
|
||||
for (name, buf) in &result.regions {
|
||||
if buf.len() >= 8 {
|
||||
let val = u64::from_le_bytes(buf[0..8].try_into().unwrap());
|
||||
println!(" {}[0] = {}", name, val);
|
||||
}
|
||||
}
|
||||
let output = result.read_output_values();
|
||||
if !output.is_empty() {
|
||||
println!(" output values: {:?}", output);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Pipeline error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
816
src/parser.rs
Normal file
816
src/parser.rs
Normal file
|
|
@ -0,0 +1,816 @@
|
|||
use crate::l1_ir::*;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ParseError {
|
||||
pub message: String,
|
||||
pub line: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
impl fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "parse error at {}:{}: {}", self.line, self.col, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
struct Lexer<'a> {
|
||||
input: &'a str,
|
||||
pos: usize,
|
||||
line: usize,
|
||||
col: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Token {
|
||||
Ident(String),
|
||||
Number(u64),
|
||||
LBrace,
|
||||
RBrace,
|
||||
LParen,
|
||||
RParen,
|
||||
LBracket,
|
||||
RBracket,
|
||||
Colon,
|
||||
Comma,
|
||||
Eq,
|
||||
Plus,
|
||||
Minus,
|
||||
Star,
|
||||
Arrow, // ->
|
||||
Dot,
|
||||
Eof,
|
||||
}
|
||||
|
||||
impl fmt::Display for Token {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Token::Ident(s) => write!(f, "'{}'", s),
|
||||
Token::Number(n) => write!(f, "{}", n),
|
||||
Token::LBrace => write!(f, "'{{'"),
|
||||
Token::RBrace => write!(f, "'}}'"),
|
||||
Token::LParen => write!(f, "'('"),
|
||||
Token::RParen => write!(f, "')'"),
|
||||
Token::LBracket => write!(f, "'['"),
|
||||
Token::RBracket => write!(f, "']'"),
|
||||
Token::Colon => write!(f, "':'"),
|
||||
Token::Comma => write!(f, "','"),
|
||||
Token::Eq => write!(f, "'='"),
|
||||
Token::Plus => write!(f, "'+'"),
|
||||
Token::Minus => write!(f, "'-'"),
|
||||
Token::Star => write!(f, "'*'"),
|
||||
Token::Arrow => write!(f, "'->'"),
|
||||
Token::Dot => write!(f, "'.'"),
|
||||
Token::Eof => write!(f, "EOF"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Lexer<'a> {
|
||||
fn new(input: &'a str) -> Self {
|
||||
Lexer { input, pos: 0, line: 1, col: 1 }
|
||||
}
|
||||
|
||||
fn skip_whitespace_and_comments(&mut self) {
|
||||
let bytes = self.input.as_bytes();
|
||||
while self.pos < bytes.len() {
|
||||
if bytes[self.pos] == b'\n' {
|
||||
self.pos += 1;
|
||||
self.line += 1;
|
||||
self.col = 1;
|
||||
} else if bytes[self.pos].is_ascii_whitespace() {
|
||||
self.pos += 1;
|
||||
self.col += 1;
|
||||
} else if self.pos + 1 < bytes.len() && bytes[self.pos] == b'/' && bytes[self.pos + 1] == b'/' {
|
||||
// Line comment
|
||||
while self.pos < bytes.len() && bytes[self.pos] != b'\n' {
|
||||
self.pos += 1;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn error(&self, msg: &str) -> ParseError {
|
||||
ParseError { message: msg.to_string(), line: self.line, col: self.col }
|
||||
}
|
||||
|
||||
fn next_token(&mut self) -> Result<Token, ParseError> {
|
||||
self.skip_whitespace_and_comments();
|
||||
let bytes = self.input.as_bytes();
|
||||
|
||||
if self.pos >= bytes.len() {
|
||||
return Ok(Token::Eof);
|
||||
}
|
||||
|
||||
let ch = bytes[self.pos];
|
||||
|
||||
// Identifiers and keywords
|
||||
if ch.is_ascii_alphabetic() || ch == b'_' {
|
||||
let start = self.pos;
|
||||
while self.pos < bytes.len() && (bytes[self.pos].is_ascii_alphanumeric() || bytes[self.pos] == b'_') {
|
||||
self.pos += 1;
|
||||
self.col += 1;
|
||||
}
|
||||
let word = &self.input[start..self.pos];
|
||||
return Ok(Token::Ident(word.to_string()));
|
||||
}
|
||||
|
||||
// Numbers
|
||||
if ch.is_ascii_digit() {
|
||||
let start = self.pos;
|
||||
while self.pos < bytes.len() && bytes[self.pos].is_ascii_digit() {
|
||||
self.pos += 1;
|
||||
self.col += 1;
|
||||
}
|
||||
let num_str = &self.input[start..self.pos];
|
||||
let value = num_str.parse::<u64>()
|
||||
.map_err(|_| self.error(&format!("invalid number: {}", num_str)))?;
|
||||
return Ok(Token::Number(value));
|
||||
}
|
||||
|
||||
self.pos += 1;
|
||||
self.col += 1;
|
||||
|
||||
match ch {
|
||||
b'{' => Ok(Token::LBrace),
|
||||
b'}' => Ok(Token::RBrace),
|
||||
b'(' => Ok(Token::LParen),
|
||||
b')' => Ok(Token::RParen),
|
||||
b'[' => Ok(Token::LBracket),
|
||||
b']' => Ok(Token::RBracket),
|
||||
b':' => Ok(Token::Colon),
|
||||
b',' => Ok(Token::Comma),
|
||||
b'+' => Ok(Token::Plus),
|
||||
b'*' => Ok(Token::Star),
|
||||
b'.' => Ok(Token::Dot),
|
||||
b'=' => Ok(Token::Eq),
|
||||
b'-' => {
|
||||
if self.pos < bytes.len() && bytes[self.pos] == b'>' {
|
||||
self.pos += 1;
|
||||
self.col += 1;
|
||||
Ok(Token::Arrow)
|
||||
} else {
|
||||
Ok(Token::Minus)
|
||||
}
|
||||
}
|
||||
_ => Err(self.error(&format!("unexpected character: '{}'", ch as char))),
|
||||
}
|
||||
}
|
||||
|
||||
fn peek_token(&mut self) -> Result<Token, ParseError> {
|
||||
let saved_pos = self.pos;
|
||||
let saved_line = self.line;
|
||||
let saved_col = self.col;
|
||||
let tok = self.next_token()?;
|
||||
self.pos = saved_pos;
|
||||
self.line = saved_line;
|
||||
self.col = saved_col;
|
||||
Ok(tok)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Parser<'a> {
|
||||
lexer: Lexer<'a>,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
pub fn new(input: &'a str) -> Self {
|
||||
Parser { lexer: Lexer::new(input) }
|
||||
}
|
||||
|
||||
fn error(&self, msg: &str) -> ParseError {
|
||||
self.lexer.error(msg)
|
||||
}
|
||||
|
||||
fn expect_ident(&mut self) -> Result<String, ParseError> {
|
||||
match self.lexer.next_token()? {
|
||||
Token::Ident(s) => Ok(s),
|
||||
other => Err(self.error(&format!("expected identifier, got {}", other))),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_token(&mut self, expected: &Token) -> Result<(), ParseError> {
|
||||
let tok = self.lexer.next_token()?;
|
||||
if std::mem::discriminant(&tok) == std::mem::discriminant(expected) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(self.error(&format!("expected {}, got {}", expected, tok)))
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_number(&mut self) -> Result<u64, ParseError> {
|
||||
match self.lexer.next_token()? {
|
||||
Token::Number(n) => Ok(n),
|
||||
other => Err(self.error(&format!("expected number, got {}", other))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_field_type(&mut self) -> Result<FieldType, ParseError> {
|
||||
let name = self.expect_ident()?;
|
||||
match name.as_str() {
|
||||
"u64" => Ok(FieldType::U64),
|
||||
"i64" => Ok(FieldType::I64),
|
||||
"f64" => Ok(FieldType::F64),
|
||||
_ => Err(self.error(&format!("unknown type: {}", name))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_state_field(&mut self) -> Result<StateField, ParseError> {
|
||||
let name = self.expect_ident()?;
|
||||
self.expect_token(&Token::Colon)?;
|
||||
let ty = self.parse_field_type()?;
|
||||
self.expect_token(&Token::Eq)?;
|
||||
let init = self.expect_number()?;
|
||||
Ok(StateField { name, ty, init })
|
||||
}
|
||||
|
||||
fn parse_state_block(&mut self) -> Result<Vec<StateField>, ParseError> {
|
||||
// "state" keyword already consumed
|
||||
self.expect_token(&Token::LBrace)?;
|
||||
let mut fields = Vec::new();
|
||||
loop {
|
||||
match self.lexer.peek_token()? {
|
||||
Token::RBrace => {
|
||||
self.lexer.next_token()?;
|
||||
break;
|
||||
}
|
||||
_ => fields.push(self.parse_state_field()?),
|
||||
}
|
||||
}
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
fn parse_window_decl(&mut self) -> Result<WindowDecl, ParseError> {
|
||||
// "window" keyword already consumed
|
||||
let name = self.expect_ident()?;
|
||||
self.expect_token(&Token::Colon)?;
|
||||
self.expect_token(&Token::LParen)?;
|
||||
|
||||
let mut fields = Vec::new();
|
||||
loop {
|
||||
match self.lexer.peek_token()? {
|
||||
Token::RParen => {
|
||||
self.lexer.next_token()?;
|
||||
break;
|
||||
}
|
||||
Token::Comma => {
|
||||
self.lexer.next_token()?;
|
||||
}
|
||||
_ => fields.push(self.expect_ident()?),
|
||||
}
|
||||
}
|
||||
|
||||
// readers(...)
|
||||
let readers_kw = self.expect_ident()?;
|
||||
if readers_kw != "readers" {
|
||||
return Err(self.error(&format!("expected 'readers', got '{}'", readers_kw)));
|
||||
}
|
||||
self.expect_token(&Token::LParen)?;
|
||||
let mut readers = Vec::new();
|
||||
loop {
|
||||
match self.lexer.peek_token()? {
|
||||
Token::RParen => {
|
||||
self.lexer.next_token()?;
|
||||
break;
|
||||
}
|
||||
Token::Comma => {
|
||||
self.lexer.next_token()?;
|
||||
}
|
||||
_ => readers.push(self.expect_ident()?),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(WindowDecl { name, fields, readers })
|
||||
}
|
||||
|
||||
fn parse_expr(&mut self) -> Result<Expr, ParseError> {
|
||||
let lhs = self.parse_expr_atom()?;
|
||||
|
||||
// Check for assignment: ident = expr
|
||||
// or arithmetic: expr op expr
|
||||
match self.lexer.peek_token()? {
|
||||
Token::Eq => {
|
||||
// Assignment
|
||||
if let Expr::Var(name) = lhs {
|
||||
self.lexer.next_token()?;
|
||||
let rhs = self.parse_expr()?;
|
||||
Ok(Expr::Assign(name, Box::new(rhs)))
|
||||
} else {
|
||||
Err(self.error("left side of assignment must be an identifier"))
|
||||
}
|
||||
}
|
||||
Token::Plus | Token::Minus | Token::Star => {
|
||||
let op_tok = self.lexer.next_token()?;
|
||||
let op = match op_tok {
|
||||
Token::Plus => ArithOp::Add,
|
||||
Token::Minus => ArithOp::Sub,
|
||||
Token::Star => ArithOp::Mul,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let rhs = self.parse_expr_atom()?;
|
||||
Ok(Expr::Arith(op, Box::new(lhs), Box::new(rhs)))
|
||||
}
|
||||
_ => Ok(lhs),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_expr_atom(&mut self) -> Result<Expr, ParseError> {
|
||||
match self.lexer.peek_token()? {
|
||||
Token::Number(_) => {
|
||||
let n = self.expect_number()?;
|
||||
Ok(Expr::Lit(n))
|
||||
}
|
||||
Token::Ident(_) => {
|
||||
let name = self.expect_ident()?;
|
||||
Ok(Expr::Var(name))
|
||||
}
|
||||
Token::LParen => {
|
||||
self.lexer.next_token()?;
|
||||
let expr = self.parse_expr()?;
|
||||
self.expect_token(&Token::RParen)?;
|
||||
Ok(expr)
|
||||
}
|
||||
other => Err(self.error(&format!("expected expression, got {}", other))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_handler(&mut self) -> Result<MessageHandler, ParseError> {
|
||||
// "on" keyword already consumed
|
||||
let message_type = self.expect_ident()?;
|
||||
self.expect_token(&Token::LParen)?;
|
||||
|
||||
let mut args = Vec::new();
|
||||
loop {
|
||||
match self.lexer.peek_token()? {
|
||||
Token::RParen => {
|
||||
self.lexer.next_token()?;
|
||||
break;
|
||||
}
|
||||
Token::Comma => {
|
||||
self.lexer.next_token()?;
|
||||
}
|
||||
_ => {
|
||||
let arg_name = self.expect_ident()?;
|
||||
self.expect_token(&Token::Colon)?;
|
||||
let arg_ty = self.parse_field_type()?;
|
||||
args.push((arg_name, arg_ty));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.expect_token(&Token::LBrace)?;
|
||||
|
||||
let mut exprs = Vec::new();
|
||||
loop {
|
||||
match self.lexer.peek_token()? {
|
||||
Token::RBrace => {
|
||||
self.lexer.next_token()?;
|
||||
break;
|
||||
}
|
||||
_ => exprs.push(self.parse_expr()?),
|
||||
}
|
||||
}
|
||||
|
||||
let body = if exprs.len() == 1 {
|
||||
exprs.into_iter().next().unwrap()
|
||||
} else {
|
||||
Expr::Block(exprs)
|
||||
};
|
||||
|
||||
Ok(MessageHandler { message_type, args, body })
|
||||
}
|
||||
|
||||
fn parse_actor(&mut self) -> Result<ActorDecl, ParseError> {
|
||||
// "actor" keyword already consumed
|
||||
let name = self.expect_ident()?;
|
||||
self.expect_token(&Token::LBrace)?;
|
||||
|
||||
let mut state = Vec::new();
|
||||
let mut windows = Vec::new();
|
||||
let mut handlers = Vec::new();
|
||||
|
||||
loop {
|
||||
match self.lexer.peek_token()? {
|
||||
Token::RBrace => {
|
||||
self.lexer.next_token()?;
|
||||
break;
|
||||
}
|
||||
Token::Ident(ref kw) => {
|
||||
let kw = kw.clone();
|
||||
match kw.as_str() {
|
||||
"state" => {
|
||||
self.lexer.next_token()?;
|
||||
state = self.parse_state_block()?;
|
||||
}
|
||||
"window" => {
|
||||
self.lexer.next_token()?;
|
||||
windows.push(self.parse_window_decl()?);
|
||||
}
|
||||
"on" => {
|
||||
self.lexer.next_token()?;
|
||||
handlers.push(self.parse_handler()?);
|
||||
}
|
||||
_ => return Err(self.error(&format!(
|
||||
"unexpected keyword '{}' in actor body", kw
|
||||
))),
|
||||
}
|
||||
}
|
||||
other => return Err(self.error(&format!(
|
||||
"unexpected token {} in actor body", other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ActorDecl { name, state, windows, handlers })
|
||||
}
|
||||
|
||||
fn parse_leaf_actions(&mut self) -> Result<Vec<LeafAction>, ParseError> {
|
||||
// "process" keyword already consumed
|
||||
self.expect_token(&Token::LBrace)?;
|
||||
|
||||
let mut actions = Vec::new();
|
||||
loop {
|
||||
match self.lexer.peek_token()? {
|
||||
Token::RBrace => {
|
||||
self.lexer.next_token()?;
|
||||
break;
|
||||
}
|
||||
Token::Ident(ref kw) => {
|
||||
let kw = kw.clone();
|
||||
match kw.as_str() {
|
||||
"forward" => {
|
||||
self.lexer.next_token()?;
|
||||
self.expect_token(&Token::LParen)?;
|
||||
let target = self.expect_ident()?;
|
||||
self.expect_token(&Token::Comma)?;
|
||||
let msg_type = self.expect_ident()?;
|
||||
self.expect_token(&Token::LParen)?;
|
||||
|
||||
let mut args = Vec::new();
|
||||
loop {
|
||||
match self.lexer.peek_token()? {
|
||||
Token::RParen => {
|
||||
self.lexer.next_token()?;
|
||||
break;
|
||||
}
|
||||
Token::Comma => {
|
||||
self.lexer.next_token()?;
|
||||
}
|
||||
_ => args.push(self.parse_expr()?),
|
||||
}
|
||||
}
|
||||
self.expect_token(&Token::RParen)?; // outer close
|
||||
|
||||
actions.push(LeafAction::Forward {
|
||||
target,
|
||||
message_type: msg_type,
|
||||
args,
|
||||
});
|
||||
}
|
||||
"read" => {
|
||||
self.lexer.next_token()?;
|
||||
self.expect_token(&Token::LParen)?;
|
||||
let actor = self.expect_ident()?;
|
||||
self.expect_token(&Token::Dot)?;
|
||||
let window = self.expect_ident()?;
|
||||
self.expect_token(&Token::Dot)?;
|
||||
let field = self.expect_ident()?;
|
||||
self.expect_token(&Token::RParen)?;
|
||||
|
||||
actions.push(LeafAction::ReadWindow {
|
||||
actor,
|
||||
window,
|
||||
field,
|
||||
});
|
||||
}
|
||||
"emit" => {
|
||||
self.lexer.next_token()?;
|
||||
self.expect_token(&Token::LParen)?;
|
||||
let expr = self.parse_expr()?;
|
||||
self.expect_token(&Token::RParen)?;
|
||||
|
||||
actions.push(LeafAction::Emit(expr));
|
||||
}
|
||||
_ => return Err(self.error(&format!(
|
||||
"unexpected action '{}' in process body", kw
|
||||
))),
|
||||
}
|
||||
}
|
||||
other => return Err(self.error(&format!(
|
||||
"unexpected token {} in process body", other
|
||||
))),
|
||||
}
|
||||
}
|
||||
Ok(actions)
|
||||
}
|
||||
|
||||
fn parse_leaf(&mut self) -> Result<LeafDecl, ParseError> {
|
||||
// "leaf" keyword already consumed
|
||||
let name = self.expect_ident()?;
|
||||
self.expect_token(&Token::LBrace)?;
|
||||
|
||||
let mut reads = None;
|
||||
let mut actions = Vec::new();
|
||||
|
||||
loop {
|
||||
match self.lexer.peek_token()? {
|
||||
Token::RBrace => {
|
||||
self.lexer.next_token()?;
|
||||
break;
|
||||
}
|
||||
Token::Ident(ref kw) => {
|
||||
let kw = kw.clone();
|
||||
match kw.as_str() {
|
||||
"reads" => {
|
||||
self.lexer.next_token()?;
|
||||
let actor = self.expect_ident()?;
|
||||
self.expect_token(&Token::Dot)?;
|
||||
let window = self.expect_ident()?;
|
||||
reads = Some(ReadClause { actor, window });
|
||||
}
|
||||
"process" => {
|
||||
self.lexer.next_token()?;
|
||||
actions = self.parse_leaf_actions()?;
|
||||
}
|
||||
_ => return Err(self.error(&format!(
|
||||
"unexpected keyword '{}' in leaf body", kw
|
||||
))),
|
||||
}
|
||||
}
|
||||
other => return Err(self.error(&format!(
|
||||
"unexpected token {} in leaf body", other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LeafDecl { name, reads, actions })
|
||||
}
|
||||
|
||||
fn parse_pipeline(&mut self) -> Result<PipelineDecl, ParseError> {
|
||||
// "pipeline" keyword already consumed
|
||||
let name = self.expect_ident()?;
|
||||
self.expect_token(&Token::LBrace)?;
|
||||
|
||||
let mut stages = Vec::new();
|
||||
stages.push(self.expect_ident()?);
|
||||
|
||||
loop {
|
||||
match self.lexer.peek_token()? {
|
||||
Token::Arrow => {
|
||||
self.lexer.next_token()?;
|
||||
stages.push(self.expect_ident()?);
|
||||
}
|
||||
Token::RBrace => {
|
||||
self.lexer.next_token()?;
|
||||
break;
|
||||
}
|
||||
other => return Err(self.error(&format!(
|
||||
"expected '->' or '}}' in pipeline, got {}", other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PipelineDecl { name, stages })
|
||||
}
|
||||
|
||||
fn parse_ident_list(&mut self) -> Result<Vec<String>, ParseError> {
|
||||
self.expect_token(&Token::LBracket)?;
|
||||
let mut items = Vec::new();
|
||||
loop {
|
||||
match self.lexer.peek_token()? {
|
||||
Token::RBracket => {
|
||||
self.lexer.next_token()?;
|
||||
break;
|
||||
}
|
||||
Token::Comma => {
|
||||
self.lexer.next_token()?;
|
||||
}
|
||||
_ => items.push(self.expect_ident()?),
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn parse_core(&mut self) -> Result<CoreDecl, ParseError> {
|
||||
// "core" keyword already consumed
|
||||
let name = self.expect_ident()?;
|
||||
self.expect_token(&Token::LBrace)?;
|
||||
|
||||
let mut actors = Vec::new();
|
||||
let mut leaves = Vec::new();
|
||||
let mut pipelines = Vec::new();
|
||||
let mut steps = 0u64;
|
||||
|
||||
loop {
|
||||
match self.lexer.peek_token()? {
|
||||
Token::RBrace => {
|
||||
self.lexer.next_token()?;
|
||||
break;
|
||||
}
|
||||
Token::Ident(ref kw) => {
|
||||
let kw = kw.clone();
|
||||
match kw.as_str() {
|
||||
"actors" => {
|
||||
self.lexer.next_token()?;
|
||||
self.expect_token(&Token::Colon)?;
|
||||
actors = self.parse_ident_list()?;
|
||||
}
|
||||
"leaves" => {
|
||||
self.lexer.next_token()?;
|
||||
self.expect_token(&Token::Colon)?;
|
||||
leaves = self.parse_ident_list()?;
|
||||
}
|
||||
"pipelines" => {
|
||||
self.lexer.next_token()?;
|
||||
self.expect_token(&Token::Colon)?;
|
||||
pipelines = self.parse_ident_list()?;
|
||||
}
|
||||
"steps" => {
|
||||
self.lexer.next_token()?;
|
||||
self.expect_token(&Token::Colon)?;
|
||||
steps = self.expect_number()?;
|
||||
}
|
||||
_ => return Err(self.error(&format!(
|
||||
"unexpected field '{}' in core body", kw
|
||||
))),
|
||||
}
|
||||
}
|
||||
other => return Err(self.error(&format!(
|
||||
"unexpected token {} in core body", other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CoreDecl { name, actors, leaves, pipelines, steps })
|
||||
}
|
||||
|
||||
pub fn parse_program(&mut self) -> Result<L1Program, ParseError> {
|
||||
let mut actors = Vec::new();
|
||||
let mut leaves = Vec::new();
|
||||
let mut pipelines = Vec::new();
|
||||
let mut cores = Vec::new();
|
||||
|
||||
loop {
|
||||
match self.lexer.peek_token()? {
|
||||
Token::Eof => break,
|
||||
Token::Ident(ref kw) => {
|
||||
let kw = kw.clone();
|
||||
match kw.as_str() {
|
||||
"actor" => {
|
||||
self.lexer.next_token()?;
|
||||
actors.push(self.parse_actor()?);
|
||||
}
|
||||
"leaf" => {
|
||||
self.lexer.next_token()?;
|
||||
leaves.push(self.parse_leaf()?);
|
||||
}
|
||||
"pipeline" => {
|
||||
self.lexer.next_token()?;
|
||||
pipelines.push(self.parse_pipeline()?);
|
||||
}
|
||||
"core" => {
|
||||
self.lexer.next_token()?;
|
||||
cores.push(self.parse_core()?);
|
||||
}
|
||||
_ => return Err(self.error(&format!(
|
||||
"unexpected top-level keyword '{}'", kw
|
||||
))),
|
||||
}
|
||||
}
|
||||
other => return Err(self.error(&format!(
|
||||
"unexpected token {} at top level", other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(L1Program { actors, leaves, pipelines, cores })
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse L1 DSL text into an L1 program.
|
||||
pub fn parse(input: &str) -> Result<L1Program, ParseError> {
|
||||
Parser::new(input).parse_program()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_counter_example() {
|
||||
let input = include_str!("../examples/counter.l1");
|
||||
let program = parse(input).expect("counter.l1 should parse");
|
||||
|
||||
assert_eq!(program.actors.len(), 1);
|
||||
assert_eq!(program.actors[0].name, "counter");
|
||||
assert_eq!(program.actors[0].state.len(), 1);
|
||||
assert_eq!(program.actors[0].state[0].name, "count");
|
||||
assert_eq!(program.actors[0].state[0].ty, FieldType::U64);
|
||||
assert_eq!(program.actors[0].state[0].init, 0);
|
||||
|
||||
assert_eq!(program.actors[0].windows.len(), 1);
|
||||
assert_eq!(program.actors[0].windows[0].name, "count_view");
|
||||
assert_eq!(program.actors[0].windows[0].fields, vec!["count"]);
|
||||
assert_eq!(program.actors[0].windows[0].readers, vec!["display"]);
|
||||
|
||||
assert_eq!(program.actors[0].handlers.len(), 1);
|
||||
assert_eq!(program.actors[0].handlers[0].message_type, "Increment");
|
||||
assert_eq!(program.actors[0].handlers[0].args.len(), 1);
|
||||
assert_eq!(program.actors[0].handlers[0].args[0].0, "amount");
|
||||
|
||||
assert_eq!(program.leaves.len(), 2);
|
||||
assert_eq!(program.leaves[0].name, "ticker");
|
||||
assert_eq!(program.leaves[1].name, "display");
|
||||
assert!(program.leaves[1].reads.is_some());
|
||||
|
||||
assert_eq!(program.pipelines.len(), 1);
|
||||
assert_eq!(program.pipelines[0].stages, vec!["ticker", "counter", "display"]);
|
||||
|
||||
assert_eq!(program.cores.len(), 1);
|
||||
assert_eq!(program.cores[0].steps, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_window_example() {
|
||||
let input = include_str!("../examples/window.l1");
|
||||
let program = parse(input).expect("window.l1 should parse");
|
||||
|
||||
assert_eq!(program.actors.len(), 1);
|
||||
assert_eq!(program.actors[0].name, "accumulator");
|
||||
assert_eq!(program.actors[0].state[0].name, "total");
|
||||
assert_eq!(program.actors[0].state[0].init, 0);
|
||||
|
||||
assert_eq!(program.actors[0].windows[0].name, "total_view");
|
||||
assert_eq!(program.actors[0].windows[0].readers, vec!["observe"]);
|
||||
|
||||
assert_eq!(program.leaves.len(), 2);
|
||||
assert_eq!(program.leaves[0].name, "source");
|
||||
assert_eq!(program.leaves[1].name, "observe");
|
||||
assert!(program.leaves[1].reads.is_some());
|
||||
assert_eq!(program.leaves[1].actions.len(), 2);
|
||||
|
||||
assert_eq!(program.cores[0].steps, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_error_unexpected_top_level() {
|
||||
let input = "foobar { }";
|
||||
let err = parse(input).unwrap_err();
|
||||
assert!(err.message.contains("unexpected top-level keyword 'foobar'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_error_missing_brace() {
|
||||
let input = "actor counter state { count: u64 = 0 } }";
|
||||
let err = parse(input).unwrap_err();
|
||||
assert!(err.message.contains("expected '{'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_error_unknown_type() {
|
||||
let input = "actor a { state { x: bool = 0 } }";
|
||||
let err = parse(input).unwrap_err();
|
||||
assert!(err.message.contains("unknown type: bool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_error_malformed_pipeline() {
|
||||
let input = "pipeline p { a -> }";
|
||||
let err = parse(input).unwrap_err();
|
||||
assert!(err.message.contains("expected identifier"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_matches_hand_built_counter() {
|
||||
let input = include_str!("../examples/counter.l1");
|
||||
let parsed = parse(input).expect("should parse");
|
||||
let hand_built = crate::l1_ir::tests::build_counter_program();
|
||||
|
||||
assert_eq!(parsed.actors.len(), hand_built.actors.len());
|
||||
assert_eq!(parsed.actors[0].name, hand_built.actors[0].name);
|
||||
assert_eq!(parsed.actors[0].state, hand_built.actors[0].state);
|
||||
assert_eq!(parsed.actors[0].windows, hand_built.actors[0].windows);
|
||||
assert_eq!(parsed.actors[0].handlers[0].message_type, hand_built.actors[0].handlers[0].message_type);
|
||||
assert_eq!(parsed.leaves.len(), hand_built.leaves.len());
|
||||
assert_eq!(parsed.pipelines, hand_built.pipelines);
|
||||
assert_eq!(parsed.cores, hand_built.cores);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_matches_hand_built_window() {
|
||||
let input = include_str!("../examples/window.l1");
|
||||
let parsed = parse(input).expect("should parse");
|
||||
let hand_built = crate::l1_ir::tests::build_window_program();
|
||||
|
||||
assert_eq!(parsed.actors[0].name, hand_built.actors[0].name);
|
||||
assert_eq!(parsed.actors[0].state, hand_built.actors[0].state);
|
||||
assert_eq!(parsed.actors[0].windows, hand_built.actors[0].windows);
|
||||
assert_eq!(parsed.leaves.len(), hand_built.leaves.len());
|
||||
assert_eq!(parsed.pipelines, hand_built.pipelines);
|
||||
assert_eq!(parsed.cores, hand_built.cores);
|
||||
}
|
||||
}
|
||||
307
src/pipeline.rs
Normal file
307
src/pipeline.rs
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
use crate::codegen::{self, CodegenError, ExecutionResult, MachineCode};
|
||||
use crate::emit::{self, EmitError};
|
||||
use crate::l0_ir::L0Program;
|
||||
use crate::l1_ir::L1Program;
|
||||
use crate::parser::{self, ParseError};
|
||||
use crate::translate_validate::{self, VerificationReport};
|
||||
use crate::verify_l0;
|
||||
use crate::verify_l1::{self, VerifyError};
|
||||
use std::fmt;
|
||||
|
||||
/// Errors from any stage of the pipeline.
|
||||
#[derive(Debug)]
|
||||
pub enum PipelineError {
|
||||
Parse(ParseError),
|
||||
VerifyL1(VerifyError),
|
||||
Emit(EmitError),
|
||||
VerifyL0(Vec<verify_l0::L0VerifyError>),
|
||||
Codegen(CodegenError),
|
||||
TranslationValidation(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for PipelineError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
PipelineError::Parse(e) => write!(f, "parse: {}", e),
|
||||
PipelineError::VerifyL1(e) => write!(f, "L1 verify: {}", e),
|
||||
PipelineError::Emit(e) => write!(f, "emit: {}", e),
|
||||
PipelineError::VerifyL0(errs) => {
|
||||
write!(f, "L0 verify:")?;
|
||||
for e in errs {
|
||||
write!(f, " {}", e)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
PipelineError::Codegen(e) => write!(f, "codegen: {}", e),
|
||||
PipelineError::TranslationValidation(msg) => write!(f, "translation validation: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PipelineError {}
|
||||
|
||||
/// All intermediate representations captured during pipeline execution.
|
||||
pub struct PipelineIR {
|
||||
pub l1: L1Program,
|
||||
pub l0: L0Program,
|
||||
pub machine_code: MachineCode,
|
||||
}
|
||||
|
||||
/// Run the full pipeline: DSL text -> parse -> L1 IR -> verify -> emit L0 IR
|
||||
/// -> verify -> codegen -> execute.
|
||||
///
|
||||
/// Returns both the execution result and captured intermediate representations.
|
||||
pub fn run(source: &str) -> Result<(ExecutionResult, PipelineIR), PipelineError> {
|
||||
// Parse
|
||||
let l1 = parser::parse(source).map_err(PipelineError::Parse)?;
|
||||
|
||||
// Verify L1
|
||||
verify_l1::verify(&l1).map_err(PipelineError::VerifyL1)?;
|
||||
|
||||
// Emit L0 IR
|
||||
let l0 = emit::emit(&l1).map_err(PipelineError::Emit)?;
|
||||
|
||||
// Verify L0
|
||||
verify_l0::verify(&l0).map_err(PipelineError::VerifyL0)?;
|
||||
|
||||
// Codegen
|
||||
let mc = codegen::codegen(&l0).map_err(PipelineError::Codegen)?;
|
||||
|
||||
// Execute
|
||||
let result = codegen::execute(&mc).map_err(PipelineError::Codegen)?;
|
||||
|
||||
let ir = PipelineIR {
|
||||
l1,
|
||||
l0,
|
||||
machine_code: mc,
|
||||
};
|
||||
|
||||
Ok((result, ir))
|
||||
}
|
||||
|
||||
/// Dump all intermediate representations for a given DSL input.
|
||||
/// Returns a human-readable string showing L1 IR, L0 IR, and x86-64 hex.
|
||||
pub fn dump_ir(source: &str) -> Result<String, PipelineError> {
|
||||
let l1 = parser::parse(source).map_err(PipelineError::Parse)?;
|
||||
verify_l1::verify(&l1).map_err(PipelineError::VerifyL1)?;
|
||||
let l0 = emit::emit(&l1).map_err(PipelineError::Emit)?;
|
||||
verify_l0::verify(&l0).map_err(PipelineError::VerifyL0)?;
|
||||
let mc = codegen::codegen(&l0).map_err(PipelineError::Codegen)?;
|
||||
|
||||
let mut out = String::new();
|
||||
|
||||
out.push_str("========== L1 IR ==========\n");
|
||||
out.push_str(&format!("{}", l1));
|
||||
out.push('\n');
|
||||
|
||||
out.push_str("========== L0 IR ==========\n");
|
||||
out.push_str(&format!("{}", l0));
|
||||
out.push('\n');
|
||||
|
||||
out.push_str("========== x86-64 Machine Code ==========\n");
|
||||
out.push_str(&mc.hex_dump());
|
||||
out.push('\n');
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Run the full verification pipeline: parse → L1 verify → emit → L0 verify → translate validate → report.
|
||||
///
|
||||
/// Returns the verification report containing results from all three validation tools.
|
||||
pub fn verify_translation(source: &str) -> Result<VerificationReport, PipelineError> {
|
||||
// Parse
|
||||
let l1 = parser::parse(source).map_err(PipelineError::Parse)?;
|
||||
|
||||
// Verify L1
|
||||
verify_l1::verify(&l1).map_err(PipelineError::VerifyL1)?;
|
||||
|
||||
// Emit L0 IR
|
||||
let l0 = emit::emit(&l1).map_err(PipelineError::Emit)?;
|
||||
|
||||
// Verify L0
|
||||
verify_l0::verify(&l0).map_err(PipelineError::VerifyL0)?;
|
||||
|
||||
// Translation validation
|
||||
let report = translate_validate::validate(&l1, &l0);
|
||||
|
||||
if !report.all_passed() {
|
||||
return Err(PipelineError::TranslationValidation(format!("{}", report)));
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn counter_end_to_end() {
|
||||
let source = include_str!("../examples/counter.l1");
|
||||
let (result, _ir) = run(source).expect("counter pipeline should succeed");
|
||||
let count = result.read_u64("counter_state", 0).expect("should read counter_state");
|
||||
assert_eq!(count, 5, "counter should be 5 after 5 steps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_end_to_end() {
|
||||
let source = include_str!("../examples/window.l1");
|
||||
let (result, _ir) = run(source).expect("window pipeline should succeed");
|
||||
let total = result.read_u64("accumulator_state", 0).expect("should read accumulator_state");
|
||||
assert_eq!(total, 30, "total should be 30 after 3 steps of Add(10)");
|
||||
let observed = result.read_output_values();
|
||||
assert_eq!(observed, vec![10, 20, 30], "observed values should be [10, 20, 30]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counter_ir_dump() {
|
||||
let source = include_str!("../examples/counter.l1");
|
||||
let dump = dump_ir(source).expect("dump should succeed");
|
||||
assert!(dump.contains("========== L1 IR =========="));
|
||||
assert!(dump.contains("========== L0 IR =========="));
|
||||
assert!(dump.contains("========== x86-64 Machine Code =========="));
|
||||
assert!(dump.contains("actor counter"));
|
||||
assert!(dump.contains("=== Regions ==="));
|
||||
assert!(dump.contains("counter_state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_ir_dump() {
|
||||
let source = include_str!("../examples/window.l1");
|
||||
let dump = dump_ir(source).expect("dump should succeed");
|
||||
assert!(dump.contains("========== L1 IR =========="));
|
||||
assert!(dump.contains("accumulator"));
|
||||
assert!(dump.contains("output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counter_test_vector_full_pipeline() {
|
||||
// Full test vector: DSL source -> L1 IR -> L0 IR -> x86-64 -> result
|
||||
let source = include_str!("../examples/counter.l1");
|
||||
let (result, ir) = run(source).expect("pipeline should succeed");
|
||||
|
||||
// 1. DSL source is the counter.l1 file (inlined via include_str!)
|
||||
assert!(source.contains("actor counter"));
|
||||
assert!(source.contains("on Increment(amount: u64)"));
|
||||
assert!(source.contains("steps: 5"));
|
||||
|
||||
// 2. Expected L1 IR text
|
||||
let l1_text = format!("{}", ir.l1);
|
||||
assert!(l1_text.contains("actor counter {"));
|
||||
assert!(l1_text.contains("count: u64 = 0"));
|
||||
assert!(l1_text.contains("window count_view : (count) readers(display)"));
|
||||
assert!(l1_text.contains("on Increment(amount: u64) { count = (count + amount) }"));
|
||||
assert!(l1_text.contains("leaf ticker {"));
|
||||
assert!(l1_text.contains("forward(counter, Increment(1))"));
|
||||
assert!(l1_text.contains("leaf display {"));
|
||||
assert!(l1_text.contains("pipeline main { ticker -> counter -> display }"));
|
||||
assert!(l1_text.contains("steps: 5"));
|
||||
|
||||
// 3. Expected L0 IR text
|
||||
let l0_text = format!("{}", ir.l0);
|
||||
assert!(l0_text.contains("=== Regions ==="));
|
||||
assert!(l0_text.contains("region counter_state : 8 bytes, rw, state"));
|
||||
assert!(l0_text.contains("queue"));
|
||||
assert!(l0_text.contains("region step_counter : 8 bytes, rw, control"));
|
||||
assert!(l0_text.contains("=== Blocks ==="));
|
||||
assert!(l0_text.contains("entry:"));
|
||||
assert!(l0_text.contains("loop_check:"));
|
||||
assert!(l0_text.contains("step:"));
|
||||
assert!(l0_text.contains("exit:"));
|
||||
assert!(l0_text.contains("load.64"));
|
||||
assert!(l0_text.contains("store.64"));
|
||||
assert!(l0_text.contains("cmp.lt"));
|
||||
assert!(l0_text.contains("branch"));
|
||||
assert!(l0_text.contains("queue_push"));
|
||||
assert!(l0_text.contains("queue_pop"));
|
||||
assert!(l0_text.contains("terminate"));
|
||||
|
||||
// 4. Expected x86-64 bytes (non-empty hex string)
|
||||
let hex = ir.machine_code.hex_dump();
|
||||
assert!(!hex.is_empty(), "machine code should not be empty");
|
||||
// Verify it ends with ret (c3) preceded by the epilogue pops
|
||||
assert!(hex.contains("c3"), "machine code should contain ret instruction");
|
||||
|
||||
// 5. Expected execution result
|
||||
let count = result.read_u64("counter_state", 0).unwrap();
|
||||
assert_eq!(count, 5, "counter should equal step count (5)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_test_vector_full_pipeline() {
|
||||
// Full test vector: DSL source -> L1 IR -> L0 IR -> x86-64 -> result
|
||||
let source = include_str!("../examples/window.l1");
|
||||
let (result, ir) = run(source).expect("pipeline should succeed");
|
||||
|
||||
// 1. DSL source
|
||||
assert!(source.contains("actor accumulator"));
|
||||
assert!(source.contains("on Add(value: u64)"));
|
||||
assert!(source.contains("steps: 3"));
|
||||
|
||||
// 2. Expected L1 IR text
|
||||
let l1_text = format!("{}", ir.l1);
|
||||
assert!(l1_text.contains("actor accumulator {"));
|
||||
assert!(l1_text.contains("total: u64 = 0"));
|
||||
assert!(l1_text.contains("window total_view : (total) readers(observe)"));
|
||||
assert!(l1_text.contains("on Add(value: u64) { total = (total + value) }"));
|
||||
assert!(l1_text.contains("leaf source {"));
|
||||
assert!(l1_text.contains("forward(accumulator, Add(10))"));
|
||||
assert!(l1_text.contains("leaf observe {"));
|
||||
assert!(l1_text.contains("emit(total)"));
|
||||
assert!(l1_text.contains("pipeline main { source -> accumulator -> observe }"));
|
||||
assert!(l1_text.contains("steps: 3"));
|
||||
|
||||
// 3. Expected L0 IR text
|
||||
let l0_text = format!("{}", ir.l0);
|
||||
assert!(l0_text.contains("region accumulator_state : 8 bytes, rw, state"));
|
||||
assert!(l0_text.contains("region output :"));
|
||||
assert!(l0_text.contains("output"));
|
||||
assert!(l0_text.contains("entry:"));
|
||||
assert!(l0_text.contains("queue_push output"));
|
||||
|
||||
// 4. x86-64 bytes
|
||||
let hex = ir.machine_code.hex_dump();
|
||||
assert!(!hex.is_empty());
|
||||
assert!(hex.contains("c3"));
|
||||
|
||||
// 5. Expected execution result
|
||||
let total = result.read_u64("accumulator_state", 0).unwrap();
|
||||
assert_eq!(total, 30, "total should be 30");
|
||||
let observed = result.read_output_values();
|
||||
assert_eq!(observed, vec![10, 20, 30], "observed values should be [10, 20, 30]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_error_on_invalid_input() {
|
||||
let result = run("invalid garbage input");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_translation_counter() {
|
||||
let source = include_str!("../examples/counter.l1");
|
||||
let report = verify_translation(source).expect("counter verification should pass");
|
||||
assert!(report.all_passed());
|
||||
assert_eq!(report.results.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_translation_window() {
|
||||
let source = include_str!("../examples/window.l1");
|
||||
let report = verify_translation(source).expect("window verification should pass");
|
||||
assert!(report.all_passed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_translation_product() {
|
||||
let source = include_str!("../examples/product.l1");
|
||||
let report = verify_translation(source).expect("product verification should pass");
|
||||
assert!(report.all_passed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_translation_invalid_input() {
|
||||
let result = verify_translation("invalid garbage");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
1849
src/repl.rs
Normal file
1849
src/repl.rs
Normal file
File diff suppressed because it is too large
Load diff
1127
src/translate_validate.rs
Normal file
1127
src/translate_validate.rs
Normal file
File diff suppressed because it is too large
Load diff
642
src/verify_l0.rs
Normal file
642
src/verify_l0.rs
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
use crate::l0_ir::*;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt;
|
||||
|
||||
/// Error kinds for L0 verification failures.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum L0ErrorKind {
|
||||
/// A branch or jump targets a block that does not exist.
|
||||
DanglingBlockTarget { op_desc: String, target: String },
|
||||
/// A block is unreachable from the entry block.
|
||||
UnreachableBlock { block: String },
|
||||
/// A load/store references a region that is not declared.
|
||||
UndeclaredRegion { op_desc: String, region: String },
|
||||
/// A load/store access exceeds region bounds (offset + size > region size).
|
||||
OutOfBoundsAccess {
|
||||
op_desc: String,
|
||||
region: String,
|
||||
offset: usize,
|
||||
access_size: usize,
|
||||
region_size: usize,
|
||||
},
|
||||
/// A store targets a read-only region.
|
||||
WriteToReadOnly { op_desc: String, region: String },
|
||||
/// queue_push or queue_pop references a non-queue region.
|
||||
QueueOpOnNonQueue { op_desc: String, region: String, actual_kind: RegionKind },
|
||||
/// No blocks defined.
|
||||
NoBlocks,
|
||||
/// No entry block (first block is the entry point).
|
||||
NoEntryBlock,
|
||||
}
|
||||
|
||||
/// A single L0 verification error with location context.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct L0VerifyError {
|
||||
pub block: String,
|
||||
pub kind: L0ErrorKind,
|
||||
}
|
||||
|
||||
impl fmt::Display for L0VerifyError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match &self.kind {
|
||||
L0ErrorKind::DanglingBlockTarget { op_desc, target } =>
|
||||
write!(f, "in block '{}': {} targets non-existent block '{}'", self.block, op_desc, target),
|
||||
L0ErrorKind::UnreachableBlock { block } =>
|
||||
write!(f, "block '{}' is unreachable from entry", block),
|
||||
L0ErrorKind::UndeclaredRegion { op_desc, region } =>
|
||||
write!(f, "in block '{}': {} references undeclared region '{}'", self.block, op_desc, region),
|
||||
L0ErrorKind::OutOfBoundsAccess { op_desc, region, offset, access_size, region_size } =>
|
||||
write!(f, "in block '{}': {} accesses {}[{}..{}] but region is only {} bytes",
|
||||
self.block, op_desc, region, offset, offset + access_size, region_size),
|
||||
L0ErrorKind::WriteToReadOnly { op_desc, region } =>
|
||||
write!(f, "in block '{}': {} writes to read-only region '{}'", self.block, op_desc, region),
|
||||
L0ErrorKind::QueueOpOnNonQueue { op_desc, region, actual_kind } =>
|
||||
write!(f, "in block '{}': {} uses region '{}' which is {} (not queue)",
|
||||
self.block, op_desc, region, actual_kind),
|
||||
L0ErrorKind::NoBlocks =>
|
||||
write!(f, "program has no blocks"),
|
||||
L0ErrorKind::NoEntryBlock =>
|
||||
write!(f, "program has no entry block"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for L0VerifyError {}
|
||||
|
||||
/// Verify a concrete L0 IR program.
|
||||
///
|
||||
/// Checks:
|
||||
/// 1. Control flow: all branch/jump targets exist, no unreachable blocks
|
||||
/// 2. Region access: all load/store reference declared regions, within bounds, respecting access modes
|
||||
/// 3. Memory bounds: total region bytes are finite (implicit — all sizes are usize)
|
||||
/// 4. Queue safety: queue_push/queue_pop only on queue-typed regions
|
||||
pub fn verify(program: &L0Program) -> Result<(), Vec<L0VerifyError>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if program.blocks.is_empty() {
|
||||
errors.push(L0VerifyError {
|
||||
block: "<program>".to_string(),
|
||||
kind: L0ErrorKind::NoBlocks,
|
||||
});
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
// Build lookup tables
|
||||
let block_names: HashSet<&str> = program.blocks.iter().map(|b| b.name.as_str()).collect();
|
||||
let regions: HashMap<&str, &Region> = program.regions.iter().map(|r| (r.name.as_str(), r)).collect();
|
||||
|
||||
// Check 1: Control flow — all branch/jump targets exist
|
||||
for block in &program.blocks {
|
||||
for op in &block.ops {
|
||||
match op {
|
||||
Op::Branch { true_target, false_target, .. } => {
|
||||
if !block_names.contains(true_target.as_str()) {
|
||||
errors.push(L0VerifyError {
|
||||
block: block.name.clone(),
|
||||
kind: L0ErrorKind::DanglingBlockTarget {
|
||||
op_desc: format!("{}", op),
|
||||
target: true_target.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
if !block_names.contains(false_target.as_str()) {
|
||||
errors.push(L0VerifyError {
|
||||
block: block.name.clone(),
|
||||
kind: L0ErrorKind::DanglingBlockTarget {
|
||||
op_desc: format!("{}", op),
|
||||
target: false_target.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Op::Jump { target } => {
|
||||
if !block_names.contains(target.as_str()) {
|
||||
errors.push(L0VerifyError {
|
||||
block: block.name.clone(),
|
||||
kind: L0ErrorKind::DanglingBlockTarget {
|
||||
op_desc: format!("{}", op),
|
||||
target: target.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 1b: Reachability — all blocks reachable from entry (first block)
|
||||
let mut reachable: HashSet<&str> = HashSet::new();
|
||||
let mut worklist: Vec<&str> = vec![program.blocks[0].name.as_str()];
|
||||
while let Some(name) = worklist.pop() {
|
||||
if !reachable.insert(name) {
|
||||
continue;
|
||||
}
|
||||
// Find this block and collect successors
|
||||
if let Some(block) = program.blocks.iter().find(|b| b.name == name) {
|
||||
for op in &block.ops {
|
||||
match op {
|
||||
Op::Branch { true_target, false_target, .. } => {
|
||||
if block_names.contains(true_target.as_str()) {
|
||||
worklist.push(true_target.as_str());
|
||||
}
|
||||
if block_names.contains(false_target.as_str()) {
|
||||
worklist.push(false_target.as_str());
|
||||
}
|
||||
}
|
||||
Op::Jump { target } => {
|
||||
if block_names.contains(target.as_str()) {
|
||||
worklist.push(target.as_str());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for block in &program.blocks {
|
||||
if !reachable.contains(block.name.as_str()) {
|
||||
errors.push(L0VerifyError {
|
||||
block: block.name.clone(),
|
||||
kind: L0ErrorKind::UnreachableBlock {
|
||||
block: block.name.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check 2 & 3 & 4: Region access, bounds, queue safety
|
||||
for block in &program.blocks {
|
||||
for op in &block.ops {
|
||||
match op {
|
||||
Op::Load { size, region, offset, .. } => {
|
||||
check_region_read(block, op, region, *offset, size.bytes(), ®ions, &mut errors);
|
||||
}
|
||||
Op::Store { size, region, offset, .. } => {
|
||||
check_region_write(block, op, region, *offset, size.bytes(), ®ions, &mut errors);
|
||||
}
|
||||
Op::QueuePush { region, .. } => {
|
||||
check_queue_region(block, op, region, ®ions, &mut errors);
|
||||
}
|
||||
Op::QueuePop { region, .. } => {
|
||||
check_queue_region(block, op, region, ®ions, &mut errors);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that a region exists and the read access is within bounds.
|
||||
fn check_region_read(
|
||||
block: &Block,
|
||||
op: &Op,
|
||||
region_name: &str,
|
||||
offset: usize,
|
||||
access_size: usize,
|
||||
regions: &HashMap<&str, &Region>,
|
||||
errors: &mut Vec<L0VerifyError>,
|
||||
) {
|
||||
match regions.get(region_name) {
|
||||
None => {
|
||||
errors.push(L0VerifyError {
|
||||
block: block.name.clone(),
|
||||
kind: L0ErrorKind::UndeclaredRegion {
|
||||
op_desc: format!("{}", op),
|
||||
region: region_name.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
Some(rgn) => {
|
||||
if offset + access_size > rgn.size {
|
||||
errors.push(L0VerifyError {
|
||||
block: block.name.clone(),
|
||||
kind: L0ErrorKind::OutOfBoundsAccess {
|
||||
op_desc: format!("{}", op),
|
||||
region: region_name.to_string(),
|
||||
offset,
|
||||
access_size,
|
||||
region_size: rgn.size,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that a region exists, is read-write, and the write access is within bounds.
|
||||
fn check_region_write(
|
||||
block: &Block,
|
||||
op: &Op,
|
||||
region_name: &str,
|
||||
offset: usize,
|
||||
access_size: usize,
|
||||
regions: &HashMap<&str, &Region>,
|
||||
errors: &mut Vec<L0VerifyError>,
|
||||
) {
|
||||
match regions.get(region_name) {
|
||||
None => {
|
||||
errors.push(L0VerifyError {
|
||||
block: block.name.clone(),
|
||||
kind: L0ErrorKind::UndeclaredRegion {
|
||||
op_desc: format!("{}", op),
|
||||
region: region_name.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
Some(rgn) => {
|
||||
if rgn.access == AccessMode::ReadOnly {
|
||||
errors.push(L0VerifyError {
|
||||
block: block.name.clone(),
|
||||
kind: L0ErrorKind::WriteToReadOnly {
|
||||
op_desc: format!("{}", op),
|
||||
region: region_name.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
if offset + access_size > rgn.size {
|
||||
errors.push(L0VerifyError {
|
||||
block: block.name.clone(),
|
||||
kind: L0ErrorKind::OutOfBoundsAccess {
|
||||
op_desc: format!("{}", op),
|
||||
region: region_name.to_string(),
|
||||
offset,
|
||||
access_size,
|
||||
region_size: rgn.size,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that a queue operation references a queue-typed region.
|
||||
fn check_queue_region(
|
||||
block: &Block,
|
||||
op: &Op,
|
||||
region_name: &str,
|
||||
regions: &HashMap<&str, &Region>,
|
||||
errors: &mut Vec<L0VerifyError>,
|
||||
) {
|
||||
match regions.get(region_name) {
|
||||
None => {
|
||||
errors.push(L0VerifyError {
|
||||
block: block.name.clone(),
|
||||
kind: L0ErrorKind::UndeclaredRegion {
|
||||
op_desc: format!("{}", op),
|
||||
region: region_name.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
Some(rgn) => {
|
||||
// Queue ops on output regions are allowed (emit uses queue_push on output)
|
||||
if rgn.kind != RegionKind::Queue && rgn.kind != RegionKind::Output {
|
||||
errors.push(L0VerifyError {
|
||||
block: block.name.clone(),
|
||||
kind: L0ErrorKind::QueueOpOnNonQueue {
|
||||
op_desc: format!("{}", op),
|
||||
region: region_name.to_string(),
|
||||
actual_kind: rgn.kind,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute total memory usage across all regions.
|
||||
pub fn total_memory(program: &L0Program) -> usize {
|
||||
program.regions.iter().map(|r| r.size).sum()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::l0_ir::tests::build_counter_l0;
|
||||
|
||||
#[test]
|
||||
fn valid_counter_passes_verification() {
|
||||
let program = build_counter_l0();
|
||||
assert!(verify(&program).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_emitted_counter_passes() {
|
||||
let input = include_str!("../examples/counter.l1");
|
||||
let parsed = crate::parser::parse(input).expect("should parse");
|
||||
crate::verify_l1::verify(&parsed).expect("should verify L1");
|
||||
let l0 = crate::emit::emit(&parsed).expect("should emit");
|
||||
assert!(verify(&l0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_emitted_window_passes() {
|
||||
let input = include_str!("../examples/window.l1");
|
||||
let parsed = crate::parser::parse(input).expect("should parse");
|
||||
crate::verify_l1::verify(&parsed).expect("should verify L1");
|
||||
let l0 = crate::emit::emit(&parsed).expect("should emit");
|
||||
assert!(verify(&l0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_memory_for_counter() {
|
||||
let program = build_counter_l0();
|
||||
let mem = total_memory(&program);
|
||||
// counter_state(8) + msg_queue(128) + step_counter(8) = 144
|
||||
assert_eq!(mem, 144);
|
||||
}
|
||||
|
||||
// --- Negative tests ---
|
||||
|
||||
#[test]
|
||||
fn reject_dangling_branch_target() {
|
||||
let program = L0Program {
|
||||
regions: vec![],
|
||||
blocks: vec![
|
||||
Block {
|
||||
name: "entry".to_string(),
|
||||
ops: vec![
|
||||
Op::LoadImm { value: 1, dest: Register(0) },
|
||||
Op::Branch {
|
||||
cond: Register(0),
|
||||
true_target: "exists".to_string(),
|
||||
false_target: "nowhere".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
Block {
|
||||
name: "exists".to_string(),
|
||||
ops: vec![Op::Terminate],
|
||||
},
|
||||
],
|
||||
};
|
||||
let errs = verify(&program).unwrap_err();
|
||||
assert!(errs.iter().any(|e| matches!(&e.kind,
|
||||
L0ErrorKind::DanglingBlockTarget { target, .. } if target == "nowhere"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_dangling_jump_target() {
|
||||
let program = L0Program {
|
||||
regions: vec![],
|
||||
blocks: vec![
|
||||
Block {
|
||||
name: "entry".to_string(),
|
||||
ops: vec![
|
||||
Op::Jump { target: "ghost".to_string() },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
let errs = verify(&program).unwrap_err();
|
||||
assert!(errs.iter().any(|e| matches!(&e.kind,
|
||||
L0ErrorKind::DanglingBlockTarget { target, .. } if target == "ghost"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_unreachable_block() {
|
||||
let program = L0Program {
|
||||
regions: vec![],
|
||||
blocks: vec![
|
||||
Block {
|
||||
name: "entry".to_string(),
|
||||
ops: vec![Op::Terminate],
|
||||
},
|
||||
Block {
|
||||
name: "orphan".to_string(),
|
||||
ops: vec![Op::Terminate],
|
||||
},
|
||||
],
|
||||
};
|
||||
let errs = verify(&program).unwrap_err();
|
||||
assert!(errs.iter().any(|e| matches!(&e.kind,
|
||||
L0ErrorKind::UnreachableBlock { block } if block == "orphan"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_undeclared_region_in_load() {
|
||||
let program = L0Program {
|
||||
regions: vec![],
|
||||
blocks: vec![
|
||||
Block {
|
||||
name: "entry".to_string(),
|
||||
ops: vec![
|
||||
Op::Load {
|
||||
size: OpSize::B64,
|
||||
region: "nonexistent".to_string(),
|
||||
offset: 0,
|
||||
dest: Register(0),
|
||||
},
|
||||
Op::Terminate,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
let errs = verify(&program).unwrap_err();
|
||||
assert!(errs.iter().any(|e| matches!(&e.kind,
|
||||
L0ErrorKind::UndeclaredRegion { region, .. } if region == "nonexistent"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_out_of_bounds_access() {
|
||||
let program = L0Program {
|
||||
regions: vec![
|
||||
Region {
|
||||
name: "small".to_string(),
|
||||
size: 4,
|
||||
access: AccessMode::ReadWrite,
|
||||
kind: RegionKind::State,
|
||||
},
|
||||
],
|
||||
blocks: vec![
|
||||
Block {
|
||||
name: "entry".to_string(),
|
||||
ops: vec![
|
||||
Op::Load {
|
||||
size: OpSize::B64, // 8 bytes from a 4-byte region
|
||||
region: "small".to_string(),
|
||||
offset: 0,
|
||||
dest: Register(0),
|
||||
},
|
||||
Op::Terminate,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
let errs = verify(&program).unwrap_err();
|
||||
assert!(errs.iter().any(|e| matches!(&e.kind,
|
||||
L0ErrorKind::OutOfBoundsAccess { region, offset, access_size, region_size, .. }
|
||||
if region == "small" && *offset == 0 && *access_size == 8 && *region_size == 4
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_write_to_readonly_region() {
|
||||
let program = L0Program {
|
||||
regions: vec![
|
||||
Region {
|
||||
name: "constants".to_string(),
|
||||
size: 64,
|
||||
access: AccessMode::ReadOnly,
|
||||
kind: RegionKind::State,
|
||||
},
|
||||
],
|
||||
blocks: vec![
|
||||
Block {
|
||||
name: "entry".to_string(),
|
||||
ops: vec![
|
||||
Op::LoadImm { value: 42, dest: Register(0) },
|
||||
Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: "constants".to_string(),
|
||||
offset: 0,
|
||||
src: Register(0),
|
||||
},
|
||||
Op::Terminate,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
let errs = verify(&program).unwrap_err();
|
||||
assert!(errs.iter().any(|e| matches!(&e.kind,
|
||||
L0ErrorKind::WriteToReadOnly { region, .. } if region == "constants"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_queue_push_on_state_region() {
|
||||
let program = L0Program {
|
||||
regions: vec![
|
||||
Region {
|
||||
name: "actor_state".to_string(),
|
||||
size: 64,
|
||||
access: AccessMode::ReadWrite,
|
||||
kind: RegionKind::State,
|
||||
},
|
||||
],
|
||||
blocks: vec![
|
||||
Block {
|
||||
name: "entry".to_string(),
|
||||
ops: vec![
|
||||
Op::LoadImm { value: 1, dest: Register(0) },
|
||||
Op::QueuePush {
|
||||
region: "actor_state".to_string(),
|
||||
src: Register(0),
|
||||
},
|
||||
Op::Terminate,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
let errs = verify(&program).unwrap_err();
|
||||
assert!(errs.iter().any(|e| matches!(&e.kind,
|
||||
L0ErrorKind::QueueOpOnNonQueue { region, actual_kind: RegionKind::State, .. }
|
||||
if region == "actor_state"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_queue_pop_on_control_region() {
|
||||
let program = L0Program {
|
||||
regions: vec![
|
||||
Region {
|
||||
name: "step_counter".to_string(),
|
||||
size: 8,
|
||||
access: AccessMode::ReadWrite,
|
||||
kind: RegionKind::Control,
|
||||
},
|
||||
],
|
||||
blocks: vec![
|
||||
Block {
|
||||
name: "entry".to_string(),
|
||||
ops: vec![
|
||||
Op::QueuePop {
|
||||
region: "step_counter".to_string(),
|
||||
dest: Register(0),
|
||||
},
|
||||
Op::Terminate,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
let errs = verify(&program).unwrap_err();
|
||||
assert!(errs.iter().any(|e| matches!(&e.kind,
|
||||
L0ErrorKind::QueueOpOnNonQueue { region, actual_kind: RegionKind::Control, .. }
|
||||
if region == "step_counter"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_no_blocks() {
|
||||
let program = L0Program {
|
||||
regions: vec![],
|
||||
blocks: vec![],
|
||||
};
|
||||
let errs = verify(&program).unwrap_err();
|
||||
assert!(errs.iter().any(|e| matches!(&e.kind, L0ErrorKind::NoBlocks)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_store_out_of_bounds_with_offset() {
|
||||
let program = L0Program {
|
||||
regions: vec![
|
||||
Region {
|
||||
name: "data".to_string(),
|
||||
size: 16,
|
||||
access: AccessMode::ReadWrite,
|
||||
kind: RegionKind::State,
|
||||
},
|
||||
],
|
||||
blocks: vec![
|
||||
Block {
|
||||
name: "entry".to_string(),
|
||||
ops: vec![
|
||||
Op::LoadImm { value: 99, dest: Register(0) },
|
||||
Op::Store {
|
||||
size: OpSize::B64,
|
||||
region: "data".to_string(),
|
||||
offset: 12, // 12 + 8 = 20 > 16
|
||||
src: Register(0),
|
||||
},
|
||||
Op::Terminate,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
let errs = verify(&program).unwrap_err();
|
||||
assert!(errs.iter().any(|e| matches!(&e.kind,
|
||||
L0ErrorKind::OutOfBoundsAccess { region, offset, access_size, region_size, .. }
|
||||
if region == "data" && *offset == 12 && *access_size == 8 && *region_size == 16
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_messages_are_descriptive() {
|
||||
let program = L0Program {
|
||||
regions: vec![],
|
||||
blocks: vec![
|
||||
Block {
|
||||
name: "entry".to_string(),
|
||||
ops: vec![
|
||||
Op::Jump { target: "missing".to_string() },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
let errs = verify(&program).unwrap_err();
|
||||
let msg = format!("{}", errs[0]);
|
||||
assert!(msg.contains("entry"), "error should name the block");
|
||||
assert!(msg.contains("missing"), "error should name the target");
|
||||
}
|
||||
}
|
||||
680
src/verify_l1.rs
Normal file
680
src/verify_l1.rs
Normal file
|
|
@ -0,0 +1,680 @@
|
|||
use crate::l1_ir::*;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct VerifyError {
|
||||
pub kind: ErrorKind,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ErrorKind {
|
||||
DanglingRef,
|
||||
DuplicateOwnership,
|
||||
InvalidWindowReader,
|
||||
MalformedPipeline,
|
||||
BufferNotConsumed,
|
||||
InvalidSteps,
|
||||
PipelineCycle,
|
||||
WindowWriterConflict,
|
||||
}
|
||||
|
||||
impl fmt::Display for VerifyError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "[{:?}] {}", self.kind, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VerifyError {}
|
||||
|
||||
/// Verify an L1 program for structural correctness.
|
||||
/// Returns Ok(()) if the program passes all checks, or Err with the first violation found.
|
||||
pub fn verify(program: &L1Program) -> Result<(), VerifyError> {
|
||||
verify_ref_resolution(program)?;
|
||||
verify_exclusive_ownership(program)?;
|
||||
verify_window_separation(program)?;
|
||||
verify_pipeline_wellformedness(program)?;
|
||||
verify_buffer_conservation(program)?;
|
||||
verify_finiteness(program)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Safety: all names referenced in cores, pipelines, windows, and leaf actions resolve
|
||||
/// to declared actors/leaves/pipelines.
|
||||
fn verify_ref_resolution(program: &L1Program) -> Result<(), VerifyError> {
|
||||
let actor_names: HashSet<&str> = program.actors.iter().map(|a| a.name.as_str()).collect();
|
||||
let leaf_names: HashSet<&str> = program.leaves.iter().map(|l| l.name.as_str()).collect();
|
||||
let pipeline_names: HashSet<&str> = program.pipelines.iter().map(|p| p.name.as_str()).collect();
|
||||
let all_entity_names: HashSet<&str> = actor_names.iter().chain(leaf_names.iter()).copied().collect();
|
||||
|
||||
// Check core references
|
||||
for core in &program.cores {
|
||||
for actor_ref in &core.actors {
|
||||
if !actor_names.contains(actor_ref.as_str()) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::DanglingRef,
|
||||
message: format!(
|
||||
"core '{}' references undeclared actor '{}'",
|
||||
core.name, actor_ref
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
for leaf_ref in &core.leaves {
|
||||
if !leaf_names.contains(leaf_ref.as_str()) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::DanglingRef,
|
||||
message: format!(
|
||||
"core '{}' references undeclared leaf '{}'",
|
||||
core.name, leaf_ref
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
for pipe_ref in &core.pipelines {
|
||||
if !pipeline_names.contains(pipe_ref.as_str()) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::DanglingRef,
|
||||
message: format!(
|
||||
"core '{}' references undeclared pipeline '{}'",
|
||||
core.name, pipe_ref
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check pipeline stage references
|
||||
for pipeline in &program.pipelines {
|
||||
for stage in &pipeline.stages {
|
||||
if !all_entity_names.contains(stage.as_str()) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::DanglingRef,
|
||||
message: format!(
|
||||
"pipeline '{}' references undeclared entity '{}'",
|
||||
pipeline.name, stage
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check window reader references
|
||||
for actor in &program.actors {
|
||||
for window in &actor.windows {
|
||||
for reader in &window.readers {
|
||||
if !leaf_names.contains(reader.as_str()) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::DanglingRef,
|
||||
message: format!(
|
||||
"window '{}' on actor '{}' names undeclared leaf reader '{}'",
|
||||
window.name, actor.name, reader
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Check that window fields reference actual state fields
|
||||
let state_field_names: HashSet<&str> =
|
||||
actor.state.iter().map(|f| f.name.as_str()).collect();
|
||||
for field in &window.fields {
|
||||
if !state_field_names.contains(field.as_str()) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::DanglingRef,
|
||||
message: format!(
|
||||
"window '{}' on actor '{}' references undeclared state field '{}'",
|
||||
window.name, actor.name, field
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check leaf forward targets and read references
|
||||
for leaf in &program.leaves {
|
||||
for action in &leaf.actions {
|
||||
match action {
|
||||
LeafAction::Forward { target, .. } => {
|
||||
if !all_entity_names.contains(target.as_str()) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::DanglingRef,
|
||||
message: format!(
|
||||
"leaf '{}' forwards to undeclared target '{}'",
|
||||
leaf.name, target
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
LeafAction::ReadWindow { actor, window, field } => {
|
||||
if !actor_names.contains(actor.as_str()) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::DanglingRef,
|
||||
message: format!(
|
||||
"leaf '{}' reads from undeclared actor '{}'",
|
||||
leaf.name, actor
|
||||
),
|
||||
});
|
||||
}
|
||||
// Check the window exists on the actor
|
||||
if let Some(actor_decl) = program.actors.iter().find(|a| a.name == *actor) {
|
||||
if !actor_decl.windows.iter().any(|w| w.name == *window) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::DanglingRef,
|
||||
message: format!(
|
||||
"leaf '{}' reads from undeclared window '{}' on actor '{}'",
|
||||
leaf.name, window, actor
|
||||
),
|
||||
});
|
||||
}
|
||||
// Check the field exists in the window
|
||||
if let Some(win) = actor_decl.windows.iter().find(|w| w.name == *window) {
|
||||
if !win.fields.contains(field) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::DanglingRef,
|
||||
message: format!(
|
||||
"leaf '{}' reads field '{}' not exposed by window '{}' on actor '{}'",
|
||||
leaf.name, field, window, actor
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LeafAction::Emit(_) => {}
|
||||
}
|
||||
}
|
||||
// Check reads clause
|
||||
if let Some(reads) = &leaf.reads {
|
||||
if !actor_names.contains(reads.actor.as_str()) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::DanglingRef,
|
||||
message: format!(
|
||||
"leaf '{}' reads clause references undeclared actor '{}'",
|
||||
leaf.name, reads.actor
|
||||
),
|
||||
});
|
||||
}
|
||||
if let Some(actor_decl) = program.actors.iter().find(|a| a.name == reads.actor) {
|
||||
if !actor_decl.windows.iter().any(|w| w.name == reads.window) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::DanglingRef,
|
||||
message: format!(
|
||||
"leaf '{}' reads clause references undeclared window '{}' on actor '{}'",
|
||||
leaf.name, reads.window, reads.actor
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Safety: every actor and leaf belongs to exactly one core.
|
||||
fn verify_exclusive_ownership(program: &L1Program) -> Result<(), VerifyError> {
|
||||
let mut actor_owners: HashMap<&str, &str> = HashMap::new();
|
||||
let mut leaf_owners: HashMap<&str, &str> = HashMap::new();
|
||||
|
||||
for core in &program.cores {
|
||||
for actor_ref in &core.actors {
|
||||
if let Some(prev_core) = actor_owners.insert(actor_ref.as_str(), core.name.as_str()) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::DuplicateOwnership,
|
||||
message: format!(
|
||||
"actor '{}' is owned by both core '{}' and core '{}'",
|
||||
actor_ref, prev_core, core.name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
for leaf_ref in &core.leaves {
|
||||
if let Some(prev_core) = leaf_owners.insert(leaf_ref.as_str(), core.name.as_str()) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::DuplicateOwnership,
|
||||
message: format!(
|
||||
"leaf '{}' is owned by both core '{}' and core '{}'",
|
||||
leaf_ref, prev_core, core.name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Safety: window readers are leaves in the same core as the actor;
|
||||
/// no leaf both reads a window on an actor AND forwards messages to that same actor.
|
||||
fn verify_window_separation(program: &L1Program) -> Result<(), VerifyError> {
|
||||
// Build a map: entity_name -> core_name
|
||||
let mut entity_core: HashMap<&str, &str> = HashMap::new();
|
||||
for core in &program.cores {
|
||||
for a in &core.actors {
|
||||
entity_core.insert(a.as_str(), core.name.as_str());
|
||||
}
|
||||
for l in &core.leaves {
|
||||
entity_core.insert(l.as_str(), core.name.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
for actor in &program.actors {
|
||||
let actor_core = entity_core.get(actor.name.as_str());
|
||||
|
||||
for window in &actor.windows {
|
||||
for reader in &window.readers {
|
||||
// Reader must be a leaf
|
||||
let leaf_names: HashSet<&str> =
|
||||
program.leaves.iter().map(|l| l.name.as_str()).collect();
|
||||
if !leaf_names.contains(reader.as_str()) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::InvalidWindowReader,
|
||||
message: format!(
|
||||
"window '{}' on actor '{}' names reader '{}' which is not a leaf",
|
||||
window.name, actor.name, reader
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Reader must be in the same core as the actor
|
||||
let reader_core = entity_core.get(reader.as_str());
|
||||
if actor_core != reader_core {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::InvalidWindowReader,
|
||||
message: format!(
|
||||
"window '{}' reader '{}' is not in the same core as actor '{}'",
|
||||
window.name, reader, actor.name
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Check the reader leaf doesn't also forward messages to this actor
|
||||
if let Some(leaf) = program.leaves.iter().find(|l| l.name == *reader) {
|
||||
for action in &leaf.actions {
|
||||
if let LeafAction::Forward { target, .. } = action {
|
||||
if target == &actor.name {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::WindowWriterConflict,
|
||||
message: format!(
|
||||
"leaf '{}' both reads window '{}' on actor '{}' and forwards messages to it",
|
||||
reader, window.name, actor.name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Consistency: pipeline chain elements exist, stages connect validly.
|
||||
fn verify_pipeline_wellformedness(program: &L1Program) -> Result<(), VerifyError> {
|
||||
for pipeline in &program.pipelines {
|
||||
if pipeline.stages.len() < 2 {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::MalformedPipeline,
|
||||
message: format!(
|
||||
"pipeline '{}' has fewer than 2 stages",
|
||||
pipeline.name
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Check for duplicate stages (which would indicate a cycle)
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
for stage in &pipeline.stages {
|
||||
if !seen.insert(stage.as_str()) {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::PipelineCycle,
|
||||
message: format!(
|
||||
"pipeline '{}' contains duplicate stage '{}' (cycle detected)",
|
||||
pipeline.name, stage
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Liveness: every leaf input is consumed — forwarded or explicitly dropped.
|
||||
/// A leaf with a process body must have at least one action (forward, read, or emit).
|
||||
fn verify_buffer_conservation(program: &L1Program) -> Result<(), VerifyError> {
|
||||
for leaf in &program.leaves {
|
||||
if leaf.actions.is_empty() {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::BufferNotConsumed,
|
||||
message: format!(
|
||||
"leaf '{}' has no actions — input is not consumed",
|
||||
leaf.name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resource bound: steps is a positive integer, pipelines are acyclic.
|
||||
fn verify_finiteness(program: &L1Program) -> Result<(), VerifyError> {
|
||||
for core in &program.cores {
|
||||
if core.steps == 0 {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::InvalidSteps,
|
||||
message: format!(
|
||||
"core '{}' has steps = 0, must be positive",
|
||||
core.name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pipeline acyclicity: check that no entity appears as a stage across pipelines
|
||||
// forming a cycle. In a single pipeline this is caught by duplicate detection above.
|
||||
// Across pipelines, build a graph of entity ordering and check for cycles.
|
||||
let mut edges: Vec<(&str, &str)> = Vec::new();
|
||||
for pipeline in &program.pipelines {
|
||||
for pair in pipeline.stages.windows(2) {
|
||||
edges.push((pair[0].as_str(), pair[1].as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
// Topological sort to detect cycles
|
||||
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
let mut in_degree: HashMap<&str, usize> = HashMap::new();
|
||||
let mut all_nodes: HashSet<&str> = HashSet::new();
|
||||
|
||||
for (from, to) in &edges {
|
||||
adj.entry(*from).or_default().push(*to);
|
||||
in_degree.entry(*to).or_insert(0);
|
||||
*in_degree.entry(*to).or_default() += 1;
|
||||
in_degree.entry(*from).or_insert(0);
|
||||
all_nodes.insert(*from);
|
||||
all_nodes.insert(*to);
|
||||
}
|
||||
|
||||
let mut queue: Vec<&str> = in_degree
|
||||
.iter()
|
||||
.filter(|(_, deg)| **deg == 0)
|
||||
.map(|(node, _)| *node)
|
||||
.collect();
|
||||
|
||||
let mut visited = 0usize;
|
||||
while let Some(node) = queue.pop() {
|
||||
visited += 1;
|
||||
if let Some(neighbors) = adj.get(node) {
|
||||
for next in neighbors {
|
||||
if let Some(deg) = in_degree.get_mut(next) {
|
||||
*deg -= 1;
|
||||
if *deg == 0 {
|
||||
queue.push(*next);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if visited < all_nodes.len() {
|
||||
return Err(VerifyError {
|
||||
kind: ErrorKind::PipelineCycle,
|
||||
message: "pipeline graph contains a cycle".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::l1_ir::tests::{build_counter_program, build_window_program};
|
||||
|
||||
#[test]
|
||||
fn counter_passes_verification() {
|
||||
let program = build_counter_program();
|
||||
assert!(verify(&program).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_passes_verification() {
|
||||
let program = build_window_program();
|
||||
assert!(verify(&program).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_counter_passes_verification() {
|
||||
let input = include_str!("../examples/counter.l1");
|
||||
let program = crate::parser::parse(input).expect("should parse");
|
||||
assert!(verify(&program).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_window_passes_verification() {
|
||||
let input = include_str!("../examples/window.l1");
|
||||
let program = crate::parser::parse(input).expect("should parse");
|
||||
assert!(verify(&program).is_ok());
|
||||
}
|
||||
|
||||
// --- Negative test cases ---
|
||||
|
||||
#[test]
|
||||
fn reject_dangling_actor_ref_in_core() {
|
||||
let mut program = build_counter_program();
|
||||
program.cores[0].actors = vec!["nonexistent_actor".to_string()];
|
||||
let err = verify(&program).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::DanglingRef);
|
||||
assert!(err.message.contains("nonexistent_actor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_duplicate_ownership() {
|
||||
let mut program = build_counter_program();
|
||||
// Add a second core that also claims the counter actor
|
||||
program.cores.push(CoreDecl {
|
||||
name: "second_core".to_string(),
|
||||
actors: vec!["counter".to_string()],
|
||||
leaves: vec![],
|
||||
pipelines: vec![],
|
||||
steps: 1,
|
||||
});
|
||||
let err = verify(&program).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::DuplicateOwnership);
|
||||
assert!(err.message.contains("counter"));
|
||||
assert!(err.message.contains("main"));
|
||||
assert!(err.message.contains("second_core"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_invalid_window_reader() {
|
||||
let mut program = build_counter_program();
|
||||
// Set the window reader to a non-existent leaf
|
||||
program.actors[0].windows[0].readers = vec!["ghost_leaf".to_string()];
|
||||
let err = verify(&program).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::DanglingRef);
|
||||
assert!(err.message.contains("ghost_leaf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_window_reader_in_different_core() {
|
||||
let mut program = build_counter_program();
|
||||
// Add a new leaf and put it in a different core, then make it a window reader
|
||||
program.leaves.push(LeafDecl {
|
||||
name: "remote_leaf".to_string(),
|
||||
reads: None,
|
||||
actions: vec![LeafAction::Emit(Expr::Lit(0))],
|
||||
});
|
||||
program.actors[0].windows[0].readers = vec!["remote_leaf".to_string()];
|
||||
// remote_leaf is NOT in the main core, put it in a second core
|
||||
program.cores.push(CoreDecl {
|
||||
name: "other_core".to_string(),
|
||||
actors: vec![],
|
||||
leaves: vec!["remote_leaf".to_string()],
|
||||
pipelines: vec![],
|
||||
steps: 1,
|
||||
});
|
||||
let err = verify(&program).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::InvalidWindowReader);
|
||||
assert!(err.message.contains("remote_leaf"));
|
||||
assert!(err.message.contains("not in the same core"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_malformed_pipeline_single_stage() {
|
||||
let mut program = build_counter_program();
|
||||
program.pipelines[0].stages = vec!["ticker".to_string()];
|
||||
let err = verify(&program).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::MalformedPipeline);
|
||||
assert!(err.message.contains("fewer than 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_empty_leaf_actions() {
|
||||
let mut program = build_counter_program();
|
||||
program.leaves[0].actions = vec![];
|
||||
let err = verify(&program).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::BufferNotConsumed);
|
||||
assert!(err.message.contains("ticker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_zero_steps() {
|
||||
let mut program = build_counter_program();
|
||||
program.cores[0].steps = 0;
|
||||
let err = verify(&program).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::InvalidSteps);
|
||||
assert!(err.message.contains("steps = 0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_pipeline_cycle() {
|
||||
let mut program = build_counter_program();
|
||||
// Create a cycle across two pipelines: a -> b and b -> a
|
||||
program.leaves.push(LeafDecl {
|
||||
name: "leaf_a".to_string(),
|
||||
reads: None,
|
||||
actions: vec![LeafAction::Emit(Expr::Lit(0))],
|
||||
});
|
||||
program.leaves.push(LeafDecl {
|
||||
name: "leaf_b".to_string(),
|
||||
reads: None,
|
||||
actions: vec![LeafAction::Emit(Expr::Lit(0))],
|
||||
});
|
||||
program.cores[0].leaves.push("leaf_a".to_string());
|
||||
program.cores[0].leaves.push("leaf_b".to_string());
|
||||
program.pipelines = vec![
|
||||
PipelineDecl {
|
||||
name: "p1".to_string(),
|
||||
stages: vec!["leaf_a".to_string(), "leaf_b".to_string()],
|
||||
},
|
||||
PipelineDecl {
|
||||
name: "p2".to_string(),
|
||||
stages: vec!["leaf_b".to_string(), "leaf_a".to_string()],
|
||||
},
|
||||
];
|
||||
program.cores[0].pipelines = vec!["p1".to_string(), "p2".to_string()];
|
||||
let err = verify(&program).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::PipelineCycle);
|
||||
assert!(err.message.contains("cycle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_window_writer_conflict() {
|
||||
// A leaf that both reads a window on an actor AND forwards messages to it
|
||||
let program = L1Program {
|
||||
actors: vec![ActorDecl {
|
||||
name: "my_actor".to_string(),
|
||||
state: vec![StateField {
|
||||
name: "val".to_string(),
|
||||
ty: FieldType::U64,
|
||||
init: 0,
|
||||
}],
|
||||
windows: vec![WindowDecl {
|
||||
name: "view".to_string(),
|
||||
fields: vec!["val".to_string()],
|
||||
readers: vec!["bad_leaf".to_string()],
|
||||
}],
|
||||
handlers: vec![MessageHandler {
|
||||
message_type: "Update".to_string(),
|
||||
args: vec![("x".to_string(), FieldType::U64)],
|
||||
body: Expr::Assign(
|
||||
"val".to_string(),
|
||||
Box::new(Expr::Var("x".to_string())),
|
||||
),
|
||||
}],
|
||||
}],
|
||||
leaves: vec![
|
||||
LeafDecl {
|
||||
name: "bad_leaf".to_string(),
|
||||
reads: Some(ReadClause {
|
||||
actor: "my_actor".to_string(),
|
||||
window: "view".to_string(),
|
||||
}),
|
||||
actions: vec![
|
||||
LeafAction::ReadWindow {
|
||||
actor: "my_actor".to_string(),
|
||||
window: "view".to_string(),
|
||||
field: "val".to_string(),
|
||||
},
|
||||
LeafAction::Forward {
|
||||
target: "my_actor".to_string(),
|
||||
message_type: "Update".to_string(),
|
||||
args: vec![Expr::Lit(1)],
|
||||
},
|
||||
],
|
||||
},
|
||||
LeafDecl {
|
||||
name: "other_leaf".to_string(),
|
||||
reads: None,
|
||||
actions: vec![LeafAction::Emit(Expr::Lit(0))],
|
||||
},
|
||||
],
|
||||
pipelines: vec![PipelineDecl {
|
||||
name: "main".to_string(),
|
||||
stages: vec!["bad_leaf".to_string(), "my_actor".to_string()],
|
||||
}],
|
||||
cores: vec![CoreDecl {
|
||||
name: "main".to_string(),
|
||||
actors: vec!["my_actor".to_string()],
|
||||
leaves: vec!["bad_leaf".to_string(), "other_leaf".to_string()],
|
||||
pipelines: vec!["main".to_string()],
|
||||
steps: 1,
|
||||
}],
|
||||
};
|
||||
let err = verify(&program).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::WindowWriterConflict);
|
||||
assert!(err.message.contains("bad_leaf"));
|
||||
assert!(err.message.contains("reads window"));
|
||||
assert!(err.message.contains("forwards messages"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_dangling_pipeline_entity() {
|
||||
let mut program = build_counter_program();
|
||||
program.pipelines[0].stages = vec![
|
||||
"ticker".to_string(),
|
||||
"phantom".to_string(),
|
||||
"display".to_string(),
|
||||
];
|
||||
let err = verify(&program).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::DanglingRef);
|
||||
assert!(err.message.contains("phantom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_dangling_leaf_forward_target() {
|
||||
let mut program = build_counter_program();
|
||||
program.leaves[0].actions = vec![LeafAction::Forward {
|
||||
target: "void".to_string(),
|
||||
message_type: "Msg".to_string(),
|
||||
args: vec![Expr::Lit(1)],
|
||||
}];
|
||||
let err = verify(&program).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::DanglingRef);
|
||||
assert!(err.message.contains("void"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue