Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions change_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* Dogfood round 2: `apps/notes_cli`, `apps/auth_api`, `apps/grep_cli` built on standalone bytecode; `store_open` accepts a runtime URI expression; AST density sugar `(switch …)` and `(inc x)`. Real-app token harness extended with Python/Go/Node ports and parity. Journal: `docs/journals/2026-10-10_dogfood_apps_round2.md`.

### Fixed
* #151: float and mixed int/float `%` and `//` follow Python exactly. `%` is `math.fmod` floored to the divisor's sign (computed exactly by an existing-construct doubling/subtraction loop, so `(% MaxInt64 1e33)` is `9.223372036854776e+18`, `(% 1 0.1)` is `0.09999999999999995`, `(% 4.0 -2.0)` is `-0`), and `//` is CPython's float floor division returned as an int (`(// MaxInt64 1e33)` is `0`). Integral floats that print like ints (`189944.0`) paired with a big int no longer run the int core in float arithmetic. Inf/NaN operands fail with `VALUE_ERROR: % and // need finite operands`, and a `//` result outside int64 with `VALUE_ERROR: // result outside int64`. Mixed int/float comparisons no longer treat the float 2^63 as MinInt64 or round the int through float64. A non-numeric operand now reports `% requires numeric operands (int or float)` at the form instead of `< compares incompatible types`, and template VALUE_ERRORs (destructuring included) carry `at line L, column C`. Instruction cost (-run-bc): a 20k-iteration loop with one `%` and one `//` takes 4.30M instructions (3.17M after #149, 1.55M before #149), so about 460 iterations fit the default 100k limit; `to_base` of MinInt64 in base 2 takes about 16.2k (13.4k after #149); the exact float path loops about twice the binary-exponent gap (worst case `1e308 % 5e-324`, about 53k). Still pure sugar: no opcode or capability; Go/JS still reject with `UNSUPPORTED_ON_TARGET`.
* #149: integer `%`, `//` and `to_base` use exact int64 arithmetic over the whole range with Python floor semantics (previously one float division, wrong above 2^53; `(// (2^53-1) -3)` was already off by one). `(% MaxInt64 10)` is 7, `(// 123456789012345678 1000)` is 123456789012345, `MinInt64 // -1` is the existing `integer overflow` (not `CONVERSION_ERROR`), and `to_base` handles MinInt64. Operands inside (-2^52, 2^52) and float operands keep the old expansion. `let` destructuring of a list of the wrong length fails with `VALUE_ERROR: destructuring expects N elements`. Still pure sugar: no opcode or capability; Go/JS still reject with `UNSUPPORTED_ON_TARGET`.
* HOWL-E2E-UX-001 UX-003: browser JavaScript fetch accepts explicit host grants without reading Node process.env. Missing grants still deny before requests; Node environment authority and exit behavior are preserved. Browser bootstrap errors preserve the original failure.
* S8/P2.1 filesystem portion: native VM and interpreter enforce repeatable `filesystem:read=<root>` and `filesystem:write=<root>` grants, with symlink containment checks. File stores require read coverage and write coverage for mutations. Coarse `filesystem` remains unrestricted; process/network scoping and generated backends remain open. Journal: `docs/journals/2026-10-06_path_scoped_filesystem.md`.
Expand Down
160 changes: 144 additions & 16 deletions internal/ast/desugar_controlflow.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package ast

import "strings"
import (
"strings"
"sync"
)

