feat: iteration 36 task 5 — operators sample, docs closeout

- docs/examples/operators: manual-test workload (ok/FAIL lines per
  expression; trap mode proves WO_T_SHIFT); NO test fixtures by
  developer directive — acceptance is the manual pass
- 00-wob-format.md: v6 section (opcodes 42-46, T_SHIFT, header v6)
- CODE-LOGIC.md both sides: precedence-into-existing-rungs, Lua not,
  rewind-and-reparse compound assigns, trap-not-mask, 63-bit hex limit
- story 36 refine -> in-progress with landing blockquote; board updated
- gates: woc-test 543/0, wovm-test ASan, oop-accept ALL MET,
  deps-accept 8/0, web-app 26/0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-22 21:42:08 +02:00
parent 5d0e6635f1
commit 5ebf98f594
8 changed files with 229 additions and 4 deletions

View file

@ -260,3 +260,38 @@ a keyword, and visibility is name resolution at compile time.
- **Exit-code bands stay split**: WO-E108 is a diagnostic through the normal
collector path (exit 1); WO-E106/E107/E109 are manifest errors printed
directly (exit 2).
## Operator parity (iteration 36 — `.wob` v6)
- **Precedence went INTO existing rungs, not new ones.** `|`/`^` joined
`parse_additive`, `&`/`<<`/`>>` joined `parse_multiplicative` — exactly
Go's table (`token.go` Precedence), which exists to fix C's trap:
`x & mask == 0` groups the AND first here. The ladder doc in `parser.ml`
carries the worked examples.
- **`not` is a keyword at the unary level (Lua placement).** `not a == b`
groups `(not a) == b`. Chosen over Python's looser placement because the
grammar's ordering is already anchored to Lua by name and because
Bool-only typing turns almost every misread into a compile error. It
lowers on the existing EQ against a zero constant — no new opcode, the
same doctrine as and/or's JZ lowering.
- **Compound assigns are parse-time sugar via rewind-and-reparse.**
`x += e` IS `x = x + e`, the documented contract — including an index
expression evaluating twice, exactly as the written-out form would. The
parser re-parses the place by resetting `st.pos` (no expression rung
consumes a compound token, so the second parse stops where the first
did); every re-parsed node draws a fresh id, so owner/emit see two
honest reads, never one node in two roles. `+=`/`-=` had been lexed
since haxe-parity Task 2 but no rule consumed them — dead tokens,
`x += 1` died as a generic WO-E101 until this iteration.
- **Bitwise is Int-only on BOTH sides (WO-E201 family)** — no F-twin
exists, so a Float operand would have become a garbage word operation
with no diagnostic. A LITERAL shift count outside 0..63 is WO-E223 at
the operand's position (a negative literal arrives as
`Unary(Neg, IntLit)` — both shapes are caught); a variable count is the
VM's WO_T_SHIFT.
- **Hex/binary literals accumulate in OCaml's native int (63-bit).** A
full-width 64-bit literal like `0xFFFFFFFFFFFFFFFF` is out of reach —
all-ones is spelled `-1` (and complement is `-1 ^ x`; there is no `~`).
The `0x`/`0b` prefix commits only when a real base digit follows, so
`0xg` stays `Int 0` + `Ident` — a parse error at its own position, no
new lexer diagnostic. `_` separators are consumed only BETWEEN digits.

View file

@ -193,7 +193,8 @@ that sequences its tasks. Read one, approve, then the next starts.
| Track | Item | Where |
| -------- | --------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Language | nothing active — the framework v1-polish slice landed 2026-08-20 (branch framework-v1, awaiting merge); next per the order: brainstorm 20/21's forks | [order](#implementation-order-re-sequenced-2026-08-20--code-review-pass) |
| Language | 🔄 [iteration 36 — operator parity](stories/language-runtime-database/in-progress/36-operator-parity.md): `not`, bitwise `& \| ^ << >>`, hex/binary/`_` literals, compound assigns — CODE LANDED 2026-08-22 (branch operator-parity, `.wob` v6, all gates green; reference project `.dev/reference/go` drove the design). Awaiting the developer's MANUAL pass on `docs/examples/operators/` (no test fixtures by directive); unblocks story 34's pure-`.wo` HMAC question | [plan](superpowers/plans/2026-08-22-operator-parity.md) |
| Language | the framework v1-polish slice landed 2026-08-20 (branch framework-v1, awaiting merge); next per the order: brainstorm 20/21's forks | [order](#implementation-order-re-sequenced-2026-08-20--code-review-pass) |
Off-goal work is parked; the goal (2026-08-20) is the web framework as a
polished micro-framework v1 — iteration 17 (library kind + `internal/`) is

