From 626e9bbc5f6e97ccf5104434fe363fe3fec98f49 Mon Sep 17 00:00:00 2001 From: Eric Boothe Date: Wed, 10 Jun 2026 09:44:31 -0600 Subject: [PATCH] fix(eval): GT-seed scoping scan must flag _dynRange/_offsetAddr (the #66 helpers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.3.1 emitter lowers computed-endpoint ranges (ref:OFFSET(...)) through _dynRange/_offsetAddr with NO bare _offset( call, so per-sheet-eval's dynamic-read scan — which only knew _offset(/ctx.get(String( — silently approved GT-seed scoping on exactly the builds where ranges are runtime- addressed. Observed live on the a1-66c canonical eval (scan found no _offset(, scoped the cluster seed). Today this is defense-in-depth, not a live corruption hole: a sheet-qualified computed-endpoint anchor (Ext!E1:OFFSET(...)) refuses to parse (honest NaN, filed #71), so _dynRange reads are same-sheet-only and cluster-scope warm-starts cover them. But the scan must refuse to scope what it cannot statically bound — when #71 closes, this is already correct. New MODEL C in test-row-chunked-modules.mjs: a cluster whose ONLY dynamic construct is a same-sheet computed-endpoint range — asserts the scan refuses to scope (red pre-fix via stash) and the fixture emits _dynRange with no bare _offset( (else it discriminates nothing). 22/22. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 +++ eval/per-sheet-eval.mjs | 8 +++- .../rust/tests/test-row-chunked-modules.mjs | 41 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ace43b..086dd05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ input cells). **A cluster member is never size-skipped** (`MAX_SHEET_SIZE_MB` silently dropped the monster sheets from the cluster → partial-cluster wrong fixed point; regression red pre-fix with `clustersTotal=0`). New `EVAL_CLUSTER_TIMEOUT_MS` (default 60min). +- **per-sheet-eval dynamic-read scan knows the #66 helpers** — the v0.3.1 emitter lowers + `ref:OFFSET(...)` through `_dynRange`/`_offsetAddr` with no bare `_offset(` call, so the + GT-seed-scoping scan approved scoping on exactly the builds where ranges are runtime- + addressed (observed live on the a1-66c canonical eval). Markers added (red pre-fix in the + new MODEL C). Defense-in-depth today: `_dynRange` anchors are same-sheet-only because a + sheet-qualified computed-endpoint range refuses to parse (honest NaN) — filed **#71**. - **#46 row-chunked sheet modules** (`--max-module-mb=N`, default 64, 0 disables): any sheet module crossing the cap rotates into `.partNNN.mjs` modules behind a same-named facade — ONE logical `compute()`, identical write sequence, statement-boundary splits only, diff --git a/eval/per-sheet-eval.mjs b/eval/per-sheet-eval.mjs index 274da18..2831c3d 100644 --- a/eval/per-sheet-eval.mjs +++ b/eval/per-sheet-eval.mjs @@ -292,7 +292,13 @@ async function main() { // so the scan must follow the facade's part imports or it would silently // approve scoping for exactly the monster sheets most likely to use OFFSET. let _dynamicRead = false; - const _hasDynamicRead = (src) => src.includes('_offset(') || src.includes('ctx.get(String('); + // _dynRange/_offsetAddr are the #66 computed-endpoint-range helpers — the + // v0.3.1 emitter lowers `ref:OFFSET(...)` through them WITHOUT any bare + // `_offset(` call, so a scan that only knows the scalar markers silently + // approves scoping for exactly the builds where ranges are runtime-addressed + // (observed live on the a1-66c canonical eval). + const _hasDynamicRead = (src) => src.includes('_offset(') || src.includes('_dynRange(') + || src.includes('_offsetAddr(') || src.includes('ctx.get(String('); for (const m of ct.members) { try { const src = await readFile(m.modulePath, 'utf8'); diff --git a/pipelines/rust/tests/test-row-chunked-modules.mjs b/pipelines/rust/tests/test-row-chunked-modules.mjs index d357791..e798d14 100644 --- a/pipelines/rust/tests/test-row-chunked-modules.mjs +++ b/pipelines/rust/tests/test-row-chunked-modules.mjs @@ -206,6 +206,47 @@ console.log('Testing: row-chunked module emission (#46) — facade + parts, one } } +// ── MODEL C: the ONLY dynamic construct is a computed-endpoint range (_dynRange) ── +{ + // The v0.3.1 emitter (#66) lowers `ref:OFFSET(...)` through _dynRange/_offsetAddr + // with NO bare `_offset(` call — observed live on the a1-66c canonical eval, where + // the scan found no `_offset(` and approved GT-seed scoping. _dynRange anchors are + // currently same-sheet only (a sheet-qualified anchor refuses to parse -> honest + // NaN), so cluster-scope warm-starts cover its reads today and this is + // defense-in-depth — but the scan must still refuse to scope what it cannot + // statically bound. No row-chunking needed: the scan reads plain modules too. + const Loop1 = { + '!ref': 'A1:C3', + A1: n(2, '0.5*Loop2!A1+1'), + B1: n(6, 'SUM(C1:OFFSET(C1,2,0))+0*Loop2!A1'), + C1: n(1), C2: n(2), C3: n(3), + }; + const Loop2 = { '!ref': 'A1:A1', A1: n(2, '0.5*Loop1!A1+1') }; + + const { tmp, chunked } = build({ Loop1, Loop2 }, ['Loop1', 'Loop2'], CAP_MB); + try { + const src = readFileSync(join(chunked, 'sheets', 'Loop1.mjs'), 'utf-8'); + assert(src.includes('_dynRange(') && !src.includes('_offset('), + 'fixture emits _dynRange with no bare _offset( (else this model discriminates nothing)'); + + const EVAL = join(ROOT, 'eval', 'per-sheet-eval.mjs'); + const out = join(tmp, 'report.json'); + let stdout = ''; + try { + stdout = execFileSync('node', [EVAL, chunked, '--output', out, '--sample', '50000'], + { encoding: 'utf-8', stdio: 'pipe', maxBuffer: 64 * 1024 * 1024 }); + } catch (e) { stdout = String(e.stdout || ''); } + const report = existsSync(out) ? JSON.parse(readFileSync(out, 'utf-8')) : null; + + assert(/keeping FULL GT seed/.test(stdout), + 'per-sheet-eval flags _dynRange as a runtime-addressed read and refuses to scope'); + assert(report !== null && report.summary.clustersConverged === 1 && report.summary.overallAccuracy === 100, + `cluster converged at 100% (got ${report && report.summary.clustersConverged}, ${report && report.summary.overallAccuracy}%)`); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + console.log(''); console.log(`Results: ${passed} passed, ${failed} failed, ${passed + failed} total`); process.exit(failed > 0 ? 1 : 0);