// Control-flow and core built-ins (heldout-v2 follow-up). Like the
// heldout-v1 core library, every form expands into existing, already lowered
Expand All @@ -15,8 +18,18 @@ import "strings"
// Int operands use exact int64 arithmetic over the
// whole range (see cfIntCore); MinInt64 // -1 is the
// existing RUNTIME_ERROR "integer overflow". Float
// operands keep the float formula. b = 0 is the
// existing RUNTIME_ERROR "division by zero".
// (or mixed) operands follow Python exactly (#151):
// % is fmod floored to the divisor's sign, // is
// CPython's float floor division returned as an int.
// Inf/NaN operands and // results outside int64 are
// "VALUE_ERROR: ..." (exit 1). b = 0 is the existing
// RUNTIME_ERROR "division by zero".
// Instruction cost (-run-bc): a 20k-iteration loop with
// one % and one // is 4.30M instructions (1.55M before
// #149, 3.17M after); to_base of MinInt64 in base 2 is
// about 16.2k instructions;
// the exact float path loops about twice the binary
// exponent gap (1e308 % 5e-324 is the worst case).
// (if c1 a c2 b ... [else]) n-ary if: elif chains without cond clauses.
// (break) leaves the innermost for/while. Statement position
// only (directly or via do/if/when/unless/let bodies
Expand All @@ -38,19 +51,61 @@ import "strings"
// A list of another length fails with
// "VALUE_ERROR: destructuring expects N elements", exit 1.

// cfIsFloat is true when either operand of % or // is not an integer: its
// printed form contains ".", "e" (exponent), "n" (Inf) or "N" (NaN), none of
// which an int64 ever prints. Such operands keep the float formula.
// Integral floats such as 6.0 print like ints and take the exact path; the
// result value is the same (and prints the same) as the float formula's.
const cfIsFloat = "(or (str_contains (str $A) $DOT) (str_contains (str $B) $DOT) (str_contains (str $A) $EXP) (str_contains (str $B) $EXP) (str_contains (str $A) $INF) (str_contains (str $B) $INF) (str_contains (str $A) $NAN) (str_contains (str $B) $NAN))"
// cfFloaty(x) is true when x is a float (#151). Below 2^52 in magnitude,
// x + 2^62 is exact for an int but a float >= 2^52 for a float, and such
// floats always print with an exponent; this also catches integral floats
// that print like ints (189944.0), which the int core would otherwise run in
// float arithmetic. Larger floats print with "e", "Inf" or "NaN" (NaN fails
// both range tests), none of which an int64 ever prints.
func cfFloaty(x string) string {
return "(if (and (< " + x + " $LIM) (> " + x + " $NLIM)) (str_contains (str (+ " + x + " $P62)) $EXP) (or (str_contains (str " + x + ") $EXP) (str_contains (str " + x + ") $INF) (str_contains (str " + x + ") $NAN)))"
}

var cfIsFloatX = "(or " + cfFloaty("$A") + " " + cfFloaty("$B") + ")"

// cfSmall is true when both operands are strictly inside (-2^52, 2^52). There
// the float formula is exact: a correctly rounded a/b cannot cross an integer
// boundary, and // divides a - r with |a - r| < 2^53 (with the old 2^53 bound
// (// (2^53-1) -3) was off by one). % and // keep the cheaper pre-#149
// expansion there, so the instruction cost of ordinary loops is unchanged.
const cfSmall = "(and (< $A $LIM) (> $A $NLIM) (< $B $LIM) (> $B $NLIM))"
//
// Since #151 the cheap path also requires both operands to be whole numbers
// (ints, or integral floats such as 6.0); any other float takes the exact
// float path below. A zero remainder takes the sign of the divisor as in
// Python ((% 4.0 -2.0) is -0.0, (% -0.0 5.0) is 0.0); for ints it is 0.
const cfSmall = "(and (< $A $LIM) (> $A $NLIM) (< $B $LIM) (> $B $NLIM) (= $A (to_int $A)) (= $B (to_int $B)))"

// cfCheapMod is the pre-#149 remainder formula, exact inside cfSmall.
const cfCheapMod = "(let ($R (- $A (* $B (to_int (/ $A $B))))) (if (and (!= $R 0) (!= (< $R 0) (< $B 0))) (+ $R $B) (if (= $R 0) (if (< $B 0) (* (- 0 1) (* $R $R)) (* $R $R)) $R)))"