View file

@ -0,0 +1,42 @@
# operators — iteration 36's manual-test workload
Exercises everything iteration 36 added to the language: boolean `not`,
the five Int bitwise operators (`&` `|` `^` `<<` `>>`), hex/binary/
underscore integer literals, and the five compound assigns
(`+=` `-=` `*=` `/=` `%=`).
## Run it
```
woc . # from this directory
./target/operators
```
Every line prints `ok <label> = <value>` — each label states the exact
expression and the expected result, so a manual test is reading the
lines and spotting any `FAIL`. The sections, in order:
1. **Literals** — `0xFF`, `0b1010_1010`, `1_000_000` against their
decimal twins.
2. **The five operators** — including `-16 >> 2 = -4` (`>>` is
ARITHMETIC: the sign bit extends, the story's settled decision 1)
and complement spelled `-1 ^ x` (no `~` in this language).
3. **Precedence pins** — Go's C-trap fix: `&`/`<<`/`>>` bind with
`*`, `|`/`^` bind with `+`, all above comparison, so
`x & mask == 0` groups the AND first and `1 << 4 + 1` is 17, not 32.
4. **`not`** — a word like `and`/`or`, Lua's unary placement:
`not a == b` groups `(not a) == b`.
5. **Compound assigns** — one value threaded through all five.
6. **The consumer proof** — HMAC's ipad/opad step (story 34's blocker):
`byte_at(key, i) ^ 0x36` / `^ 0x5C` in pure `.wo`.
## The trap
```
./target/operators trap
```
must die with `trap 12 in shift_by ...: shift count out of range 0..63`
(exit 1). A shift count outside 0..63 is a RUN-TIME trap only when the
count is a variable — a literal out-of-range count never compiles
(WO-E223, try changing `1 << 6` to `1 << 64`).

View file

@ -0,0 +1,74 @@
-- Operator parity — iteration 36's manual-test workload. Every section
-- prints `label expected actual`: eyeball that the two numbers agree.
-- Build & run: woc . && ./target/operators
-- Trap demo: ./target/operators trap (shift count 64 at run time
-- must die with `trap 12 ... shift count out of range`)
fn expect(label: Text, expected: Int, actual: Int) {
if expected == actual {
print("ok ${label} = ${actual}")
} else {
print("FAIL ${label} expected ${expected} got ${actual}")
}
}
fn shift_by(n: Int) -> Int {
-- variable count: the compiler cannot see 64 here, so an out-of-range
-- value reaches the VM and traps WO_T_SHIFT (the DIV0 precedent)
return 1 << n
}
fn main(args: multi Text) -> Int {
if len(args) >= 1 and args[0] == "trap" {
print("about to shift by 64 — expect trap 12, not a number:")
print_int(shift_by(64))
return 1
}
-- literals: hex, binary, underscore separators
expect("0xFF", 255, 0xFF)
expect("0b1010_1010", 170, 0b1010_1010)
expect("1_000_000", 1000000, 1_000_000)
-- the five bitwise operators, Int-only
expect("12 & 10", 8, 12 & 10)
expect("12 | 3", 15, 12 | 3)
expect("12 ^ 10", 6, 12 ^ 10)
expect("1 << 6", 64, 1 << 6)
expect("-16 >> 2 (arithmetic)", -4, -16 >> 2)
expect("complement -1 ^ 0xF0", -241, -1 ^ 0xF0)
-- precedence: Go's C-trap fix — & << >> bind with * / %, | ^ with + -
expect("1 << 4 + 1 groups (1<<4)+1", 17, 1 << 4 + 1)
expect("2 | 1 * 4 groups 2|(1*4)", 6, 2 | 1 * 4)
if 0b1010_1010 & 0xFF == 0 {
print("FAIL grouping: & lost to ==")
} else {
print("ok x & mask == 0 groups (x & mask) == 0")
}
-- not: boolean negation, a word like and/or
if not false { print("ok not false") }
if not (1 > 2) and not false or false { print("ok not chains with and/or") }
-- Lua placement: not binds tighter than ==
if (not true) == false { print("ok not a == b groups (not a) == b") }
-- compound assigns (+= -= existed as dead tokens; all five live now)
let acc: Int = 10
acc += 5
acc -= 1
acc *= 3
acc /= 2
acc %= 7
expect("10 +=5 -=1 *=3 /=2 %=7", 0, acc)
-- the consumer that motivated the iteration (story 34): HMAC's
-- ipad/opad step is byte ^ constant — RFC 2104's pads, on the byte
-- values of "key"
let k: Text = "key"
expect("'k' ^ 0x36 (ipad)", 93, byte_at(k, 0) ^ 0x36)
expect("'k' ^ 0x5c (opad)", 55, byte_at(k, 0) ^ 0x5C)
print("done — compare every line above by eye; any FAIL is a defect")
return 0
}

