Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7e65465
Round 11 prereg draft; brief: the detail's retry is always present
claude Nov 8, 2026
2b2156d
Lower a whole optimistic guess to the store setter's return
claude Nov 8, 2026
e1b2f34
PREREG-desk-11: state the gate result precisely
claude Nov 8, 2026
ceff046
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 10, 2026
01f3c56
PREREG-desk-11: repin Solid to next 6751472f, record the gate result
claude Nov 10, 2026
60173be
Count only defects: the assign-two overlap test accepts the H-11 hold…
claude Nov 10, 2026
b8b9e0b
Freeze PREREG-desk-11 (Ryan, 2026-11-10)
claude Nov 10, 2026
c8e5ab0
eval: round 11 results (defects, cost, round 10 regraded)
claude Nov 11, 2026
93f7c5d
eval: round 11, rx-4 seed 15 traced
claude Nov 11, 2026
d0fa461
Merge the merged round 11 branch history (tree unchanged)
claude Nov 11, 2026
40de1d0
eval: xstate arm (React 19 + XState v5, Stately's own docs)
claude Nov 11, 2026
d10f7a0
eval: XState side check results; cost.mjs knows the xstate arm
claude Nov 11, 2026
521e43e
Merge branch 'main' of https://github.com/ryansolid/reactivescript in…
claude Nov 11, 2026
0719948
eval: round 12 pre-registration (draft)
claude Nov 11, 2026
561924c
guide: guessing a prop goes through the child's own equation; prereg …
claude Nov 11, 2026
63b2cfc
Round 12 prereg: pin Solid next bcef1041, record the gate
claude Nov 11, 2026
82c6e42
Round 12 prereg: frozen
claude Nov 11, 2026
fe0a0e0
eval: round 12 results (graded, stats, costs, first traces)
claude Nov 11, 2026
61e3b14
eval: round 12 traces (solidjs/solid#4786, #4787), sensitivity
claude Nov 12, 2026
efa0e6d
Guide: guess only on a value that is shown; name the three ways to ke…
claude Nov 12, 2026
97b45d7
Guide: prefer keeping a loading value uneditable over a separate gues…
claude Nov 12, 2026
a145f65
Revert the guide's preference for an uneditable loading value
claude Nov 12, 2026
a65d95a
Guide: a write before its value is shown needs a place to show (not '…
claude Nov 12, 2026
d257bfe
Guide: state the shown-value and refresh rules without the eval task'…
claude Nov 12, 2026
b988aaf
Prototype GUESS_BEFORE_SHOWN (D-34) behind RX_PROTO_GBS; round 12 bro…
claude Nov 12, 2026
dd15045
GUESS_WHILE_LOADING (AC-3a): a guess a click can make while its value…
claude Nov 12, 2026
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
195 changes: 194 additions & 1 deletion compiler/src/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,25 @@ fn snapshot_reads<'a>(n: Node<'a>, caught: bool, locals: &mut HashSet<String>, o
snapshot_reads(c, caught, locals, out);
}
}
/// AC-3a: the submit buttons of a form's markup (a `<button>` without `type`, or `type="submit"`), not through components.
fn submit_buttons<'a>(ms: &'a [Markup], out: &mut Vec<&'a [Attr]>) {
for m in ms {
match &m.kind {
MarkupKind::El { tag, attrs, children } => {
if tag == "button" && !attrs.iter().any(|a| a.name == "type" && !matches!(&a.e.k, ExprKind::Lit(Lit::Str(v)) if v == "submit")) {
out.push(attrs);
}
submit_buttons(children, out);
}
MarkupKind::Mif { t, f, .. } => {
submit_buttons(t, out);
submit_buttons(f, out);
}
MarkupKind::Mfor { body, .. } | MarkupKind::Loading { body, .. } | MarkupKind::Errored { body, .. } | MarkupKind::Portal { body, .. } => submit_buttons(body, out),
_ => {}
}
}
}
/// A node the reference walks generically (`Object.values`): an expression, or a statement inside one.
#[derive(Clone, Copy)]
pub(crate) enum Node<'a> {
Expand Down Expand Up @@ -770,6 +789,8 @@ struct ReaderRec<'a> {
/// R-8: an event handler on an element, with the element's attributes (its `disabled` covers the handler).
struct HandlerRec<'a> {
attrs: &'a [Attr],
/// the element's children: a `<form>`'s submit buttons cover its `onSubmit` (AC-3a)
kids: &'a [Markup],
h: &'a Expr,
env: EId,
region: RId,
Expand Down Expand Up @@ -1028,6 +1049,7 @@ impl<'a> Checker<'a> {
self.resolve_tree();
self.loading_on_outside();
self.snapshot_guards();
self.guess_while_loading();
}
fn run_later(&mut self, job: Later<'a>) {
match job {
Expand Down Expand Up @@ -2765,7 +2787,7 @@ impl<'a> Checker<'a> {
if !h {
self.readers.push(ReaderRec { region, e: &a.e, env, line: self.line });
} else {
self.handlers.push(HandlerRec { attrs, h: &a.e, env, region, line: self.line, what: format!("<{}> {}", tag, a.name) });
self.handlers.push(HandlerRec { attrs, kids: children, h: &a.e, env, region, line: self.line, what: format!("<{}> {}", tag, a.name) });
}
}
self.markup(children, env, false);
Expand Down Expand Up @@ -3068,6 +3090,132 @@ impl<'a> Checker<'a> {
}
}
}