// cfFloatCore (#151) computes R = fmod(A, B) exactly, with the sign of A,
// for float operands (or a mix of int and float, converted to float first
// like Python). Inf or NaN operands fail with "VALUE_ERROR: % and // need
// finite operands"; B = 0 is the existing "division by zero". It uses only
// existing constructs: |B| is doubled into a list of |B|*2^k (exact) while it
// fits under |A|, then subtracted back from the largest down. Each
// subtraction is exact (Sterbenz), so the result is the correctly rounded
// fmod for any magnitudes (1e308 % 5e-324 included). The loop runs about
// twice the binary-exponent gap between A and B. extra runs after the guard;
// tail sees R (fmod), N (float A), DN (float B), AD (|B|). The caller closes
// four parens.
func cfFloatCore(extra string) string {
return "(let ($S (if (= $B 0) (/ $A $B) (/ 0 1)) $ZF (+ (* 0 $A) (* 0 $B))) (let ($N (+ $A $ZF) $DN (+ $B $ZF)) (let ($R (if (< $N 0) (- 0 $N) $N) $AD (if (< $DN 0) (- 0 $DN) $DN) $P (list) $Q $ZF $I 0) (do " +
"(when (or (str_contains (str $A) $INFS) (str_contains (str $A) $NANS) (str_contains (str $B) $INFS) (str_contains (str $B) $NANS)) (do (stderr $FMSG) (exit 1))) " + extra +
" (set $Q $AD) (while (<= $Q $R) (do (append $P $Q) (set $Q (* $Q 2)))) (set $I (- (list_len $P) 1)) (while (>= $I 0) (do (when (>= $R (list_get $P $I)) (set $R (- $R (list_get $P $I)))) (set $I (- $I 1)))) (when (< $N 0) (set $R (- 0 $R))) "
}

// cfFloatMod floors the exact fmod like Python: a non-zero remainder whose
// sign differs from B gets B added; a zero remainder takes B's sign.
var cfFloatMod = cfFloatCore("") + "(if (= $R 0) (if (< $DN 0) (* (- 0 1) (* $R $R)) (* $R $R)) (if (!= (< $R 0) (< $DN 0)) (+ $R $DN) $R))))))"

// cfFloatDiv follows CPython's float floor division ((A - fmod)/B, minus one
// when the remainder's sign differs from B, then floor with a round-to-nearest
// fix-up) and returns it as an int like the rest of //. A quotient outside
// int64 fails with "VALUE_ERROR: // result outside int64" (checked before the
// loop when |A| > |B|*2^63, so huge quotients stay cheap).
var cfFloatDiv = cfFloatCore("(when (> $R (* $AD $TWO63)) (do (stderr $RMSG) (exit 1)))") +
"(let ($T (/ (- $N $R) $DN)) (do (when (and (!= $R 0) (!= (< $DN 0) (< $R 0))) (set $T (- $T 1))) (when (or (>= $T $TWO63) (< $T $NTWO63)) (do (stderr $RMSG) (exit 1))) (let ($I (to_int $T)) (do (when (> $I $T) (set $I (- $I 1))) (when (> (- $T $I) $HALF) (set $I (+ $I 1))) $I))))))))"