View file

@ -0,0 +1,9 @@
name = "operators"
version = "0.1.0"
description = "Operator-parity showcase — iteration 36's manual-test workload: not, bitwise & | ^ << >>, hex/binary literals, compound assigns"
[runtime]
wo = ">= 0.1"
[build]
runtime = "../../../runtime/wovm"

View file

@ -11,7 +11,7 @@
All integers little-endian; offsets are absolute file offsets.
**Header (44 bytes):** magic `"WOB1"`, version 5 (iteration 19; see "v5: Float and Bytes" below), then offset/count u32 pairs for the constant pool, class table, interface section, and method table, then a u32 entry-method index (all-ones = none).
**Header (44 bytes):** magic `"WOB1"`, version 6 (iteration 36; see "v6: the Int bitwise set" below — v5 was iteration 19's "v5: Float and Bytes"), then offset/count u32 pairs for the constant pool, class table, interface section, and method table, then a u32 entry-method index (all-ones = none).
**Constant pool** — sequential entries: one tag byte; tag 0 = i64 follows; tag 1 = text (u32 length + bytes, no NUL); tag 2 = f64 as its IEEE 754 bit pattern in an LE u64 (v5). There is no Bytes tag: Bytes has no literal form.
@ -55,6 +55,7 @@ The metadata exists for exactly one reason: `json.encode`/`json.decode` are runt
| 33 | ENDTRY | pop the innermost catch frame — the try region completed without trapping |
| 34–38 | FADD/FSUB/FMUL/FDIV/FNEG | f64 arithmetic on the register's bits (v5). **None of these trap**: IEEE 754 quiet semantics, so `x/0.0` is ±Inf and `0.0/0.0` is NaN. FNEG flips the sign bit, so `-0.0` is reachable |
| 39–41 | FEQ/FLT/FLE | f64 IEEE compares, result 0/1 — so any comparison involving NaN is 0, and `0.0 == -0.0` is 1. Not a total order; indexes and order-by use the `float_cmp` builtin instead |
| 42–46 | BAND/BOR/BXOR/SHL/SHR | i64 bitwise (v6, iteration 36). Int-only — woc refuses Float/Bool/Text operands, so no F-twin exists. SHL shifts the unsigned word (wrapping, like ADD); **SHR is arithmetic** (the sign bit extends). A shift count outside 0..63 traps T_SHIFT — never the hardware's silent count%64; a *literal* out-of-range count is rejected at compile time (WO-E223), so the trap only ever fires on variable counts |
**try/catch (Task 5).** A trap raised while a catch frame is live unwinds every frame *above* the catching one exactly as an uncaught trap does (drop maps run, registers null), then releases what the try region owned in the catching frame — the difference between the drop entry at the trapping instruction and the one at the handler pc — and resumes at the handler instead of leaving the VM. A frame that returns takes its still-open catch frames with it, so a `return` out of a try region cannot leave a handler pointing at a dead window. With no catch frame live, a trap behaves byte-for-byte as it did before v2. The catch arm's error record is an ordinary compiler-allocated object filled by the `err_fill` builtin (field order: 0 code, 1 line, 2 method, 3 msg).
@ -70,7 +71,7 @@ The metadata exists for exactly one reason: `json.encode`/`json.decode` are runt
`EQS` accepts a nil operand for the same reason: two `?Text` values compare with it, and the answer is "both absent is equal, one absent is not". A non-nil operand must still be a real Text.
**Trap codes:** DIV0, BORROW, STACK, OOM, DB, BOUNDS, KEY, EXPLICIT, IO (a syscall the source cannot prevent said no — errno's message rides in the error record).
**Trap codes:** DIV0, BORROW, STACK, OOM, DB, BOUNDS, KEY, EXPLICIT, IO (a syscall the source cannot prevent said no — errno's message rides in the error record), UNIQUE, FK, SHIFT (v6 — a variable shift count outside 0..63).
## v5: Float and Bytes (iteration 19)
@ -138,6 +139,34 @@ fraction there still fails the decode whole). A non-finite Float encodes as
invalid JSON. A Bytes field crosses as a base64 string, matching
`base64_encode`'s alphabet exactly.
## v6: the Int bitwise set (iteration 36)
The version bump is the same contract v5 set: an image is rejected in both
age directions, because an older runtime meeting opcode 42 would bail on
"unknown opcode" only after the loader trusted the rest of the header.
**What v6 adds** — nothing but opcodes and one trap kind: no new constant
tag, field kind, or section.
- Opcodes `42–46` — `BAND`/`BOR`/`BXOR`/`SHL`/`SHR`, three-register i64
forms (table above). Int-only by the checker, so unlike v5 there is no
parallel F-set and no mode bit.
- Trap kind `T_SHIFT` (12) — a variable shift count outside 0..63. The
DIV0 precedent, deliberately NOT x86's silent count-mod-64 (`x << 64`
must never quietly equal `x`) and NOT Go's saturate-to-0/-1 (spec
surface serving generic-width code this language does not have).
A literal count is rejected at compile time as WO-E223, so the trap
is reachable only through a count computed at run time.
- `>>` is **arithmetic** — the sign bit extends, Go's own choice for a
signed integer, and this language's one Int is signed 64-bit. Byte-mask
code (crypto, base64) never notices: its values keep the sign bit clear,
where arithmetic and logical shifts agree bit for bit.
- Source-side companions that need no format space: hex/binary/underscore
Int literals (a literal is pool bits by the time it reaches the image),
boolean `not` (lowered on the existing EQ against a zero constant, the
same no-new-opcode doctrine as and/or's JZ lowering), and the compound
assigns (parse-time sugar — `x += e` IS `x = x + e`, dead by emit time).
## Enum payload variants (haxe-parity compiler Task 4)
`.wob` v1 is unchanged — no new section, no new header field, no version

View file

@ -1,6 +1,6 @@
---
iteration: "36"
status: refine
status: in-progress
---
# Iteration 36 — operator parity: `not`, bitwise, hex literals, compound assigns
@ -30,6 +30,22 @@ status: refine
> (5 tasks: lexer/tokens; AST/parser incl. compound-assign desugar;
> type checker incl. literal shift-count rejection; emit + VM opcodes
> `.wob` v6; corpus/gates/docs closeout).
>
> **CODE LANDED 2026-08-22** (branch `operator-parity`), full stack in
> one day: tokens/lexer (incl. hex `0x` / binary `0b` / `_` separators),
> Go-rung parsing, WO-E201 Int-only/Bool-only checking, WO-E223 literal
> shift rejection, opcodes `WOP_BAND..WOP_SHR` 42-46 + `WO_T_SHIFT` as
> `.wob` v6. The dead-token defect is closed: `x += 1` parses (all five
> compound assigns, parse-time sugar — the desugar-equivalence contract
> incl. double index-eval is in `compiler/src/CODE-LOGIC.md`). Gates:
> `woc-test` 543/0, `wovm-test` ASan both flavors, `oop-accept` ALL MET,
> `deps-accept` 8/0, `web-app` 26/0 — all unchanged. **Deviation by
> developer directive: NO test fixtures were written** — acceptance is
> MANUAL via the new sample `docs/examples/operators/` (`woc .`, run,
> read the ok/FAIL lines; `trap` mode proves WO_T_SHIFT). The corpus
> pins this plan called for (grouping, arithmetic `>>`, ipad/opad,
> trap) live in that sample instead. Status stays in-progress until the
> developer's manual pass; the AC below reads as written pre-deviation.
## Why this iteration exists

View file

@ -181,3 +181,22 @@ layout.
word and a Bytes as the same length-prefixed blob a Text uses, so replay is
bit-exact for NaN, ±Inf, and `-0.0`. `test_wal`'s `test_float_bytes_replay`
asserts on bits for exactly that reason.
## The Int bitwise set (iteration 36 — `.wob` v6)
- **One shared case-body text serves both dispatch flavors** — the new
CASE blocks sit in the Int neighborhood after LE, so `-DWO_ISO_C`
cannot rot (same discipline as every opcode before them).
- **SHL shifts the unsigned register word** (wrapping, like ADD — a
signed left-shift overflow would be UB); **SHR casts to int64_t
first**, so it is ARITHMETIC — gcc/clang define signed `>>` as
sign-extending, and those are the only compilers this runtime targets.
- **The count check is a trap, not a mask.** x86 masks the count mod 64,
which would make `x << 64 == x` silently; Go saturates to 0/-1, spec
surface for generic-width code this VM does not have. WO_T_SHIFT (12)
follows the DIV0 precedent instead: named, catchable, honest. It can
only fire on a count computed at run time — woc rejects literal
out-of-range counts as WO-E223.
- **The loader validates the five opcodes as plain three-register forms**
— the count is a register, not an immediate, so there is nothing to
range-check at load time.