// ---- AC-3a: a guess a click can make while its target loads a new question ----
/// AC-3a (D-34, Solid's `OPTIMISTIC_WRITE_WHILE_LOADING`): a handler starts a guess (in its own body, an inferred
/// or inline action, or a named action it calls) on an equation or source that awaits, whose question reads what
/// the click reads (a selection, a row), from an element that is not covered (R-8: inside a `loading` region whose
/// content reads the target, or `disabled` reading it). While that question is out the guess composes on the
/// previous answer, and the new answer replaces it: the user's change disappears while its action is out.
fn guess_while_loading(&mut self) {
let hs: Vec<(&'a [Attr], &'a [Markup], &'a Expr, EId, RId, i64, String)> = self.handlers.iter().map(|h| (h.attrs, h.kids, h.h, h.env, h.region, h.line, h.what.clone())).collect();
let acts: Vec<(String, i64, &'a [Stmt], EId)> = self.actions.iter().map(|a| (a.name.clone(), a.line, a.body, a.env)).collect();
let mut done: HashSet<*const Expr> = HashSet::new();
for (attrs, kids, h, env, region, line, what) in hs {
if !done.insert(h as *const Expr) {
continue;
}
// a form submits only through its buttons: disabled submit buttons block it (HTML implicit submission)
let mut submits: Vec<&'a [Attr]> = vec![];
if what == "<form> onSubmit" {
submit_buttons(kids, &mut submits);
}
// the bodies the click runs: the handler, and the actions it calls by name
let mut bodies: Vec<(Node<'a>, EId, Option<String>)> = vec![(Node::E(h), env, None)];
let mut stack = vec![Node::E(h)];
while let Some(n) = stack.pop() {
if let Node::E(Expr { k: ExprKind::Call { f, .. }, .. }) = n {
if let ExprKind::Id(name) = &f.k {
if let Some(a) = self.lookup(env, name) {
if self.syms[a].kind == SymKind::Action {
let l = self.syms[a].line;
for (an, al, body, aenv) in &acts {
if an == name && *al == l {
bodies.extend(body.iter().map(|st| (Node::S(st), *aenv, Some(name.clone()))));
}
}
}
}
}
}
stack.extend(children(n, false));
}
// what the click is about: the names those bodies read, outside their own writes and guesses
let mut inputs: Vec<SId> = vec![];
for (root, benv, _) in &bodies {
let mut st = vec![*root];
while let Some(n) = st.pop() {
match n {
Node::S(x) if matches!(x.k, StmtKind::Optimistic { .. } | StmtKind::Write { .. }) => continue,
Node::E(x) => {
if let ExprKind::Id(name) = &x.k {
if let Some(v) = self.lookup(*benv, name) {
if matches!(self.syms[v].kind, SymKind::Source | SymKind::Eq | SymKind::Value | SymKind::Props) && !inputs.contains(&v) {
inputs.push(v);
}
}
}
}
_ => {}
}
st.extend(children(n, false));
}
}
let mut seen: HashSet<SId> = HashSet::new();
for (root, benv, via) in bodies {
let mut st = vec![root];
while let Some(n) = st.pop() {
if let Node::S(x) = n {
if let StmtKind::Optimistic { target, .. } = &x.k {
if let Some(t) = self.lookup(benv, &target.name) {
if seen.insert(t)
&& self.awaits(t, &mut HashSet::new())
&& self.asks_about(t, &inputs)
&& !attrs.iter().any(|a| a.name == "disabled" && self.depends(&a.e, env, t, &mut HashSet::new()))
&& !(!submits.is_empty() && submits.iter().all(|b| b.iter().any(|a| a.name == "disabled" && self.depends(&a.e, env, t, &mut HashSet::new()))))
&& !self.region_reads(region, t, |r| r.provides.contains(HOLD))
{
let n = &target.name;
self.line = line;
self.err(
"GUESS_WHILE_LOADING",
"AC-3a",
format!(
"{what} {} `guess {n}` while {n} is loading a new question (its question reads what the click reads): the guess composes on the previous answer, and the new answer replaces it, so the change disappears while it is out (D-34) — put the element inside the `loading` region whose content reads {n}, or give it `disabled={{pending? {n}}}`, or keep the pending change in a source of its own until it lands",
match &via { Some(a) => format!("starts `{a}`, which can run"), None => "can run".into() }
),
);
}
}
}
}
st.extend(children(n, false));
}
}
}
}
/// AC-3a: `t`'s value comes from an `await` (with or without a first paint), here or in what it reads.
fn awaits(&self, t: SId, busy: &mut HashSet<SId>) -> bool {
let Some((e, env)) = self.syms[t].expr else { return false };
if !busy.insert(t) {
return false;
}
let mut stack = vec![Node::E(e)];
while let Some(n) = stack.pop() {
if let Node::E(x) = n {
match &x.k {
ExprKind::Fn { .. } | ExprKind::ServerFn { .. } | ExprKind::Peek(_) => continue,
ExprKind::Await { .. } => return true,
ExprKind::Id(name) => {
if let Some(u) = self.lookup(env, name) {
if u != t && matches!(self.syms[u].kind, SymKind::Eq | SymKind::Source) && self.awaits(u, busy) {
return true;
}
}
continue;
}
_ => {}
}
}
stack.extend(children(n, false));
}
false
}
/// AC-3a: `t`'s question reads one of `inputs` (what a click reads) that does not itself follow `t`.
fn asks_about(&self, t: SId, inputs: &[SId]) -> bool {
let Some((e, env)) = self.syms[t].expr else { return false };
inputs.iter().any(|&i| i != t && !self.syms[i].expr.is_some_and(|(ie, ienv)| self.depends(ie, ienv, t, &mut HashSet::new())) && self.depends(e, env, i, &mut HashSet::new()))
}
/// R-8: `s` can be read before its first answer: an `await` with no first paint (`-> v`, R-7) in it or what it reads.
fn absent(&self, s: SId, memo: &mut HashMap<SId, bool>, busy: &mut HashSet<SId>) -> bool {
self.sym_absent(s, false, memo, busy)
Expand Down Expand Up @@ -4123,6 +4271,51 @@ mod tests {
check_modules(&set, spa).diags.iter().map(|d| (d.line, d.code.clone())).collect()
}

/// 06 AC-3a: a guess a click can make while its value loads a new question, unless the element is covered.
#[test]
fn guess_while_loading() {
let src = r#"export default component App() {
source sel := 1
source filter := "open"
source detail = await fetch("issue:" + sel)
source rows = await fetch("rows:" + filter)
source note := ""
action rename(t: string) {
try {
guess detail := d => d == null ? d : { ...d, title: t }
guess rows := rs => rs
await fetch("rename:" + sel + "," + t)
refresh detail
} catch (e) {
note := e.message
}
}
errored fallback(e) { <p>failed</p> } {
loading on(sel) fallback { <p>loading</p> } {
<h2>{detail.title}</h2>
<button onClick={() => { rename("in") }}>in</button>
}
loading on(filter) fallback { <p>loading</p> } {
<p>{rows.length}</p>
}
}
<button onClick={() => { sel := sel + 1 }}>next</button>
<button onClick={() => { rename("out") }}>out</button>
<button disabled={pending? detail} onClick={() => { rename("off") }}>off</button>
<form onSubmit={() => { rename("form") }}>
<button disabled={pending? detail}>go</button>
</form>
<form onSubmit={() => { rename("form2") }}>
<button>go</button>
</form>
}
"#;
let got: Vec<i64> = codes(src, false).iter().filter(|(_, c)| c == "GUESS_WHILE_LOADING").map(|(l, _)| *l).collect();
// `out` and the second form: `detail` asks about `sel`, which `rename` reads; `rows` asks about `filter`,
// which the click never reads; `in` sits in detail's region, `off` and the first form are disabled on it
assert_eq!(got, vec![27, 32], "{:?}", codes(src, false));
}

/// 08 CN-3d: in a client-only build, a reader the first load surely waits on needs a `loading` above it.
#[test]
fn first_load_unheld_in_client_only_builds() {
Expand Down
75 changes: 75 additions & 0 deletions eval/PREREG-desk-12.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Round 12: round 11 again with five arms and twice the frontier runs

Pre-registration, committed before any round 12 agent runs. Status: FROZEN (Ryan, 2026-11-11 "Go").

## Why

Round 11 (`results/desk-2026-11-11`) replicated round 10's small-tier result.

- **Small tier:** .rx had 3.8 defects per app and React 9.7. H11a holds, at p = 0.047.
- **Frontier tier:** every arm was between 2 and 3. With three runs per arm, H11c ("no worse than React") could not be shown: the interval was [−2.3, 2.3].
- **XState:** a side check the same day (`results/xstate-2026-11-11`, not pre-registered) put small XState at 7.0, between .rx and React and below Foldkit's 13.2.

Round 12 asks whether these hold with fresh agents. It adds XState as a fifth arm and doubles the frontier runs, so the frontier comparison has a chance to resolve.

Ryan, 2026-11-11: replication now, maintenance next. Maintenance moves to round 13 and runs on round 12's apps.

## Task and arms (as round 11 unless stated)

**Task:** `tasks/desk9` with `brief-plain.md`, unchanged. Graded by `hidden.mjs` (77) and `hidden-overlap.mjs` (38), unchanged.

**Arms:**
- **`rx`** with `--inline-docs` (`language.md`, `async.md`, `reference.md`).
- **`foldkit`** with `--inline-docs`.
- **`react`**.
- **`vanilla`**.
- **`xstate`**, new: React 19 with xstate 5.32.6 and @xstate/react 6.1.0, with no TanStack Query. It uses `--inline-docs`, which here means Stately's own v5 cheatsheet and @xstate/react's README in the prompt. Every Stately v5 docs page is readable in `docs/xstate`, from statelyai/docs at 7ee0c60. The checker is the same as React's.

**Language and guide changes since round 11's freeze:**
- `guess` replaces `optimistic` (#47).
- Errors are cleared with `onFlush` (#47).
- A guess over an equation is allowed (#48).
- Destructuring works (#49).

The guide follows all four. `rxc` is built from the commit that holds this file once it is frozen. Round 11's .rx desks use `optimistic`, so the gate below grades them with round 11's `rxc`, which isolates the Solid change.

**Guide addition** (principle only, no task code): `async.md` says how to guess a prop. You guess over the child's own equation of it, never `props.x`.

**Solid:** `solid-next` at `next` bcef1041, built from source. It holds the fixes for both cases of solidjs/solid#4762: cf32a50b (a keyed projection of a projection losing a row on a switch) and #4769 (two guesses, one landing after the switch, now hold together as D-33 says).

**Harness:** subagents, as in round 11.

## Runs, grading

**Runs:** 6 frontier and 6 small per arm, 60 in all, in one interleaved batch. Each run gets at most 15 `./check`/`./test` calls.

**Grading:** `harness/grade.sh`, with round 11's definitions of correct and cost. Only defects are counted. A fuzz hole counts unless it is traced to a legal hold, and each exclusion is listed with its trace.

## Hypotheses and tests

All tests use seed 12.

- **H12a** (primary, replication of H11a): small .rx runs have fewer defects than small React runs. One-sided permutation test on mean defects, 100,000 relabellings.
- **H12b**: three comparisons, Holm-corrected together with H12a:
- small .rx has fewer defects than small XState;
- small .rx has fewer defects than small plain;
- small .rx has fewer defects than small Foldkit.
- **H12c**: frontier .rx runs have no more defects than frontier React runs. Difference in means with a 90% bootstrap interval, 10,000 resamples. The claim holds if the upper end is under 1 defect.
- **H12d**: small XState runs have fewer defects than small Foldkit runs. Same test as H12a, reported apart from the .rx family. This is the PROCESS question: state machines against Effect/TEA.
- **Descriptive:** cost per correct app, by arm and tier.
- **Reported alongside:**
- the change from round 11 for each arm and tier;
- defects by kind;
- turns, cost per run and lines.

The direction is the claim being tested. A null or reversed result is reported as plainly.

## Gate before freezing

Round 11's nine .rx desks are regraded on the pinned Solid, with round 11's `rxc`. Any desk that gains a hole is traced. A Solid regression is filed upstream, then fixed or recorded, before the round starts.

**Gate result** (2026-11-11, on `next` bcef1041's source, round 11's `rxc`): 26 defects across the nine desks, against 29 in round 11. Only rx-2 changed, from 5 to 2: its three fuzz holes (seeds 1, 3, 18, solidjs/solid#4762) are gone, and its two overlap failures, which are its own (a pending source in a component that remounts), remain. The other eight desks grade exactly as in round 11. No desk gained a hole, so nothing needed tracing.

## Cost

About $67 at round 11's prices: 6 × ($2.05 + $1.38 + $1.27 + $3.63 + $2.30) for the frontier runs, plus about $3.30 for the small ones.
4 changes: 2 additions & 2 deletions eval/harness/build.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Builds one app (a run directory or a reference) into `<dir>/.build/dist` with Vite, for the tests to drive.
// rx arm: `rxc compile src/main.rx` → JavaScript over Solid rc.19, mounted with the .rx runtime (ref/compile/rt-web.ts)
// react arm: `src/App.tsx` (default export) mounted with createRoot under StrictMode, as Vite's React template does
// react and xstate arms: `src/App.tsx` (default export) mounted with createRoot under StrictMode, as Vite's React template does
// solid arm: `src/App.tsx` (default export) compiled by @solidjs/compiler (Solid 2's JSX compiler) and mounted with
// @solidjs/web's `render`
// vanilla arm: `src/main.ts` default-exports `mount(root: HTMLElement)`, called once with the `#app` container
Expand Down Expand Up @@ -57,7 +57,7 @@ export async function build(dir, arm = armOf(dir)) {
writeFileSync(path.join(work, "entry.js"), entry);
writeFileSync(path.join(work, "index.html"), `<!doctype html><html><head><meta charset="utf-8"><title>app</title></head><body><div id="app"></div><script type="module" src="./entry.js"></script></body></html>\n`);
const vite = await import(pathToFileURL(path.join(evalDir, "node_modules/vite/dist/node/index.js")).href);
const plugins = arm === "react" ? [(await import(pathToFileURL(path.join(evalDir, "node_modules/@vitejs/plugin-react/dist/index.js")).href)).default()]
const plugins = arm === "react" || arm === "xstate" ? [(await import(pathToFileURL(path.join(evalDir, "node_modules/@vitejs/plugin-react/dist/index.js")).href)).default()]
: arm === "solid" ? [await solidPlugin()] : [];
try {
await vite.build({
Expand Down
2 changes: 1 addition & 1 deletion eval/harness/check.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// The arm's compiler feedback, as the agent runs it.
// rx: rxc check src/main.rx
// react: tsc --noEmit -p . and eslint (react-hooks rules)
// react, xstate: tsc --noEmit -p . and eslint (react-hooks rules)
// solid: tsc --noEmit -p . and eslint (eslint-plugin-solid's v2 config)
// vanilla: tsc --noEmit -p . (strict)
// foldkit: tsc --noEmit -p . with Foldkit's template tsconfig (its lint, oxlint + effect-tsgo, is not on npm)
Expand Down
2 changes: 1 addition & 1 deletion eval/harness/cost.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function permutation(a, b, n = 100000, seed = 5) {
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const [list, dir, out] = process.argv.slice(2);
const runs = readFileSync(list, "utf8").trim().split("\n").map(l => l.trim().split(/\s+/)).map(([run, id]) => {
const [, task, arm, n] = run.match(/^(\w+)-(rx|react|solid|foldkit|vanilla)-(\d+)$/);
const [, task, arm, n] = run.match(/^(\w+)-(rx|react|xstate|solid|foldkit|vanilla)-(\d+)$/);
return { run, task, arm, n: Number(n), ...usage(path.join(dir, `agent-${id}.jsonl`)) };
});
for (const r of runs) console.log(`${r.run.padEnd(16)} billed ${Math.round(r.billed).toString().padStart(7)} calls ${r.calls} out ${r.output} cacheRead ${r.cacheRead} cacheWrite ${r.cacheWrite}`);
Expand Down
Loading
Loading