9c833f65e4
Every surface run in format-findings.md hit the same L7 (SMA) wall, read so far as a "genuine single-shot complexity ceiling". Three controls on the plain Form-A baseline (identical L7 prompt + ail oracle) pin down what that ceiling actually is. Control 1 — blind frontier model. Ran the full L0–L7 ladder through eight fresh, clueless Claude Opus 4.8 agents, each given verbatim the same prompt the IONOS models got, with no repo access, no compiler, no oracle (all returned tool_uses: 0). Oracled afterward exactly as the harness oracles Qwen/Llama: 8/8 green, including L7. So L7 is not a language ceiling — Form-A is authorable, SMA included, by a strong enough model. The eight modules are kept under clueless-agents/ (one m.ail per dir to satisfy module-name=stem) and re-check green; L7 also runs to the expected output. Claude generalised operators never shown in the few-shot (float +, a / absent from every example, float print). Control 2 — agentic Qwen (qwen_agentic.py). The fairer question: does the compiler-as-a-tool close the gap? Runs the real Claude-Code loop — model proposes a module, harness runs ail, the raw unedited compiler output goes back into the dialogue, up to 8 turns. Result: never solved. From turn 1 Qwen collapses into a repetition loop (~274× "(seq" until max_tokens, completion=1500 every turn); the module is truncated mid-cascade, the oracle returns the correct parse error every turn, and Qwen repeats. Tool access is not an equaliser — it lifts a model only when its reasoning already operates in the right neighbourhood. Also settles the context question: turn 1 collapses at prompt=729 (no accumulated history), so the failure is "can't", not "drowns in context". Raw doc qwen-agentic-sma.md (repetition cascades collapsed to keep the repo small; the collapse marker records the original run length). Control 3 — same SMA in Python (qwen_python_sma.py). Is the AILang failure about coding ability or the unfamiliar surface? Given the same task zero-shot in Python, Qwen returns a clean, idiomatic, correct sliding-window in 113 tokens (verified by inspection + hand-trace; the harness deliberately does not auto-execute LLM code). 113 tokens of correct Python vs a 1500-token collapse in AILang, same model and temperature. Synthesis: the L7 wall is neither an algorithm ceiling nor a "model is weak" ceiling. Qwen owns the algorithm (Python) and a frontier model owns the surface (Claude, blind, first try). Qwen's AILang collapse is specifically the unfamiliar, fully-parenthesised surface it never saw in training, and a compiler-in-the-loop does not rescue it. For an LLM-authored language: making Form-A compiler-driven does not lower the model bar — AILang either targets frontier-class authors, or its surface must drop the structure-tracking load enough for a small model to keep its place. Live IONOS limited to the consented Qwen agentic + Python runs.
161 lines
7.6 KiB
Python
161 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Agentic oracle loop — can the model DRIVE the compiler, the way Claude Code does?
|
|
|
|
The single-shot ablation asks "can the model write it blind?". This asks the
|
|
fairer question: given the compiler as a tool, can the model ITERATE to green?
|
|
The model never touches `ail` directly (it only emits text); this harness runs
|
|
`ail check`/`ail run` after each attempt and feeds the RAW, unedited compiler
|
|
output back into the dialogue, up to MAX_TURNS. No hints, no hand-holding — the
|
|
only new information each turn is what the compiler actually said. That is
|
|
exactly the loop Claude Code runs.
|
|
|
|
Plain Form-A surface (no converter, so no converter risk: the model's text goes
|
|
straight to `ail`). Same SERIES few-shot + SMA task as qwen_ablation.py L7.
|
|
|
|
Reads $IONOS_API_TOKEN (never printed). Live IONOS — run with consent.
|
|
"""
|
|
from __future__ import annotations
|
|
import json, os, re, subprocess, urllib.request
|
|
from pathlib import Path
|
|
|
|
ENDPOINT = "https://openai.inference.de-txl.ionos.com/v1/chat/completions"
|
|
MODEL = os.environ.get("PROBE_MODEL", "Qwen/Qwen3-Coder-Next")
|
|
TAG = os.environ.get("PROBE_TAG", "qwen")
|
|
MAX_TURNS = int(os.environ.get("PROBE_TURNS", "8"))
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
AIL = os.environ.get("AIL_BIN", str(REPO / "target/debug/ail"))
|
|
DOC = REPO / f"experiments/2026-05-12-cross-model-authoring/{TAG}-agentic-sma.md"
|
|
|
|
DEMO_A = """(module demo_a
|
|
(fn double
|
|
(type (fn-type (params (own (con Int))) (ret (own (con Int)))))
|
|
(params x)
|
|
(body (app + x x)))
|
|
(fn main
|
|
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
|
|
(params)
|
|
(body (seq (app print (app double 21)) (do io/print_str "\\n")))))"""
|
|
|
|
DEMO_B = """(module demo_b
|
|
(data IntList (ctor Nil) (ctor Cons (con Int) (con IntList)))
|
|
(fn sum
|
|
(type (fn-type (params (own (con IntList))) (ret (own (con Int)))))
|
|
(params xs)
|
|
(body
|
|
(match xs
|
|
(case (pat-ctor Nil) 0)
|
|
(case (pat-ctor Cons h t) (app + h (app sum t))))))
|
|
(fn main
|
|
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
|
|
(params)
|
|
(body
|
|
(let xs (term-ctor IntList Cons 1 (term-ctor IntList Cons 2 (term-ctor IntList Nil)))
|
|
(seq (app print (app sum xs)) (do io/print_str "\\n"))))))"""
|
|
|
|
DEMO_S = """(module demo_s
|
|
(fn main
|
|
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
|
|
(params)
|
|
(body
|
|
(let s (new Series (con Float) 3)
|
|
(let s2 (app Series.push s 1.0)
|
|
(let s3 (app Series.push s2 2.0)
|
|
(seq (app print (app Series.len s3)) (do io/print_str "\\n"))))))))"""
|
|
|
|
INTRO = ("AILang is a fully-parenthesised S-expression language (\"Form-A\"). "
|
|
"Study these complete, valid example modules, then write the requested "
|
|
"module. Return ONLY the AILang source for the module — no prose, no "
|
|
"markdown fences.\n\n")
|
|
BASE = INTRO + "Example 1:\n" + DEMO_A + "\n\nExample 2:\n" + DEMO_B + "\n"
|
|
SERIES = (BASE + "\nExample 3 (uses the Series ring-buffer library — "
|
|
"`(new Series (con Float) N)` allocates one of capacity N; "
|
|
"`Series.push : (own (Series a)) (own a) -> (own (Series a))` appends "
|
|
"and returns the new series; `Series.at : (borrow (Series a)) (own Int) "
|
|
"-> (own a)` reads index i back from newest (0 = newest); "
|
|
"`Series.len`/`Series.total_count : (borrow (Series a)) -> (own Int)`):\n"
|
|
+ DEMO_S + "\n")
|
|
|
|
SMA_OUT = "3.0\n5.33333\n5.66667\n5.33333\n"
|
|
SMA_TASK = ("Write a module `m` computing a simple moving average, window 3, over "
|
|
"the float stream 1.0, 5.0, 3.0, 8.0, 6.0, 2.0 using the Series library: "
|
|
"create a Float Series lookback 3, push each value in turn, and after each "
|
|
"push once at least 3 values have been pushed, print the average of the "
|
|
"most recent 3 (their sum / 3.0) on its own line. Expected:\n" + SMA_OUT +
|
|
"\nYou will be given the compiler's exact output after each attempt and "
|
|
"may correct your module. Return ONLY the module source each turn.")
|
|
|
|
def call(messages):
|
|
body = json.dumps({"model": MODEL, "temperature": 0.2, "max_tokens": 1500,
|
|
"messages": messages}).encode()
|
|
req = urllib.request.Request(ENDPOINT, data=body, headers={
|
|
"Authorization": f"Bearer {os.environ['IONOS_API_TOKEN']}", "Content-Type": "application/json"})
|
|
with urllib.request.urlopen(req, timeout=300) as r:
|
|
d = json.loads(r.read())
|
|
return d["choices"][0]["message"]["content"], d.get("usage", {})
|
|
|
|
def extract(resp):
|
|
m = re.search(r"```(?:[a-zA-Z]*)?\n(.*?)```", resp, re.S)
|
|
code = m.group(1) if m else resp
|
|
i = code.find("(module")
|
|
if i < 0: return code.strip()
|
|
depth = 0
|
|
for j in range(i, len(code)):
|
|
if code[j] == "(": depth += 1
|
|
elif code[j] == ")":
|
|
depth -= 1
|
|
if depth == 0: return code[i:j+1]
|
|
return code[i:].strip()
|
|
|
|
def evaluate(code, expected):
|
|
"""Returns (ok, raw_feedback). raw_feedback is the compiler's exact text."""
|
|
m = re.search(r"\(module\s+([A-Za-z_][A-Za-z0-9_]*)", code)
|
|
name = m.group(1) if m else "m"
|
|
d = Path("/tmp/agentic"); d.mkdir(exist_ok=True)
|
|
f = d / f"{name}.ail"; f.write_text(code + "\n")
|
|
chk = subprocess.run([AIL, "check", str(f)], capture_output=True, text=True)
|
|
if chk.returncode != 0:
|
|
return False, "`ail check` failed:\n" + (chk.stderr or chk.stdout).strip()
|
|
run = subprocess.run([AIL, "run", str(f)], capture_output=True, text=True)
|
|
if run.returncode != 0:
|
|
return False, "`ail check` passed; `ail run` failed:\n" + (run.stderr or run.stdout).strip()
|
|
if run.stdout != expected:
|
|
return False, (f"`ail check` and `ail run` succeeded but the output is wrong.\n"
|
|
f"Your program printed:\n{run.stdout!r}\nExpected:\n{expected!r}")
|
|
return True, run.stdout
|
|
|
|
def main():
|
|
messages = [{"role": "system", "content": SERIES},
|
|
{"role": "user", "content": SMA_TASK}]
|
|
log = [f"# Agentic oracle loop — can {MODEL} drive the compiler to a green SMA?\n",
|
|
f"**Model:** {MODEL}; multi-turn (≤{MAX_TURNS} turns); raw `ail` output fed "
|
|
"back each turn; plain Form-A, no converter.\n", "---\n"]
|
|
solved_at = None
|
|
for turn in range(1, MAX_TURNS + 1):
|
|
try:
|
|
resp, usage = call(messages)
|
|
except Exception as e:
|
|
log += [f"## Turn {turn} — ❌ api-error\n", f"```\n{str(e)[:300]}\n```\n"]; break
|
|
code = extract(resp)
|
|
ok, fb = evaluate(code, SMA_OUT)
|
|
mark = "✅ GREEN" if ok else "❌ not yet"
|
|
print(f"turn {turn}: {mark} (completion={usage.get('completion_tokens','?')})")
|
|
log += [f"## Turn {turn} — {mark}",
|
|
f"\ntokens: prompt={usage.get('prompt_tokens','?')} completion={usage.get('completion_tokens','?')}\n",
|
|
"model output (extracted module):\n```\n" + code + "\n```\n",
|
|
"compiler feedback returned to the model:\n```\n" + fb[:1500] + "\n```\n"]
|
|
if ok:
|
|
solved_at = turn; break
|
|
# feed the raw compiler output back, exactly as Claude Code's loop would
|
|
messages.append({"role": "assistant", "content": resp})
|
|
messages.append({"role": "user", "content":
|
|
fb + "\n\nFix the module so it compiles and prints exactly the expected "
|
|
"output. Return ONLY the corrected module source."})
|
|
verdict = (f"**Solved at turn {solved_at}/{MAX_TURNS}.**" if solved_at
|
|
else f"**Not solved within {MAX_TURNS} turns.**")
|
|
print(verdict)
|
|
log += ["\n---\n## Verdict\n", verdict + "\n"]
|
|
DOC.write_text("\n".join(log)); print("doc →", DOC)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|