@bldrs-ai/conway 1.323.1029 → 1.324.1032

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -178,7 +178,7 @@ Every PR is gated on two checks defined in `.github/workflows/build.yml`:
178
178
  | Job | What it does |
179
179
  |---|---|
180
180
  | `build` | `yarn install`, WASM + TS compile (WASM cached on the `conway-geom` submodule SHA), `yarn test`, `yarn lint`. |
181
- | `run-ifc-regression` | `needs: build`. Reuses the same WASM cache. Runs the regression batch against the pinned `test-models` tag, posts a per-PR comment with `failed.csv` / `errors.csv` summaries, and uploads the candidate npm tarball as a workflow artifact. |
181
+ | `run-ifc-regression` | `needs: build`. Reuses the same WASM cache. Runs the regression batch against the pinned `test-models` tag, posts a per-PR comment with `failed.csv` / `errors.csv` / perf summaries, and uploads the candidate npm tarball + `perf.csv` as workflow artifacts. |
182
182
 
183
183
  A merge to `main` re-runs those two jobs and then chains into `auto-publish` (see [Releases](#releases) below).
184
184
 
@@ -186,9 +186,11 @@ A merge to `main` re-runs those two jobs and then chains into `auto-publish` (se
186
186
 
187
187
  The same batch the regression CI job runs can be invoked locally — see [regression/README.md](regression/README.md) for digest / verbose / batch modes and the catalog of model fixtures. The CI pinned tag is `TEST_MODELS_TAG` near the top of `build.yml`.
188
188
 
189
- ## Performance benchmarks (local only today)
189
+ ## Performance benchmarks
190
190
 
191
- Perf isn't wired into CI yet. The current flow is a local `scripts/performance_test.sh` against a checkout of [headless-three](https://github.com/bldrs-ai/headless-three) cross-linked via `yarn link` against the working tree; see [scripts/README.md](scripts/README.md). Wiring perf into CI is tracked in #314 see [Roadmap](#roadmap).
191
+ **Tier 1 Conway-only perf in CI (live).** Every regression run emits a `perf.csv` of `parseTimeMs / geometryTimeMs / totalTimeMs / rssMb / heapUsedMb / heapTotalMb` per model. The top-10 slowest are posted in the PR comment; the full CSV is uploaded as a workflow artifact. This piggybacks on the existing regression batch so cost is ~0 extra runner minutes.
192
+
193
+ **Tier 2 — full headless-three perf (planned, #314).** A `perf-three` job on `push: main` that runs `scripts/benchmark.cjs` against a clone of [headless-three](https://github.com/bldrs-ai/headless-three), installing the candidate Conway tarball as H3's `@bldrs-ai/conway` dep. Captures PNG renders + per-model timings via H3's rendering server, including a parallel job for `test-models-private`. Tracked in #314.
192
194
 
193
195
 
194
196
  # Releases
@@ -278,27 +280,23 @@ follow-ups to deepen its coverage. Big items first.
278
280
 
279
281
  ### #314 — Performance benchmarks in CI
280
282
 
281
- Today perf only runs locally before a major release, against a
282
- [headless-three](https://github.com/bldrs-ai/headless-three) checkout
283
- cross-linked via `yarn link`. That makes perf regressions easy to miss
284
- and impossible to gate merges on.
285
-
286
- Wiring perf into CI as a third job (`needs: build`, parallel to
287
- `run-ifc-regression`) is the biggest open improvement. The blocker is
288
- deciding how to host the H3 dependency in the runner — three
289
- candidates:
290
-
291
- - **Vendor H3 as a submodule** of conway. Tight coupling but simplest
292
- build chain.
293
- - **Pre-built H3 container image** that the perf job pulls. Looser
294
- coupling, slower runner startup, more infra.
295
- - **A Conway-only perf harness** that bypasses H3 entirely measure
296
- parse + geometry timings directly off the CLI. Less faithful to the
297
- Share end-to-end picture but the easiest to land.
298
-
299
- Once perf runs in CI, the comment posted on each PR should include a
300
- delta vs. main so regressions show up alongside the regression-batch
301
- results.
283
+ **Tier 1 — Conway-only perf (done):** the regression batch now emits a
284
+ per-model `perf.csv` (parse/geometry/total time + RSS/heap MB),
285
+ piggybacking on the existing run so there's no extra runner cost. Top-10
286
+ slowest models appear in each PR comment; the full CSV uploads as an
287
+ artifact. See "Performance benchmarks" above.
288
+
289
+ **Tier 2 full headless-three perf (next):** an end-to-end render-and-time
290
+ job that runs `scripts/benchmark.cjs` against a clone of
291
+ [headless-three](https://github.com/bldrs-ai/headless-three). To avoid the
292
+ local `yarn link` toolchain, the job installs the candidate Conway tarball
293
+ (produced by `run-ifc-regression`) as H3's `@bldrs-ai/conway` dep and pins
294
+ `H3_TAG` in `build.yml`. Gated to `push: main` to keep PR runtime down
295
+ while still exercising every release. A sibling job runs against
296
+ `test-models-private` using `secrets.TEST_MODELS_PRIVATE_TOKEN`, never on
297
+ PR events (so forks can't leak the secret), and uploads results as
298
+ org-scoped artifacts. Delta vs. the previous release lands in the
299
+ auto-publish summary.
302
300
 
303
301
  ### #315 — Bump pinned `TEST_MODELS_TAG`
304
302
 
@@ -69608,7 +69608,7 @@ var IfcStepParser = class extends StepParser {
69608
69608
  IfcStepParser.Instance = new IfcStepParser();
69609
69609
 
69610
69610
  // compiled/src/version/version.js
69611
- var versionString = "Conway Web-Ifc Shim v1.323.1029";
69611
+ var versionString = "Conway Web-Ifc Shim v1.324.1032";
69612
69612
 
69613
69613
  // compiled/src/statistics/statistics.js
69614
69614
  var Statistics = class {
@@ -85869,7 +85869,7 @@ var IfcSceneBuilder = class {
85869
85869
  };
85870
85870
 
85871
85871
  // compiled/src/version/version.js
85872
- var versionString = "Conway Web-Ifc Shim v1.323.1029";
85872
+ var versionString = "Conway Web-Ifc Shim v1.324.1032";
85873
85873
 
85874
85874
  // compiled/src/statistics/statistics.js
85875
85875
  var Statistics = class {
@@ -69606,7 +69606,7 @@ var IfcStepParser = class extends StepParser {
69606
69606
  IfcStepParser.Instance = new IfcStepParser();
69607
69607
 
69608
69608
  // compiled/src/version/version.js
69609
- var versionString = "Conway Web-Ifc Shim v1.323.1029";
69609
+ var versionString = "Conway Web-Ifc Shim v1.324.1032";
69610
69610
 
69611
69611
  // compiled/src/statistics/statistics.js
69612
69612
  var Statistics = class {
@@ -126,6 +126,56 @@ async function runDiff(ifcFolder, outputFolder, target, diffOutputPath, isDryRun
126
126
  await exec(`git checkout -- "${outputFolder}"`, { cwd: ifcFolder });
127
127
  }
128
128
  }
129
+ /**
130
+ * Aggregate all per-file `*.perf.csv` rows in perfDir into a single CSV at
131
+ * outputCsvPath, sorted by file name. Files are written by individual child
132
+ * regression runs (one row + one header per file); we keep the header from
133
+ * the first file we read and concatenate the data rows from the rest. The
134
+ * input directory is removed afterwards.
135
+ *
136
+ * @param perfDir Directory the children wrote their per-file perf CSVs to.
137
+ * @param outputCsvPath Aggregate perf CSV destination.
138
+ */
139
+ async function aggregatePerfCsvs(perfDir, outputCsvPath) {
140
+ const entries = await fsPromises.readdir(perfDir, { withFileTypes: true });
141
+ const perfFiles = entries
142
+ .filter((d) => d.isFile() && d.name.endsWith('.perf.csv'))
143
+ .map((d) => path.join(perfDir, d.name));
144
+ if (perfFiles.length === 0) {
145
+ console.warn(`No per-file perf CSVs found in ${perfDir}; skipping aggregate.`);
146
+ return;
147
+ }
148
+ const rows = [];
149
+ let header;
150
+ for (const perfFile of perfFiles) {
151
+ const contents = await fsPromises.readFile(perfFile, 'utf8');
152
+ const lines = contents.split(/\r?\n/).filter((l) => l.length > 0);
153
+ if (lines.length < 2) {
154
+ continue;
155
+ }
156
+ if (header === undefined) {
157
+ header = lines[0];
158
+ }
159
+ // Each per-file CSV has exactly one data row (lines[1]); the first column
160
+ // is the file name which we use for stable sorting.
161
+ const dataLine = lines[1];
162
+ const firstComma = dataLine.indexOf(',');
163
+ const fileKey = firstComma >= 0 ? dataLine.slice(0, firstComma) : dataLine;
164
+ rows.push({ file: fileKey, line: dataLine });
165
+ }
166
+ rows.sort((a, b) => a.file.localeCompare(b.file));
167
+ // Defensive: if every child wrote a malformed CSV (lines.length < 2 for all),
168
+ // header stays undefined and rows stays empty. Bail rather than write the
169
+ // literal "undefined" as a CSV header.
170
+ if (rows.length === 0 || header === undefined) {
171
+ console.warn(`No usable perf rows in ${perfDir} (${perfFiles.length} file(s) checked); ` +
172
+ `skipping aggregate write.`);
173
+ return;
174
+ }
175
+ const body = rows.map((r) => r.line).join('\n');
176
+ await fsPromises.writeFile(outputCsvPath, `${header}\n${body}\n`);
177
+ console.log(`Wrote aggregate perf CSV: ${outputCsvPath} (${rows.length} rows)`);
178
+ }
129
179
  let totalTime = 0; // To keep track of the running total time
130
180
  /**
131
181
  * Run a regression test digest for a file.
@@ -133,11 +183,13 @@ let totalTime = 0; // To keep track of the running total time
133
183
  * @param filePath
134
184
  * @param outputPath
135
185
  * @param maxTimeout
186
+ * @param perfPath Optional path the child should write a one-row perf CSV to.
136
187
  */
137
- async function runForFile(filePath, outputPath, maxTimeout) {
188
+ async function runForFile(filePath, outputPath, maxTimeout, perfPath) {
138
189
  const MAX_TIMEOUT_MS = maxTimeout;
139
190
  const startTime = Date.now(); // Start time
140
- const safeExecCommand = `node --experimental-specifier-resolution=node ./compiled/src/ifc/ifc_regression_main.js -d "${filePath}" "${outputPath}"`;
191
+ const perfFlag = perfPath ? ` --perf "${perfPath}"` : '';
192
+ const safeExecCommand = `node --experimental-specifier-resolution=node ./compiled/src/ifc/ifc_regression_main.js -d${perfFlag} "${filePath}" "${outputPath}"`;
141
193
  console.log(`Current File: ${filePath}`);
142
194
  // Use safeExecWithCancellation, will kill the process if it takes longer than MAX_TIMEOUT_MS.
143
195
  const process = await safeExecWithCancellation(safeExecCommand, MAX_TIMEOUT_MS);
@@ -235,8 +287,9 @@ function getSystemMemoryUsagePercent() {
235
287
  * @param failedLines
236
288
  * @param memUtilization
237
289
  * @param maxTimeout
290
+ * @param perfDir If set, the child writes its perf CSV here as <basename>.perf.csv.
238
291
  */
239
- async function processIFCFilesInParallel(ifcFiles, outputPath, errorLines, fileLines, failedLines, memUtilization, maxTimeout) {
292
+ async function processIFCFilesInParallel(ifcFiles, outputPath, errorLines, fileLines, failedLines, memUtilization, maxTimeout, perfDir) {
240
293
  const concurrencyLimit = os.cpus().length;
241
294
  console.log(`Concurrency: ${concurrencyLimit} threads - Max Timeout: ${maxTimeout} ms`);
242
295
  const limit = pLimit(concurrencyLimit);
@@ -251,7 +304,10 @@ async function processIFCFilesInParallel(ifcFiles, outputPath, errorLines, fileL
251
304
  }
252
305
  activeTasks++;
253
306
  console.log(`Starting task for "${path.basename(ifcPath)}". Active tasks: ${activeTasks}`);
254
- const fileResults = await runForFile(ifcPath, path.join(outputPath, path.basename(ifcPath, '.ifc')), maxTimeout);
307
+ const perfChildPath = perfDir ?
308
+ path.join(perfDir, `${path.basename(ifcPath, '.ifc')}.perf.csv`) :
309
+ undefined;
310
+ const fileResults = await runForFile(ifcPath, path.join(outputPath, path.basename(ifcPath, '.ifc')), maxTimeout, perfChildPath);
255
311
  activeTasks--;
256
312
  console.log(`Completed task for "${path.basename(ifcPath)}". Active tasks: ${activeTasks}`);
257
313
  return { ifcPath, fileResults };
@@ -286,8 +342,9 @@ async function processIFCFilesInParallel(ifcFiles, outputPath, errorLines, fileL
286
342
  * @param fileLines
287
343
  * @param failedLines
288
344
  * @param maxTimeout
345
+ * @param perfDir If set, the child writes its perf CSV here as <basename>.perf.csv.
289
346
  */
290
- async function recursiveWalk(parentPath, excludeRegex, outputPath, errorLines, fileLines, failedLines, maxTimeout) {
347
+ async function recursiveWalk(parentPath, excludeRegex, outputPath, errorLines, fileLines, failedLines, maxTimeout, perfDir) {
291
348
  const items = await fsPromises.readdir(parentPath, { withFileTypes: true });
292
349
  items.sort((a, b) => (a.name > b.name ? 1 : -1));
293
350
  for (const item of items) {
@@ -296,10 +353,13 @@ async function recursiveWalk(parentPath, excludeRegex, outputPath, errorLines, f
296
353
  continue;
297
354
  }
298
355
  if (item.isDirectory()) {
299
- await recursiveWalk(resolved, excludeRegex, outputPath, errorLines, fileLines, failedLines, maxTimeout);
356
+ await recursiveWalk(resolved, excludeRegex, outputPath, errorLines, fileLines, failedLines, maxTimeout, perfDir);
300
357
  }
301
358
  else if (path.extname(resolved).toLowerCase() === '.ifc') {
302
- const fileResults = await runForFile(resolved, path.join(outputPath, path.basename(resolved, '.ifc')), maxTimeout);
359
+ const perfChildPath = perfDir ?
360
+ path.join(perfDir, `${path.basename(resolved, '.ifc')}.perf.csv`) :
361
+ undefined;
362
+ const fileResults = await runForFile(resolved, path.join(outputPath, path.basename(resolved, '.ifc')), maxTimeout, perfChildPath);
303
363
  if (fileResults.type === 'Run') {
304
364
  if (fileResults.errorLines !== void 0) {
305
365
  errorLines.push(...fileResults.errorLines);
@@ -368,6 +428,13 @@ const args = yargs(process.argv.slice(SKIP_PARAMS))
368
428
  alias: 'timeout',
369
429
  default: 150000,
370
430
  });
431
+ yargs2.option('perf', {
432
+ describe: 'Output path for aggregate perf CSV. Each child writes a one-row ' +
433
+ '<basename>.perf.csv to a temp dir; on completion they are merged ' +
434
+ 'into this file, sorted by file name. Disabled when unset.',
435
+ type: 'string',
436
+ default: '',
437
+ });
371
438
  yargs2.positional('model_folder', {
372
439
  describe: 'Folder containing IFC files, recursively walked',
373
440
  type: 'string',
@@ -388,10 +455,20 @@ const args = yargs(process.argv.slice(SKIP_PARAMS))
388
455
  const doParallel = argv['parallel'] ?? false; // <--- read the parallel flag
389
456
  const memUtilization = argv['mem-utilization'];
390
457
  const maxTimeout = argv['timeout'];
458
+ const perfOutputPath = (argv['perf'] ?? '').trim();
391
459
  if (changes.length === 0) {
392
460
  changes = path.join(outputPath, 'changes');
393
461
  }
394
462
  await fsPromises.mkdir(outputPath, { recursive: true });
463
+ // When perf is requested, children write their one-row CSVs into a
464
+ // throwaway temp dir; we aggregate and clean up afterwards. Keeping
465
+ // them out of outputPath avoids the runDiff step picking up
466
+ // machine-specific timings as "changes" against the test-models
467
+ // checked-in baselines.
468
+ let perfTmpDir;
469
+ if (perfOutputPath.length > 0) {
470
+ perfTmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'conway-perf-'));
471
+ }
395
472
  const mainPath = path.join(outputPath, 'main.csv');
396
473
  const errorPath = path.join(outputPath, 'errors.csv');
397
474
  const failedPath = path.join(outputPath, 'failed.csv');
@@ -404,16 +481,30 @@ const args = yargs(process.argv.slice(SKIP_PARAMS))
404
481
  // 1) Collect all IFC files first
405
482
  const allIFCFiles = await collectIFCFiles(ifcFolder, excludeRegex);
406
483
  // 2) Process them in parallel
407
- await processIFCFilesInParallel(allIFCFiles, outputPath, errorLines, fileLines, failedLines, memUtilization, maxTimeout);
484
+ await processIFCFilesInParallel(allIFCFiles, outputPath, errorLines, fileLines, failedLines, memUtilization, maxTimeout, perfTmpDir);
408
485
  }
409
486
  else {
410
487
  console.log('Processing in serial mode...');
411
- await recursiveWalk(ifcFolder, excludeRegex, outputPath, errorLines, fileLines, failedLines, maxTimeout);
488
+ await recursiveWalk(ifcFolder, excludeRegex, outputPath, errorLines, fileLines, failedLines, maxTimeout, perfTmpDir);
412
489
  }
413
490
  // Write out results
414
491
  await fsPromises.writeFile(mainPath, `file,hash,errors\n${fileLines.join('')}`);
415
492
  await fsPromises.writeFile(errorPath, `${errorCSVHeader}\n${errorLines.join('')}`);
416
493
  await fsPromises.writeFile(failedPath, `file,code,signal\n${failedLines.join('')}`);
494
+ // Aggregate per-child perf rows (if requested) before runDiff so the
495
+ // run completes deterministically even when the git diff step is
496
+ // skipped or fails.
497
+ if (perfTmpDir) {
498
+ try {
499
+ await aggregatePerfCsvs(perfTmpDir, perfOutputPath);
500
+ }
501
+ catch (e) {
502
+ console.error('Failed to aggregate perf CSVs:', e);
503
+ }
504
+ finally {
505
+ await fsPromises.rm(perfTmpDir, { recursive: true, force: true });
506
+ }
507
+ }
417
508
  // If user wants a git diff
418
509
  await runDiff(ifcFolder, outputPath, target, changes, dryRun);
419
510
  // ---- New: log total runtime
@@ -36,6 +36,50 @@ function csvSafeString(from) {
36
36
  }
37
37
  return from;
38
38
  }
39
+ // Bytes per megabyte for memory-stat formatting in the perf CSV.
40
+ // eslint-disable-next-line no-magic-numbers
41
+ const BYTES_PER_MB = 1024 * 1024;
42
+ // Fixed-point precision for perf MB values.
43
+ // eslint-disable-next-line no-magic-numbers
44
+ const PERF_MB_PRECISION = 2;
45
+ /**
46
+ * Write a single-row per-file perf CSV at the given path. Memory snapshot is
47
+ * taken at call time; for the OK path this is right after geometry extraction
48
+ * — close to peak. No-op when perfPath is empty.
49
+ *
50
+ * Memory semantics: `rssMb` includes the conway-geom WASM heap (mmap'd into
51
+ * the process) and is the load-bearing memory metric for geometry work.
52
+ * `heapUsedMb` / `heapTotalMb` are V8-only — useful for tracking JS-side
53
+ * allocation pressure but they exclude WASM-side buffers. Expect a large
54
+ * gap between rss and heap on geometry-heavy models.
55
+ *
56
+ * @param perfPath Path to write the CSV to. Empty string disables.
57
+ * @param ifcFile Source IFC file path (basename used as the row key).
58
+ * @param status OK or FAIL.
59
+ * @param parseTimeMs Parse stage duration in ms.
60
+ * @param geometryTimeMs Geometry extraction duration in ms.
61
+ * @param totalTimeMs Sum of parse + geometry in ms.
62
+ */
63
+ async function writePerfCsvIfRequested(perfPath, ifcFile, status, parseTimeMs, geometryTimeMs, totalTimeMs) {
64
+ if (perfPath.length === 0) {
65
+ return;
66
+ }
67
+ const mem = process.memoryUsage();
68
+ const rssMb = (mem.rss / BYTES_PER_MB).toFixed(PERF_MB_PRECISION);
69
+ const heapUsedMb = (mem.heapUsed / BYTES_PER_MB).toFixed(PERF_MB_PRECISION);
70
+ const heapTotalMb = (mem.heapTotal / BYTES_PER_MB).toFixed(PERF_MB_PRECISION);
71
+ const fileName = csvSafeString(path.basename(ifcFile));
72
+ const header = "file,status,parseTimeMs,geometryTimeMs,totalTimeMs,rssMb,heapUsedMb,heapTotalMb\n";
73
+ const row = `${fileName},${status},${parseTimeMs},${geometryTimeMs},${totalTimeMs},` +
74
+ `${rssMb},${heapUsedMb},${heapTotalMb}\n`;
75
+ try {
76
+ await fsPromises.writeFile(perfPath, header + row);
77
+ }
78
+ catch (e) {
79
+ // Perf is best-effort; never fail the regression run because of a perf write.
80
+ console.error(`Failed to write perf CSV at ${perfPath}:`, e);
81
+ }
82
+ }
39
83
  /**
40
84
  * Display errors and dump errors errors to stderr
41
85
  *
@@ -94,6 +138,12 @@ function doWork() {
94
138
  alias: "v",
95
139
  default: false,
96
140
  });
141
+ yargs2.option("perf", {
142
+ describe: "Write a single-row perf CSV (parse/geometry/total time + memory) at this path",
143
+ type: "string",
144
+ alias: "p",
145
+ default: "",
146
+ });
97
147
  yargs2.positional("filename", { describe: "IFC File Paths", type: "string" });
98
148
  yargs2.positional("output", { describe: "Output path", type: "string" });
99
149
  }, async (argv) => {
@@ -104,6 +154,7 @@ function doWork() {
104
154
  const strict = argv["strict"] ?? false;
105
155
  const digest = argv["digest"] ?? false;
106
156
  const verbose = argv["verbose"] ?? false;
157
+ const perfPath = argv["perf"] ?? "";
107
158
  try {
108
159
  indexIfcBuffer = fs.readFileSync(ifcFile);
109
160
  }
@@ -119,6 +170,7 @@ function doWork() {
119
170
  }
120
171
  const parser = IfcStepParser.Instance;
121
172
  const bufferInput = new ParsingBuffer(indexIfcBuffer);
173
+ const parseStartMs = Date.now();
122
174
  const result0 = parser.parseHeader(bufferInput)[1];
123
175
  switch (result0) {
124
176
  case ParseResult.COMPLETE:
@@ -155,11 +207,20 @@ function doWork() {
155
207
  break;
156
208
  default:
157
209
  }
210
+ const parseEndMs = Date.now();
211
+ const parseTimeMs = parseEndMs - parseStartMs;
158
212
  if (model === void 0) {
213
+ await writePerfCsvIfRequested(perfPath, ifcFile, "FAIL", parseTimeMs, 0, parseTimeMs);
159
214
  return;
160
215
  }
161
216
  model.nullOnErrors = !strict;
217
+ const geomStartMs = Date.now();
162
218
  const result = await geometryExtraction(model);
219
+ const geomEndMs = Date.now();
220
+ const geometryTimeMs = geomEndMs - geomStartMs;
221
+ const totalTimeMs = geomEndMs - parseStartMs;
222
+ const perfStatus = result === void 0 ? "FAIL" : "OK";
223
+ await writePerfCsvIfRequested(perfPath, ifcFile, perfStatus, parseTimeMs, geometryTimeMs, totalTimeMs);
163
224
  if (result === void 0) {
164
225
  Logger.error("Couldn't extract geometry");
165
226
  }
@@ -1,2 +1,2 @@
1
- const versionString = 'Conway Web-Ifc Shim v1.323.1029';
1
+ const versionString = 'Conway Web-Ifc Shim v1.324.1032';
2
2
  export { versionString };