diff --git a/eval/per-sheet-eval.mjs b/eval/per-sheet-eval.mjs index 4691adc..96a902c 100644 --- a/eval/per-sheet-eval.mjs +++ b/eval/per-sheet-eval.mjs @@ -47,8 +47,12 @@ const NODE_HEAP_MB = parseInt(process.env.NODE_HEAP_MB || '8192'); const MAX_SHEET_SIZE_MB = parseInt(process.env.MAX_SHEET_SIZE_MB || '150'); // Per-task child timeout. A real cross-sheet cluster (17 sheets, millions of // formulas, seeded from a multi-GB ground truth) needs more than the per-sheet -// default — raise EVAL_TIMEOUT_MS for the single-pass cone measurement. +// default, so cluster tasks get their own ceiling: a warm-seeded real cluster is +// ~2-3 passes at ~10-15 min/pass, and the #61 divergence detector bounds a +// hopeless cluster's churn — 60 min covers the healthy case without letting a +// stuck child run forever. const EVAL_TIMEOUT_MS = parseInt(process.env.EVAL_TIMEOUT_MS || '300000'); +const EVAL_CLUSTER_TIMEOUT_MS = parseInt(process.env.EVAL_CLUSTER_TIMEOUT_MS || String(Math.max(EVAL_TIMEOUT_MS, 3600000))); // ── Validate inputs ──────────────────────────────────────────────────────── const gtPath = join(chunkedDir, '_ground-truth.json'); @@ -80,7 +84,7 @@ async function main() { console.log(` Ground truth: ${totalCells} cells`); // Group ground truth by sheet - const gtBySheet = {}; + let gtBySheet = {}; for (const [addr, val] of Object.entries(allGt)) { const bang = addr.indexOf('!'); if (bang < 0) continue; @@ -169,11 +173,12 @@ async function main() { } const namedCells = namedOfInterest.map(o => o.cell); - // Write full ground truth to a temp file for child processes + // Children read the full ground truth straight from the build's own file — + // re-serializing a ~5.8M-cell copy into _eval_tmp doubled parent peak memory + // (a ~170MB transient string) and disk for no isolation benefit. const tmpDir = join(chunkedDir, '_eval_tmp'); await mkdir(tmpDir, { recursive: true }); - const gtTmpPath = join(tmpDir, '_gt_full.json'); - await writeFile(gtTmpPath, JSON.stringify(allGt)); + const gtTmpPath = gtPath; // Build task list. Cross-sheet circular clusters become a SINGLE task (one // convergence, all members scored); standalone sheets stay one task each. @@ -190,20 +195,30 @@ async function main() { continue; } - // Check module size + const cluster = clusterSheetSet.has(entry.name) ? sheetClusters.find(c => c.includes(entry.name)) : null; + + // Check module size — STANDALONE sheets only. A cluster member must NEVER be + // size-skipped: the cluster would converge WITHOUT it to a silently wrong + // fixed point and every member would be scored against garbage. An oversized + // member is included loudly — if the import then OOMs, the cluster reports + // an honest crash instead of a confident wrong number. try { const modStat = await stat(modulePath); const sizeMB = modStat.size / (1024 * 1024); if (sizeMB > MAX_SHEET_SIZE_MB) { - skipped.push({ name: entry.name, reason: `module too large (${sizeMB.toFixed(0)}MB > ${MAX_SHEET_SIZE_MB}MB limit)` }); - continue; + if (cluster) { + console.log(` ! ${entry.name}: ${sizeMB.toFixed(0)}MB exceeds the ${MAX_SHEET_SIZE_MB}MB cap but is a cluster member — included anyway (a partial cluster is a silently wrong fixed point).`); + } else { + skipped.push({ name: entry.name, reason: `module too large (${sizeMB.toFixed(0)}MB > ${MAX_SHEET_SIZE_MB}MB limit)` }); + continue; + } } } catch { skipped.push({ name: entry.name, reason: 'stat failed' }); continue; } - if (SKIP_CLUSTERS && clusterSheetSet.has(entry.name)) { + if (SKIP_CLUSTERS && cluster) { skipped.push({ name: entry.name, reason: 'circular cluster (--skip-clusters; needs single-pass orchestrator eval)' }); continue; } @@ -222,6 +237,7 @@ async function main() { // Write per-sheet GT to temp const sheetGtPath = join(tmpDir, `_gt_${sanitized}.json`); await writeFile(sheetGtPath, JSON.stringify(sampleGt)); + entry.gt = null; // sampled to disk — free the parent's per-sheet copy const member = { sheetName: entry.name, @@ -235,7 +251,6 @@ async function main() { // Route cluster members into one shared cluster task; standalone sheets each // get their own task. - const cluster = clusterSheetSet.has(entry.name) ? sheetClusters.find(c => c.includes(entry.name)) : null; if (cluster) { const key = cluster.join('|'); if (!clusterTasks.has(key)) { @@ -251,6 +266,13 @@ async function main() { // standalone sheets still start first under the concurrency window). for (const ct of clusterTasks.values()) tasks.push(ct); + // The grouped-by-sheet GT (a second full set of ~N-million property slots) is + // no longer needed — every evaluated sheet's sample is on disk. Free it so the + // parent holds ONE full GT copy (allGt, still needed for seed scoping and the + // named-output report) instead of two. + for (const e of sheetEntries) e.gt = null; + gtBySheet = null; + // GT-seed scoping (#33): give each cluster child only the GT it needs — its // external reads (+ a warm start for its own cells) — instead of the whole // ~5.8M-cell ground truth, which OOMs an 8GB heap. Write a per-cluster scoped @@ -328,17 +350,18 @@ import { readFile } from 'fs/promises'; import { compute } from ${JSON.stringify(pathToFileURL(modulePath).href)}; ${clusterImports} -const allGt = JSON.parse(await readFile(${JSON.stringify(gtFullPath.replace(/\\/g, '/'))}, 'utf8')); const sheetGt = JSON.parse(await readFile(${JSON.stringify(sheetGtPath.replace(/\\/g, '/'))}, 'utf8')); const cn = s => { let n=0; for(const c of s) n = n*26+c.charCodeAt(0)-64; return n; }; const nc = n => { let s=''; while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);} return s; }; const ctx = { - values: {}, - _written: new Set(), // cells written by compute() — the cluster convergence diffs only these + // Seeded with the full ground truth (upstream sheet values) — parsed DIRECTLY + // into ctx.values. The old pattern (parse allGt, then copy every entry in a + // loop) held TWO full copies of a multi-GB object; this holds one. + values: JSON.parse(await readFile(${JSON.stringify(gtFullPath.replace(/\\/g, '/'))}, 'utf8')), _sheetConvergence: {}, // intra-sheet cycle telemetry sink (PR #52): an emitted sheet with an internal convergence loop writes ctx._sheetConvergence[SHEET_NAME]; without this it throws "Cannot set properties of undefined" get(addr) { return this.values[addr] !== undefined ? this.values[addr] : 0; }, - set(addr, value) { this.values[addr] = value; this._written.add(addr); }, + set(addr, value) { this.values[addr] = value; }, _parseRange(rangeStr) { const m = rangeStr.match(/^(.+)!([A-Z]+)(\\d+):([A-Z]+)(\\d+)$/); if (!m) return null; @@ -368,11 +391,6 @@ const ctx = { } }; -// Seed context with full ground truth (upstream sheet values) -for (const [addr, val] of Object.entries(allGt)) { - ctx.values[addr] = val; -} - // Run compute (with convergence loop for circular clusters) try { ${clusterComputeBlock} @@ -471,16 +489,17 @@ process.stdout.write(JSON.stringify({ accuracy: total > 0 ? correct/total : 0, c import { readFile } from 'fs/promises'; ${importLines} -const allGt = JSON.parse(await readFile(${JSON.stringify(gtFullPath.replace(/\\/g, '/'))}, 'utf8')); - const cn = s => { let n=0; for(const c of s) n = n*26+c.charCodeAt(0)-64; return n; }; const nc = n => { let s=''; while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);} return s; }; const ctx = { - values: {}, - _written: new Set(), + // Seeded with the (scoped or full) ground truth, parsed DIRECTLY into + // ctx.values — single copy. The old parse-allGt-then-copy-every-entry pattern + // held TWO full copies of a multi-GB object and was one leg of the 16GB OOM + // on real-model clusters. + values: JSON.parse(await readFile(${JSON.stringify(gtFullPath.replace(/\\/g, '/'))}, 'utf8')), _sheetConvergence: {}, // intra-sheet cycle telemetry sink (PR #52): an emitted sheet with an internal convergence loop writes ctx._sheetConvergence[SHEET_NAME]; without this it throws "Cannot set properties of undefined" get(addr) { return this.values[addr] !== undefined ? this.values[addr] : 0; }, - set(addr, value) { this.values[addr] = value; this._written.add(addr); }, + set(addr, value) { this.values[addr] = value; }, _parseRange(rangeStr) { const m = rangeStr.match(/^(.+)!([A-Z]+)(\\d+):([A-Z]+)(\\d+)$/); if (!m) return null; @@ -501,61 +520,80 @@ const ctx = { } }; -// Seed context with full ground truth (upstream sheet values) -for (const [addr, val] of Object.entries(allGt)) ctx.values[addr] = val; - -// ONE convergence loop over ALL cluster members. Diff only the cells the cluster -// wrote (ctx._written). A non-finite cell (Inf/NaN from a waterfall/coverage -// formula dividing by a not-yet-converged 0) is recorded and stops the loop -// rather than poisoning the delta — converged stays false (honest contract). +// ONE convergence loop over ALL cluster members, mirroring the SHIPPED engine's +// emitted cluster loop (chunked_emitter.rs) decision-for-decision so this harness +// accepts a fixed point on exactly the pass the engine does: the same SAMPLED +// delta surface (every numeric cell on the cluster's OWN sheets plus a bounded +// strided safety net over the rest of ctx, captured once after the first pass), +// the same parallel _before baseline array (undefined slots force an Infinity +// delta, so a first pass can never falsely converge), the same transient-tolerant +// non-finite rules (#57), and the same divergence detection and churn caps (#61). +// The old harness surface — a _written Set of ~4.7M fresh strings plus a per-cell +// prevSnapshot object — was BOTH a 16GB-OOM leg on real-model clusters AND subtly +// non-lockstep: it diffed and NaN-filled cells the cluster wrote onto NON-member +// sheets (the engine does neither) and skipped never-written GT input cells on +// member sheets (the engine NaN-fills those too on non-convergence). +// NOTE: comments here must avoid backticks (this is inside a template literal). const clusterFns = [${members.map((_, i) => `compute_${i}`).join(', ')}]; const MAX_ITER = 200, TOL = 1e-6; -const prevSnapshot = {}; +const _clusterSet = new Set(${JSON.stringify(clusterSheets)}); +const _SAMPLE_SAFETY_CAP = 4000; +let _sampleKeys = null, _before = null; const _deltaHist = []; let _iters = 0, _conv = false, _nonFinite = null, _err = null, _nonFiniteSettled = 0; try { for (let _ci = 0; _ci < MAX_ITER; _ci++) { _iters = _ci + 1; for (const fn of clusterFns) fn(ctx); + const v = ctx.values; + if (_sampleKeys === null) { + // First pass done: the cluster's cells now exist. Capture the sample. + _sampleKeys = []; + for (const key in v) { + const _bang = key.indexOf('!'); + if (_bang > 0 && _clusterSet.has(key.slice(0, _bang)) && typeof v[key] === 'number') _sampleKeys.push(key); + } + const _allKeys = Object.keys(v); + const _stride = Math.max(1, Math.floor(_allKeys.length / _SAMPLE_SAFETY_CAP)); + for (let i = 0; i < _allKeys.length; i += _stride) { + if (typeof v[_allKeys[i]] === 'number') _sampleKeys.push(_allKeys[i]); + } + _before = new Array(_sampleKeys.length); + // No observable cluster cells: we cannot confirm a fixed point, so do not + // let an empty scan read maxDelta=0 and falsely converge on pass 0. + if (_sampleKeys.length === 0) break; // converged stays false (honest) + } let maxDelta = 0, anyNonFinite = false; - for (const k of ctx._written) { - const v = ctx.values[k]; - if (typeof v !== 'number') continue; - // TRANSIENT-TOLERANT non-finite handling (#57) — mirrors chunked_emitter.rs so - // the fast eval harness and the shipped engine agree. A non-finite written cell - // (Inf/NaN from a divide-by-cold-0 denominator that has not warmed yet) does NOT - // break the loop the way the old "_ci>=2" veto did (which quit before the - // denominator warmed). Exclude it from the delta and keep iterating; judge - // non-finiteness only at the fixed point. - // Store the non-finite value as the baseline (mirrors the engine storing _before[i] - // = _cur): when this cell WARMS to a finite number its delta becomes - // |finite - nonfinite| = non-finite, forcing a non-converging pass — so this harness - // accepts a fixed point on exactly the same pass the engine does (true lockstep, not - // just loop shape). NOTE: comments here must avoid backticks (this is inside a - // template literal). - if (!Number.isFinite(v)) { anyNonFinite = true; _nonFinite = k; prevSnapshot[k] = v; continue; } - const prev = prevSnapshot[k]; - // First observation (unset) forces a non-converging delta, like the engine's - // typeof-not-number guard — NOT the old prevSnapshot||0, which let a small first - // value look converged and diverged from the engine's pass count. - if (typeof prev !== 'number') { maxDelta = Infinity; prevSnapshot[k] = v; continue; } - const d = Math.abs(v - prev); + for (let i = 0; i < _sampleKeys.length; i++) { + const _cur = v[_sampleKeys[i]]; + if (typeof _cur !== 'number') continue; + // TRANSIENT-TOLERANT non-finite handling (#57): a non-finite cell (Inf/NaN + // from a divide-by-cold-0 denominator that has not warmed yet) is excluded + // from the delta, remembered, and the loop KEEPS iterating; non-finiteness + // is judged only at the fixed point. Storing it as the baseline means a + // WARMING cell produces a non-finite delta next pass, forcing another pass + // exactly like the engine. + if (!Number.isFinite(_cur)) { anyNonFinite = true; _nonFinite = _sampleKeys[i]; _before[i] = _cur; continue; } + const _b = _before[i]; + // A cell going undefined -> number is a change, not convergence. + if (typeof _b !== 'number') { maxDelta = Infinity; _before[i] = _cur; continue; } + const d = Math.abs(_cur - _b); if (d > maxDelta) maxDelta = d; - prevSnapshot[k] = v; + _before[i] = _cur; } if (anyNonFinite) { // Keep iterating so a TRANSIENT cold-0 can warm. Bound the churn (#61): stop fast // when the FINITE surface has SETTLED while a cell stays non-finite (STRUCTURAL - // #DIV/0! whose finite inputs settled, so it will never warm); a generous absolute - // cap also covers a divergent+persistent-non-finite cluster (whose finite surface - // never settles, so the settled test never fires). Mirrors chunked_emitter.rs. + // div-by-zero whose finite inputs settled, so it will never warm); a generous + // absolute cap also covers a divergent+persistent-non-finite cluster (whose finite + // surface never settles, so the settled test never fires). Mirrors chunked_emitter.rs. _deltaHist.length = 0; _nonFiniteSettled++; - if ((_ci > 0 && maxDelta < TOL && _nonFiniteSettled >= 4) || _nonFiniteSettled >= 50) break; + if ((maxDelta < TOL && _nonFiniteSettled >= 4) || _nonFiniteSettled >= 50) break; continue; } _nonFiniteSettled = 0; - if (_ci > 0 && maxDelta < TOL) { _conv = true; _nonFinite = null; break; } + if (maxDelta < TOL) { _conv = true; _nonFinite = null; break; } // Divergence detection (#61 — mirrors chunked_emitter.rs): a divergent (monotone-up) // or stuck-non-zero (flat-hot) cluster breaks early instead of churning to MAX_ITER // and then NaN-filling. A genuine contraction shrinks the delta, so it never matches. @@ -571,14 +609,20 @@ try { } } catch (e) { _err = e.message; } -// Honesty contract (mirrors chunked_emitter.rs NaN-fill on !_conv): a cluster that did -// NOT converge is not a fixed point — NaN-fill the cells it wrote so compareOne and the -// named-output harvest below report NaN (detectably unusable) instead of leaving the -// warm-seeded GT values, which would over-report accuracy on a cluster the SHIPPED engine -// (eng.run) NaN-fills. Without this the fast harness and the engine disagree on a -// non-converged returns/MIP cluster (the lite/cone accuracy numbers read from here). +// Honesty contract (mirrors chunked_emitter.rs NaN-fill on !_conv): a cluster that +// did NOT converge is not a fixed point — NaN-fill the numeric cells on the +// cluster's OWN sheets (the engine's exact surface) so compareOne and the +// named-output harvest below report NaN (detectably unusable) instead of leaving +// the warm-seeded GT values, which would over-report accuracy on a cluster the +// SHIPPED engine (eng.run) NaN-fills. The lite/cone accuracy numbers read from here. if (!_conv) { - for (const k of ctx._written) { if (typeof ctx.values[k] === 'number') ctx.values[k] = NaN; } + for (const key in ctx.values) { + const _b = key.indexOf('!'); + if (_b > 0 && _clusterSet.has(key.slice(0, _b)) && typeof ctx.values[key] === 'number' + && !(ctx._locked && ctx._locked.has(key))) { + ctx.values[key] = NaN; + } + } } const compareOne = (sheetGt) => { @@ -620,7 +664,7 @@ process.stdout.write(JSON.stringify({ results, iters: _iters, converged: _conv, const { stdout: evalOut } = await execAsync( 'node', [`--max-old-space-size=${NODE_HEAP_MB}`, tmpScript], - { timeout: EVAL_TIMEOUT_MS, maxBuffer: 50 * 1024 * 1024, env: safeEnv } + { timeout: EVAL_CLUSTER_TIMEOUT_MS, maxBuffer: 50 * 1024 * 1024, env: safeEnv } ); const out = JSON.parse(evalOut); const elapsed = Date.now() - evalStart; diff --git a/package.json b/package.json index 99313db..0fc27ae 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "test:onboarding": "node tests/cli/test-onboarding.mjs", "test:golden": "node tests/cli/test-golden-master.mjs", "golden": "node eval/golden-master.mjs", - "test": "node tests/lib/test-lib.mjs && node tests/lib/test-scope-plan.mjs && node tests/lib/test-cone-emit.mjs && node tests/lib/test-lite-harness.mjs && node tests/lib/test-lite-tier0.mjs && node tests/lib/test-lite-tier0-generic.mjs && node tests/lib/test-driver-scope.mjs && node tests/lib/test-tier-recommender.mjs && node tests/lib/test-lite-evaluators.mjs && node tests/lib/test-lite-surrogate.mjs && node tests/lib/test-lite-provenance.mjs && node tests/lib/test-artifact-hash.mjs && node tests/cli/test-colrow-ref.mjs && node pipelines/rust/tests/test-date-axis-sumifs.mjs && node pipelines/rust/tests/test-shared-formula-anchors.mjs && node pipelines/rust/tests/test-structure-fidelity.mjs &&node pipelines/rust/tests/test-iferror-infinity.mjs && node pipelines/rust/tests/test-circular-honesty.mjs && node pipelines/rust/tests/test-cluster-transient-div0.mjs && node pipelines/rust/tests/test-div-nan-propagation.mjs && node pipelines/rust/tests/test-cluster-divergent-cap.mjs && node tests/cli/test-cli.mjs && node tests/cli/test-manifest-improvements.mjs && node tests/cli/test-manifest-maps.mjs && node tests/cli/test-model-type.mjs && node tests/cli/test-dedupe-inputs.mjs && node tests/cli/test-refine-label-index.mjs && node tests/cli/test-init-shared-gt.mjs && node tests/cli/test-per-sheet-eval.mjs && node pipelines/rust/tests/test-per-sheet-eval-intracycle.mjs && node pipelines/rust/tests/test-per-sheet-eval-lockstep.mjs && node tests/cli/test-ai-interface.mjs && node tests/cli/test-e2e4-fixes.mjs && node tests/cli/test-ship-ready.mjs && node tests/cli/use-case-suite.mjs && node tests/cli/test-onboarding.mjs && node tests/cli/test-lite-e2e.mjs && node tests/cli/test-lite-byrequest-disclosure.mjs", + "test": "node tests/lib/test-lib.mjs && node tests/lib/test-scope-plan.mjs && node tests/lib/test-cone-emit.mjs && node tests/lib/test-lite-harness.mjs && node tests/lib/test-lite-tier0.mjs && node tests/lib/test-lite-tier0-generic.mjs && node tests/lib/test-driver-scope.mjs && node tests/lib/test-tier-recommender.mjs && node tests/lib/test-lite-evaluators.mjs && node tests/lib/test-lite-surrogate.mjs && node tests/lib/test-lite-provenance.mjs && node tests/lib/test-artifact-hash.mjs && node tests/cli/test-colrow-ref.mjs && node pipelines/rust/tests/test-date-axis-sumifs.mjs && node pipelines/rust/tests/test-shared-formula-anchors.mjs && node pipelines/rust/tests/test-structure-fidelity.mjs &&node pipelines/rust/tests/test-iferror-infinity.mjs && node pipelines/rust/tests/test-circular-honesty.mjs && node pipelines/rust/tests/test-cluster-transient-div0.mjs && node pipelines/rust/tests/test-div-nan-propagation.mjs && node pipelines/rust/tests/test-cluster-divergent-cap.mjs && node tests/cli/test-cli.mjs && node tests/cli/test-manifest-improvements.mjs && node tests/cli/test-manifest-maps.mjs && node tests/cli/test-model-type.mjs && node tests/cli/test-dedupe-inputs.mjs && node tests/cli/test-refine-label-index.mjs && node tests/cli/test-init-shared-gt.mjs && node tests/cli/test-per-sheet-eval.mjs && node pipelines/rust/tests/test-per-sheet-eval-intracycle.mjs && node pipelines/rust/tests/test-per-sheet-eval-lockstep.mjs && node pipelines/rust/tests/test-per-sheet-eval-cluster-size.mjs && node tests/cli/test-ai-interface.mjs && node tests/cli/test-e2e4-fixes.mjs && node tests/cli/test-ship-ready.mjs && node tests/cli/use-case-suite.mjs && node tests/cli/test-onboarding.mjs && node tests/cli/test-lite-e2e.mjs && node tests/cli/test-lite-byrequest-disclosure.mjs", "bench": "node benchmarks/bench.mjs" }, "devDependencies": {} diff --git a/pipelines/rust/tests/test-per-sheet-eval-cluster-size.mjs b/pipelines/rust/tests/test-per-sheet-eval-cluster-size.mjs new file mode 100644 index 0000000..13271dd --- /dev/null +++ b/pipelines/rust/tests/test-per-sheet-eval-cluster-size.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * Regression: per-sheet-eval must NEVER size-skip a cross-sheet cluster member. + * + * Pre-fix, the MAX_SHEET_SIZE_MB gate ran before cluster routing, so an oversized + * member was silently dropped from the cluster task and the remaining members + * converged WITHOUT it — a silently wrong fixed point scored as if it were the + * model (on the real A-1 model the default 150MB cap dropped the 200MB+ monster + * sheets from the 17-sheet returns cluster with only a skip line in the log). + * + * Post-fix, a cluster member over the cap is included loudly; only STANDALONE + * sheets are size-skipped. This test runs the REAL rust-parser on a 3-sheet model + * (Bal<->Debt cross-sheet cluster + a standalone Calc sheet) with the cap forced + * to ~0 so EVERY module is "too large": + * - pre-fix: all sheets skipped -> clustersTotal === 0 (the cluster silently + * never forms) — this is the RED the fix turns green. + * - post-fix: clustersTotal === 1, converged, cluster sheets scored 100%; + * the standalone sheet is still skipped as 'module too large'. + * + * Needs the rust-parser binary. Skips (exit 0) if it isn't built. + * + * Usage: node pipelines/rust/tests/test-per-sheet-eval-cluster-size.mjs + */ + +import XLSX from 'xlsx'; +import { writeFileSync, existsSync, readFileSync, mkdtempSync, rmSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { tmpdir } from 'os'; +import { execFileSync } from 'child_process'; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dir, '..', '..', '..'); +const exe = process.platform === 'win32' ? '.exe' : ''; +const PARSER = [ + join(ROOT, 'pipelines/rust/target/release', `rust-parser${exe}`), + join(ROOT, 'pipelines/rust/target/debug', `rust-parser${exe}`), +].find(existsSync); +const EVAL = join(ROOT, 'eval', 'per-sheet-eval.mjs'); + +if (!PARSER) { + console.log('SKIP: rust-parser not built (cd pipelines/rust && cargo build --release)'); + process.exit(0); +} + +let passed = 0, failed = 0; +const assert = (c, m) => { if (c) { passed++; } else { failed++; console.error(` FAIL: ${m}`); } }; +const S = (ref, cells) => { const s = { '!ref': ref }; for (const [k, v] of Object.entries(cells)) s[k] = v; return s; }; +const n = (v, f) => (f ? { t: 'n', v, f } : { t: 'n', v }); + +console.log('Testing: a cluster member is never size-skipped (MAX_SHEET_SIZE_MB applies to standalone sheets only)'); + +// Bal<->Debt form a convergent cross-sheet cluster; Calc is standalone. +const Bal = S('A1:A1', { A1: n(2, '1+0.5*Debt!A1') }); +const Debt = S('A1:A2', { A1: n(2, 'Bal!A1'), A2: n(4, 'Debt!A1+Bal!A1') }); +const Calc = S('A1:A2', { A1: n(7), A2: n(14, 'A1*2') }); + +const tmp = mkdtempSync(join(tmpdir(), 'pse-size-')); +let report = null; +try { + writeFileSync(join(tmp, 'm.xlsx'), XLSX.write({ SheetNames: ['Bal', 'Debt', 'Calc'], Sheets: { Bal, Debt, Calc } }, { type: 'buffer', bookType: 'xlsx' })); + execFileSync(PARSER, [join(tmp, 'm.xlsx'), join(tmp, 'out'), '--chunked'], { encoding: 'utf-8', stdio: 'pipe' }); + const out = join(tmp, 'report.json'); + // Force the size cap to ~0 so every emitted module exceeds it. + try { + execFileSync('node', [EVAL, join(tmp, 'out', 'chunked'), '--output', out], { + encoding: 'utf-8', stdio: 'pipe', maxBuffer: 64 * 1024 * 1024, + env: { ...process.env, MAX_SHEET_SIZE_MB: '0.000001' }, + }); + } catch { /* exit code reflects accuracy threshold; inspect the report */ } + report = existsSync(out) ? JSON.parse(readFileSync(out, 'utf-8')) : null; +} finally { + rmSync(tmp, { recursive: true, force: true }); +} + +assert(report !== null, 'report written'); +if (report) { + assert(report.summary.clustersTotal === 1, + `oversized cluster members still form the cluster task (clustersTotal=${report.summary.clustersTotal}, want 1 — 0 means members were silently size-skipped)`); + assert(report.summary.clustersConverged === 1, + `the full cluster converges (clustersConverged=${report.summary.clustersConverged})`); + for (const name of ['Bal', 'Debt']) { + const row = report.sheets.find(s => s.name === name); + assert(row && row.status === 'ok' && row.accuracy === 100, + `${name} scored from the full-cluster fixed point (got ${row ? `${row.status}/${row.accuracy}%` : 'no row'})`); + } + const calcSkip = report.skipped.find(s => s.name === 'Calc'); + assert(!!calcSkip && /too large/.test(calcSkip.reason), + `standalone sheet is still size-skipped (got ${JSON.stringify(calcSkip || report.skipped)})`); +} + +console.log(''); +console.log(`Results: ${passed} passed, ${failed} failed, ${passed + failed} total`); +process.exit(failed > 0 ? 1 : 0);