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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ For the release process and tag conventions, see [RELEASING.md](RELEASING.md).

## Unreleased

### Added

- **Script mode: implicit `main>_;` for bare top-level statements (ILO-439).** A file no longer needs a `main>_;` wrapper: bare statements at the top level are collected, in source order, into a synthetic `main`. `prnt +2 2` alone in a file prints 4. Declarations and statements mix freely as long as each statement starts its own top-level line - `tri n:n>n;/(*n +n 1) 2` followed by `prnt tri 10` on the next line prints 55, which is exactly the shape a model writes when asked for a compact program. Measured motivation (ILO-364 closed-loop benchmark): the missing-wrapper diagnostics dominated every ilo failure (ILO-P102 fired 19 times across one N=3 run) and models could not recover from them even when the hint named the fix; with script mode those collapse to near zero and failures shift to genuine type errors. Deliberate inversion of the original ticket's "verifier rejects mixed" rule, on that evidence. Guard-rails so the new acceptance cannot swallow existing diagnostics: fragments glued to a broken declaration (`f>n;1e`, `main->n`, `0 -1.5`, stray `}`, foreign `let`/`if`/`return`) still route to the parser's targeted errors; a glued `name=expr` after a declaration still fires ILO-P102 (its registry text updated); a file with both an explicit `main` and bare statements is rejected with new **ILO-P104**; the REPL asks `parser::is_script_mode_input` so `+1 2` still evaluates to `3` instead of reporting `defined: main`. The k-means chain that originally motivated ILO-P102 now simply runs.

- **ILO-V500: unconditional-recursion detection (first code in the verifier namespace).** A function whose straight-line body (no guards, matches, loops, or early returns) contains a direct call to itself can never terminate; because tail calls trampoline, it used to spin silently at runtime (each occurrence burned the full 20s timeout in the ILO-364 harness) instead of overflowing the stack. Now rejected at verify time with a hint that names both fixes: add a base-case guard, or - for the script-mode case `tri n:n>n;...;prnt tri 10` where a trailing call glued to the definition line joins the body - put the call on its own line. Conservative by construction: any branching construct in the body disables the check, so guarded recursion (`cd n:n>n;=n 0 0;cd -n 1`), match-arm recursion, ternary-branch recursion, and fn-refs passed to HOFs are all untouched (pinned by tests).

### Fixed

