Skip to content

Commit 5489d2b

Browse files
olwangclaude
andcommitted
feat(selfhost): AST-dump producer (astdump.rss) — streaming rss parser, parity-gated
Step 2 of frontend object parity: a recursive-descent rss parser that STREAMS the canonical AST dump (the dump is a pre-order traversal, so no handle-based AST is materialized — each parse fn emits its node line(s) at a threaded depth and returns the next token index). Reuses scan.rss's tokenizer/accessors/constants. Core coverage: top-level functions (public/async/native, params with read/mut/take effects, generic-arg types, return type, body); statements (return, let/local [mut] [:type] =, assign, if/else[-if], while/loop, break, continue, expr-stmt); a range-based split-at-last-top-level-operator expression parser matching the Rust oracle's precedence (||, &&, |, ^, &, comparisons, shifts, +/-, */%), plus calls (name/qualified/receiver, named + effect-prefixed args), field access, index, array literals, try `?`, parenthesized grouping, and literals. Harness (crate::selfhost_parity): - ast_parity_tiny_sample + ast_parity_samples (non-ignored) — byte-for-byte vs the oracle on samples/tiny.rss and curated samples/ast/*.rss. The curated set is also the fast inner-loop gate (risk mitigation for corpus-gate runtime). - ast_parity_corpus (#[ignore]) — measures unaided reach over all 560 files and ratchets a floor. Current: 58 byte-exact, 0 run-failures (the producer never crashes; unsupported constructs mismatch rather than panic). Floor set to 58. Residual (declaration items, match/closures/for, effect-receiver calls, operator line-continuation) is the growing tail, tracked next as SH-025. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2834ae6 commit 5489d2b

5 files changed

Lines changed: 1013 additions & 0 deletions

File tree

crates/rsscript/src/selfhost_parity.rs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,3 +1412,146 @@ fn ast_oracle_total_over_corpus() {
14121412
}
14131413
assert!(empty.is_empty(), "{} files produced a degenerate dump", empty.len());
14141414
}
1415+
1416+
// ---------------------------------------------------------------------------
1417+
// AST-dump PARITY — the rss producer (`selfhost/astdump.rss`) vs the oracle.
1418+
//
1419+
// Step 2: the rss recursive-descent parser streams the canonical AST dump; the
1420+
// harness compares it byte-for-byte against `ast_oracle_dump`. Coverage is a
1421+
// growing core (see astdump.rss): the curated `samples/ast/*.rss` set is a
1422+
// non-ignored fast gate (also the risk mitigation for corpus-gate runtime — AST
1423+
// dumps are much larger than token dumps), while `ast_parity_corpus` measures
1424+
// unaided reach over all 556 files and ratchets a floor. Residual divergences
1425+
// are tracked as SH-025.
1426+
// ---------------------------------------------------------------------------
1427+
1428+
fn compile_astdump() -> Result<RegVmExecutable, String> {
1429+
let combined = combined_tool_source("astdump.rss")?;
1430+
reg_vm_compile_source("selfhost/astdump.rss", &combined)
1431+
.map_err(|e| format!("rss astdump failed to compile: {e:?}"))
1432+
}
1433+
1434+
/// Run the precompiled rss AST-dump producer; its stdout IS the dump.
1435+
fn run_astdump(exe: &RegVmExecutable, source: &str) -> Result<String, String> {
1436+
let output = exe
1437+
.eval_main_with_args([source.to_string()])
1438+
.map_err(|e| format!("rss astdump failed to run: {e:?}"))?;
1439+
Ok(output.stdout)
1440+
}
1441+
1442+
/// Curated AST-parity samples under `selfhost/samples/ast/`, sorted for stable order.
1443+
fn ast_sample_files() -> Vec<PathBuf> {
1444+
let dir = selfhost_dir().join("samples/ast");
1445+
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
1446+
.into_iter()
1447+
.flatten()
1448+
.flatten()
1449+
.map(|e| e.path())
1450+
.filter(|p| p.extension().is_some_and(|x| x == "rss"))
1451+
.collect();
1452+
files.sort();
1453+
files
1454+
}
1455+
1456+
/// Step-2 gate (non-ignored): the rss producer matches the oracle byte-for-byte
1457+
/// on the tiny sample — the end-to-end proof that the streaming producer, the
1458+
/// dump format, and the oracle all agree.
1459+
#[test]
1460+
fn ast_parity_tiny_sample() {
1461+
let sample_path = selfhost_dir().join("samples/tiny.rss");
1462+
let source = std::fs::read_to_string(&sample_path)
1463+
.unwrap_or_else(|e| panic!("cannot read {}: {e}", sample_path.display()));
1464+
let oracle = ast_oracle_dump("samples/tiny.rss", &source);
1465+
let exe = compile_astdump().expect("rss astdump should compile");
1466+
let actual = run_astdump(&exe, &source).expect("rss astdump should run");
1467+
assert_eq!(
1468+
actual, oracle,
1469+
"AST parity mismatch on tiny.rss\n--- oracle ---\n{oracle}\n--- rss ---\n{actual}"
1470+
);
1471+
}
1472+
1473+
/// Step-2 gate (non-ignored): the rss producer matches the oracle byte-for-byte
1474+
/// on every curated sample. This is the fast inner-loop gate; keep the samples
1475+
/// within the producer's supported core so it stays green as coverage grows.
1476+
#[test]
1477+
fn ast_parity_samples() {
1478+
let exe = compile_astdump().expect("rss astdump should compile");
1479+
let mut mismatches: Vec<String> = Vec::new();
1480+
let files = ast_sample_files();
1481+
for file in &files {
1482+
let rel = file
1483+
.strip_prefix(selfhost_dir())
1484+
.unwrap_or(file)
1485+
.display()
1486+
.to_string();
1487+
let source = std::fs::read_to_string(file)
1488+
.unwrap_or_else(|e| panic!("cannot read {}: {e}", file.display()));
1489+
let oracle = ast_oracle_dump(&rel, &source);
1490+
match run_astdump(&exe, &source) {
1491+
Err(e) => mismatches.push(format!("{rel}: run error: {e}")),
1492+
Ok(actual) => {
1493+
if actual != oracle {
1494+
mismatches.push(format!(
1495+
"{rel}: mismatch\n--- oracle ---\n{oracle}\n--- rss ---\n{actual}"
1496+
));
1497+
}
1498+
}
1499+
}
1500+
}
1501+
assert!(
1502+
mismatches.is_empty(),
1503+
"AST parity failed on {} of {} samples:\n{}",
1504+
mismatches.len(),
1505+
files.len(),
1506+
mismatches.join("\n\n")
1507+
);
1508+
}
1509+
1510+
/// Floor for `ast_parity_corpus` — the number of corpus files whose rss AST dump
1511+
/// already matches the oracle byte-for-byte. Ratchets up as the producer's
1512+
/// coverage grows; a drop signals a regression. (Full parity = files.len().)
1513+
const AST_CORPUS_PARITY_FLOOR: usize = 58;
1514+
1515+
/// Step-2 measurement gate (ignored by default): how many of the 556 corpus files
1516+
/// the rss producer already reproduces byte-for-byte. Not full parity yet — this
1517+
/// ratchets a floor so coverage can only grow, and prints the current count so
1518+
/// the residual (SH-025) is visible.
1519+
#[test]
1520+
#[ignore]
1521+
fn ast_parity_corpus() {
1522+
let root = workspace_root();
1523+
let files = collect_rss_files(&root);
1524+
let exe = compile_astdump().expect("rss astdump should compile");
1525+
let mut ok = 0usize;
1526+
let mut run_failures = 0usize;
1527+
let mut sample_mismatches: Vec<String> = Vec::new();
1528+
for file in &files {
1529+
let rel = file.strip_prefix(&root).unwrap_or(file).display().to_string();
1530+
let Ok(source) = std::fs::read_to_string(file) else {
1531+
continue;
1532+
};
1533+
let oracle = ast_oracle_dump(&rel, &source);
1534+
match run_astdump(&exe, &source) {
1535+
Err(_) => run_failures += 1,
1536+
Ok(actual) => {
1537+
if actual == oracle {
1538+
ok += 1;
1539+
} else if sample_mismatches.len() < 10 {
1540+
sample_mismatches.push(rel);
1541+
}
1542+
}
1543+
}
1544+
}
1545+
let total = files.len();
1546+
eprintln!(
1547+
"\n=== ast_parity_corpus ===\n files: {total}\n byte-exact: {ok}\n \
1548+
run-failures: {run_failures}\n floor: {AST_CORPUS_PARITY_FLOOR}\n"
1549+
);
1550+
for rel in &sample_mismatches {
1551+
eprintln!("[mismatch] {rel}");
1552+
}
1553+
assert!(
1554+
ok >= AST_CORPUS_PARITY_FLOOR,
1555+
"AST corpus parity regressed: {ok} byte-exact < floor {AST_CORPUS_PARITY_FLOOR}"
1556+
);
1557+
}

0 commit comments

Comments
 (0)