// cfIntCore computes, in exact int64 arithmetic, Q = |trunc(A/B)| and
// R = -(|A| mod |B|) (so DN < R <= 0) for |B| >= 2, B = 0 failing with the
Expand All @@ -75,10 +130,10 @@ var controlflowTemplates = map[string]struct {
tpl string
binder int
}{
"%": {2, []string{"a", "b"}, []string{"A", "B", "R", "N", "DN", "Q", "T"},
"(let ($A $a $B $b) (if " + cfSmall + " (let ($R (- $A (* $B (to_int (/ $A $B))))) (if (and (!= $R 0) (!= (< $R 0) (< $B 0))) (+ $R $B) $R)) (if " + cfIsFloat + " (let ($R (- $A (* $B (to_int (/ $A $B))))) (if (and (!= $R 0) (!= (< $R 0) (< $B 0))) (+ $R $B) $R)) (if (or (= $B 1) (= $B (- 0 1))) " + cfW("0") + " " + cfIntCore + " (let ($T (if (< $A 0) $R (- 0 $R))) (if (and (!= $T 0) (!= (< $T 0) (< $B 0))) " + cfW("(+ $T $B)") + " " + cfW("$T") + "))))))))))", -1},
"//": {2, []string{"a", "b"}, []string{"A", "B", "R", "N", "DN", "Q", "T"},
"(let ($A $a $B $b) (if (or " + cfSmall + " " + cfIsFloat + ") (to_int (/ (- $A (% $A $B)) $B)) (if (= $B 1) (to_int $A) (if (= $B (- 0 1)) (to_int (- 0 $A)) " + cfIntCore + " (if (= (< $A 0) (< $B 0)) $Q (if (= $R 0) (- 0 $Q) (- 0 (+ $Q 1))))))))))))", -1},
"%": {2, []string{"a", "b"}, []string{"A", "B", "R", "N", "DN", "Q", "T", "S", "ZF", "AD", "P", "I"},
"(let ($A $a $B $b) (if " + cfSmall + " " + cfCheapMod + " (if " + cfIsFloatX + " " + cfFloatMod + " (if (or (= $B 1) (= $B (- 0 1))) " + cfW("0") + " " + cfIntCore + " (let ($T (if (< $A 0) $R (- 0 $R))) (if (and (!= $T 0) (!= (< $T 0) (< $B 0))) " + cfW("(+ $T $B)") + " " + cfW("$T") + "))))))))))", -1},
"//": {2, []string{"a", "b"}, []string{"A", "B", "R", "N", "DN", "Q", "T", "S", "ZF", "AD", "P", "I"},
"(let ($A $a $B $b) (if " + cfSmall + " (to_int (/ (- $A (% $A $B)) $B)) (if " + cfIsFloatX + " " + cfFloatDiv + " (if (= $B 1) (to_int $A) (if (= $B (- 0 1)) (to_int (- 0 $A)) " + cfIntCore + " (if (= (< $A 0) (< $B 0)) $Q (if (= $R 0) (- 0 $Q) (- 0 (+ $Q 1)))))))))))))", -1},
"range": {2, []string{"a", "b"}, []string{"A", "B", "R"},
"(let ($A $a $B $b $R (list)) (do (while (< $A $B) (do (append $R $A) (set $A (+ $A 1)))) $R))", -1},
"max_by": {3, []string{"x", "lst", "key"}, []string{"L", "KS", "I", "M", "MK", "K"},
Expand Down Expand Up @@ -150,7 +205,25 @@ func desugarControlflow(node *Node) bool {
"NLIM": func() *Node {
return &Node{Type: "INT", Value: "-4503599627370496", Line: node.Line, Column: node.Column, Filename: node.Filename}
},
"EXP": func() *Node { return strNode("e", node) },
"EXP": func() *Node { return strNode("e", node) },
"INFS": func() *Node { return strNode("Inf", node) },
"NANS": func() *Node { return strNode("NaN", node) },
"FMSG": func() *Node {
return strNode("VALUE_ERROR: % and // need finite operands"+atPos(node)+"\n", node)
},
"RMSG": func() *Node { return strNode("VALUE_ERROR: // result outside int64"+atPos(node)+"\n", node) },
"TWO63": func() *Node {
return &Node{Type: "FLOAT", Value: "9223372036854775808.0", Line: node.Line, Column: node.Column, Filename: node.Filename}
},
"P62": func() *Node {
return &Node{Type: "INT", Value: "4611686018427387904", Line: node.Line, Column: node.Column, Filename: node.Filename}
},
"HALF": func() *Node {
return &Node{Type: "FLOAT", Value: "0.5", Line: node.Line, Column: node.Column, Filename: node.Filename}
},
"NTWO63": func() *Node {
return &Node{Type: "FLOAT", Value: "-9223372036854775808.0", Line: node.Line, Column: node.Column, Filename: node.Filename}
},
"INF": func() *Node { return strNode("n", node) },
"NAN": func() *Node { return strNode("N", node) },
"D": func() *Node { return strNode("0123456789abcdefghijklmnopqrstuvwxyz", node) },
Expand Down Expand Up @@ -179,9 +252,64 @@ func desugarControlflow(node *Node) bool {
args[tmp] = symNode(compactTemp("cl"+tmp), node)
}
node.Children = buildTemplateFresh(tpl, args, nil, node).Children
if head == "%" || head == "//" {
markNumericSugar(node, head, args["a"], args["b"])
}
return true
}

// atPos renders " at line L, column C" for VALUE_ERROR messages emitted by
// templates (they go to stderr as plain text, not the JSON error object).
func atPos(at *Node) string {
if at == nil || at.Line <= 0 {
return ""
}
return " at line " + itoa(at.Line) + ", column " + itoa(at.Column)
}

var numericSugar = struct {
sync.Mutex
m map[*Node]NumericSugarSite
}{m: map[*Node]NumericSugarSite{}}

// NumericSugarSite names the % or // form a generated node came from.
type NumericSugarSite struct {
Head string
Root *Node
}

// markNumericSugar records every node the % or // template generated (not
// the user's operand subtrees), so the checker can report a non-numeric
// operand as "% requires numeric operands" at the form instead of an error
// about an internal comparison.
func markNumericSugar(root *Node, head string, a, b *Node) {
numericSugar.Lock()
defer numericSugar.Unlock()
site := NumericSugarSite{Head: head, Root: root}
if outer, ok := numericSugar.m[root]; ok {
site = outer // (% A B) inside the // expansion reports as //
}
var walk func(n *Node)
walk = func(n *Node) {
if n == nil || n == a || n == b {
return
}
numericSugar.m[n] = site
for _, c := range n.Children {
walk(c)
}
}
walk(root)
}

// NumericSugarOrigin reports the % or // form that generated n, if any.
func NumericSugarOrigin(n *Node) (NumericSugarSite, bool) {
numericSugar.Lock()
defer numericSugar.Unlock()
s, ok := numericSugar.m[n]
return s, ok
}

// desugarNaryIf turns (if c1 a c2 b ... [else]) with more than three
// arguments into nested binary ifs. Returns true when it rewrote the node.
func desugarNaryIf(node *Node) bool {
Expand Down Expand Up @@ -239,7 +367,7 @@ func desugarDestructure(node *Node) {
check := mk(symNode("do", at),
mk(symNode("when", at), mk(symNode("!=", at), mk(symNode("list_len", at), symNode(tmp, at)), intN(n)),
mk(symNode("do", at),
mk(symNode("stderr", at), strNode("VALUE_ERROR: destructuring expects "+itoa(n)+" elements\n", at)),
mk(symNode("stderr", at), strNode("VALUE_ERROR: destructuring expects "+itoa(n)+" elements"+atPos(at)+"\n", at)),
mk(symNode("exit", at), intN(1)))),
intN(0))
out = append(out, symNode(compactTemp("clDSN"), node), check)
Expand Down
14 changes: 14 additions & 0 deletions internal/checker/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ type Analysis struct {
// jsTarget is true for web_app programs, whose generated JavaScript
// evaluates a parse_json body expression directly.
jsTarget bool
// sugarReported dedupes operand-type diagnostics per % or // form.
sugarReported map[*ast.Node]bool
}

type typeEnv map[string]ast.TypeInfo
Expand Down Expand Up @@ -1031,6 +1033,18 @@ func (a *Analysis) inferChild(node *ast.Node, index int, env typeEnv) ast.TypeIn
}

func (a *Analysis) add(node *ast.Node, reason string) {
// A diagnostic inside a % or // expansion can only come from an operand
// of the wrong type: report it once, at the form, in the user's terms.
if site, ok := ast.NumericSugarOrigin(node); ok {
if a.sugarReported == nil {
a.sugarReported = map[*ast.Node]bool{}
}
if a.sugarReported[site.Root] {
return
}
a.sugarReported[site.Root] = true
node, reason = site.Root, site.Head+" requires numeric operands (int or float)"
}
a.Diagnostics = append(a.Diagnostics, Diagnostic{Reason: reason, Line: node.Line, Column: node.Column})
}

Expand Down
4 changes: 2 additions & 2 deletions internal/construct/construct.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,12 @@ type Entry struct {
// a copy on every call. construct_test.go enforces the ordering.
var table = []Entry{
{Name: "!=", Support: Supported, Note: "compileNode binop case"},
{Name: "%", Support: CompileTimeOnly, Note: "control-flow sugar; (% a b) floored remainder via to_int(/)/*/- by ast.Desugar (pure)"},
{Name: "%", Support: CompileTimeOnly, Note: "control-flow sugar; (% a b) Python-floored remainder by ast.Desugar (pure): to_int(/)/*/- for whole operands below 2^52, exact int64 core otherwise, exact float fmod loop for floats; Inf/NaN operands are VALUE_ERROR"},
{Name: "*", Support: Supported, Note: "compileNode binop case"},
{Name: "+", Support: Supported, Note: "compileNode binop case"},
{Name: "-", Support: Supported, Note: "compileNode binop case"},
{Name: "/", Support: Supported, Note: "compileNode binop case"},
{Name: "//", Support: CompileTimeOnly, Note: "control-flow sugar; (// a b) floored integer division via % and / by ast.Desugar (pure)"},
{Name: "//", Support: CompileTimeOnly, Note: "control-flow sugar; (// a b) floored division returning int by ast.Desugar (pure): via % and / for whole operands below 2^52, exact int64 core otherwise, CPython float floor division for floats; Inf/NaN operands and results outside int64 are VALUE_ERROR"},
{Name: "<", Support: Supported, Note: "compileNode binop case"},
{Name: "<=", Support: Supported, Note: "compileNode binop case"},
{Name: "=", Support: Supported, Note: "compileNode binop case, normalized to =="},
Expand Down
27 changes: 26 additions & 1 deletion internal/vm/vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,16 @@ func compareNumeric(a, b any) (int, bool) {
return 0, false
}
// Mixed int/float: keep precision by comparing as integers when possible.
// A float outside the int64 range or with a fraction never equals an
// int64; order it without rounding the int through float64 (2^63 used to
// compare as MinInt64 and MaxInt64 as equal to 2^63). NaN keeps the
// float comparison below.
if aInt && bFloat && !math.IsNaN(bf) && !floatIsWholeInt64(bf) {
return cmpIntNonWholeFloat(ai, bf), true
}
if aFloat && bInt && !math.IsNaN(af) && !floatIsWholeInt64(af) {
return -cmpIntNonWholeFloat(bi, af), true
}
if aInt && bFloat && floatIsWholeInt64(bf) {
bi2 := int64(bf)
switch {
Expand Down Expand Up @@ -1351,8 +1361,23 @@ func compareNumeric(a, b any) (int, bool) {
return 0, true
}

// cmpIntNonWholeFloat orders int64 i against a non-NaN float f that is not
// a whole number inside the int64 range.
func cmpIntNonWholeFloat(i int64, f float64) int {
if f >= 9223372036854775808.0 {
return -1
}
if f < math.MinInt64 {
return 1
}
if i <= int64(math.Floor(f)) {
return -1
}
return 1
}

func floatIsWholeInt64(f float64) bool {
if math.IsNaN(f) || math.IsInf(f, 0) || f < math.MinInt64 || f > math.MaxInt64 {
if math.IsNaN(f) || math.IsInf(f, 0) || f < math.MinInt64 || f >= 9223372036854775808.0 {
return false
}
return math.Trunc(f) == f
Expand Down
Loading
Loading