- **`get-stream` yields lines on newline, not buffer-fill (ILO-489).** The client-side streaming builtins (`get-stream`, `get-stream-h`, `pst-stream`, `pst-stream-h`, ILO-448) wrapped minreq's `ResponseLazy` in `BufReader::lines()`, which blocks on a full ~8 KiB read-buffer fill before surfacing any line. So a slow SSE upstream that flushes one short event then idles had its lines batched until the buffer filled or the connection closed - functionally correct but latency was buffer-bound, not event-bound. The line splitter now consumes `ResponseLazy`'s byte iterator incrementally and emits each line the instant its `\n` arrives (trailing `\r` stripped for CRLF / chunked encoding), so each event surfaces promptly. EOF still yields a trailing newline-less partial line; mid-stream errors still surface as `ILO-R009 http-stream read error: ...`. Unblocks ILO-482's previously flaky end-to-end streaming test.
Expand Down
34 changes: 34 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,40 @@ Early return: braceless guard (`>=x 0 val` exits the function immediately when t

Result unwrap mid-body: `v=call!` extracts the Ok value and propagates Err out of the function before continuing.

### Script mode (implicit `main`)

Bare statements at the top level of a file are collected, in source order, into a synthetic `main>_;` — no wrapper needed (ILO-439):

```
prnt +2 2 -- whole file; prints 4
```

Declarations and top-level statements can be mixed. Each statement must start
its own top-level line (an unindented newline is already the top-level
boundary, exactly as between two function declarations):

```
tri n:n>n;/(*n +n 1) 2
prnt tri 10 -- own line → becomes main's body; prints 55
```

Rules:

- A statement glued to the same line as a declaration belongs to that
declaration's body. `tri n:n>n;...;prnt tri 10` makes `tri` call itself —
rejected at verify time as unconditional recursion (`ILO-V500`) since a
straight-line body that calls its own function can never terminate.
- A file with an explicit `main` **and** bare top-level statements is
rejected (`ILO-P104`) — two entry points with no defined order.
- Bare top-level `name=expr` bindings glued after a declaration still surface
`ILO-P102` with the `main>_;` wrapper hint; on their own line they are
script statements like any other.
- Fragments that are clearly a malformed declaration (`main->n`, `f x:`,
foreign keywords like `let`/`if`/`return`) keep their targeted parser
diagnostics rather than being swallowed as statements.
- The synthesised `main` behaves exactly like a hand-written `main>_;` —
same graph root, same `ilo file.@` auto-run, same engines.

---

## Types
Expand Down
2 changes: 1 addition & 1 deletion ai.txt

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions examples/script-mode-mixed.ilo
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Script mode with declarations (ILO-439): bare top-level statements on
-- their own lines are collected into a synthetic `main>_;`, so a file can
-- define helpers and then just call them - the shape a model writes when
-- asked for a compact program. No `main>_;` wrapper needed.
tri n:n>n;r=*n +n 1;/r 2
prnt tri 10

-- run: main
-- out: 55
9 changes: 9 additions & 0 deletions skills/ilo/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ The content lives in `skills/ilo/<name>.md`. The installed binary serves the sam

## Quick reference - things agents miss

**Script mode - no `main>_;` wrapper needed (ILO-439).** Bare top-level statements are collected into a synthetic `main`. `prnt +2 2` alone in a file prints 4. Mixing with declarations works when each statement starts its own top-level line:

```
tri n:n>n;/(*n +n 1) 2
prnt tri 10 -- own line -> becomes main's body; prints 55
```

Do NOT glue the call to the declaration's line - `tri n:n>n;...;prnt tri 10` puts the call inside `tri`'s body, making it call itself (rejected as ILO-V500 unconditional recursion). Don't combine bare statements with an explicit `main` (ILO-P104).

**Text concatenation - three builtins, three jobs.** Pick by shape, not by habit:

- `+ a b` - two-arg text/number concat (also list concat). `+ "hi " name` -> `"hi alice"`.
Expand Down
4 changes: 4 additions & 0 deletions skills/ilo/ilo-language.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ Parens: `map (x:n>n;+x 1) xs`. Captures tree-only; VM/JIT auto-fallback.

Non-last fns end with safe expr (op, index, match, literal, parens); last fn: anything.

## script mode

Bare top-level statements auto-wrap into a synthetic `main>_;`. `prnt +2 2` alone prints 4. With decls, each statement on its OWN unindented line; glued to a decl's line it joins that body (self-call → ILO-V500). Explicit `main` + bare stmts = ILO-P104.

## strings

`"text"` with `\n \t \" \\`. Multi-line `"""..."""`. Interp `"hi {name}"` => `fmt "hi {}" name`. Single-ident slots only. `{{`/`}}` escape inside interpolated strings. Bare `{}` still positional; don't mix `{ident}` + `{}` in one string.
Expand Down
78 changes: 65 additions & 13 deletions src/diagnostic/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,22 +585,23 @@ declarations (`type T = ...`), tools (`tool name ...`), or `use` imports.
A bare `name=expr` is a **binding statement**, not a declaration - it has
to live inside a function body.

This diagnostic fires when a file starts with (or contains) a top-level
chain like:
Since script mode (ILO-439), a chain that starts its own top-level line
is simply valid: it is collected into a synthetic `main>_;` and runs.
So this diagnostic no longer fires for a file of bare statements, or for
statements on their own lines after a declaration.

pts=gen-pts
cs0=[[4.8 4.9][6.2 7.1]]
cs1=iter cs0 pts
cs2=iter cs1 pts
prnt cs2
It still fires when a binding is **glued to a preceding declaration on
the same line**:

helper>n;42 pts=[1 2 3]

Without a function header to anchor those bindings, the parser either
fails on the bare `=` (ILO-P003) or - when a prior `name>type;body`
declaration sits above - slurps the whole chain into that function's
body, producing a wall of misleading ILO-T005 cascades that point at
the wrong line.
That shape is almost never an intentional script line - it is a function
body that ran past its end - and silently splitting it would swallow the
real mistake. The historical cascade this code prevents: the chain being
slurped into the prior function's body, producing a wall of misleading
ILO-T005 errors pointing at the wrong line.

**Fix: wrap the chain in a `main>_;` entry point.**
**Fix: put the statement on its own line, or wrap it in `main>_;`.**

main>_;
pts=gen-pts
Expand All @@ -618,6 +619,27 @@ manifesto target is that a wrong program produces *one* actionable
diagnostic, not a cascade. ILO-P102 collapses what used to be 5-50
ILO-T005 lines (one per slurped binding) into a single pointer at the
shape fix.
"#,
},
ErrorEntry {
code: "ILO-P104",
phase: Phase::Parse,
short: "file has both an explicit `main` and bare top-level statements",
long: r#"## ILO-P104: explicit `main` plus bare top-level statements

Script mode (ILO-439) wraps bare top-level statements into a synthetic
`main>_;`. A file that also declares its own `main` would then have two
entry points with no defined order between them, so it is rejected:

prnt 9 <- bare statement, collected for the synthetic main
main>n;42 <- but an explicit main also exists

(Statements *after* an explicit `main` never reach this check - they are
handed to the declaration parser instead, surfacing its usual
diagnostics, so both orderings reject.)

**Fix:** move the top-level statements into `main`, or delete the
explicit `main` and let the statements become it.
"#,
},
ErrorEntry {
Expand Down Expand Up @@ -810,6 +832,36 @@ deliberately if the depth is real.
"#,
},
// ── Type / Verifier ──────────────────────────────────────────────────────
ErrorEntry {
code: "ILO-V500",
phase: Phase::Verify,
short: "function unconditionally calls itself and can never terminate",
long: r#"## ILO-V500: unconditional recursion

A function whose body is a straight line — no guards, matches, loops, or
early returns — contains a direct call to itself. Every invocation reaches
the self-call again, so the recursion has no base case and the function can
never terminate. Because ilo trampolines tail calls, this would spin forever
at runtime rather than overflow the stack.

```
tri n:n>n;r=*n +n 1;/r 2;prnt tri 10
^^^^^^^^^^^ glued into tri's body — tri calls tri
```

The most common source is a trailing top-level call written on the same line
as the function it was meant to exercise. Script mode (ILO-439) collects
statements into `main` only when they start their own top-level line.

**Fix:** add a base-case guard before the recursive call (`=n 0 0`), or move
the trailing call onto its own line so it becomes a top-level statement:

```
tri n:n>n;r=*n +n 1;/r 2
prnt tri 10
```
"#,
},
ErrorEntry {
code: "ILO-T001",
phase: Phase::Verify,
Expand Down
8 changes: 7 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1742,8 +1742,14 @@ fn repl_cmd() {
)
})
.collect();
// Script mode (ILO-439) wraps bare statements in a synthesised
// `main`, which looks like a definition in the AST. Ask the
// parser what shape the *input* was, so `+1 2` still evaluates
// to 3 here instead of reporting `defined: main() -> _`.
let is_script = parser::is_script_mode_input(&token_spans);
let (program, errors) = parser::parse(token_spans);
if errors.is_empty()
if !is_script
&& errors.is_empty()
&& !program.declarations.is_empty()
&& program.declarations.iter().all(|d| {
matches!(
Expand Down
Loading
Loading