@bldrs-ai/conway 1.536.1507 → 1.543.1513

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.
Files changed (49) hide show
  1. package/compiled/examples/browser-bundled.cjs +39 -5
  2. package/compiled/examples/cli-bundled.cjs +197 -22
  3. package/compiled/examples/cli-step-bundled.cjs +39 -5
  4. package/compiled/examples/validator-bundled.cjs +39 -5
  5. package/compiled/src/compat/web-ifc/geometry_shard_coordination.test.d.ts +2 -0
  6. package/compiled/src/compat/web-ifc/geometry_shard_coordination.test.d.ts.map +1 -0
  7. package/compiled/src/compat/web-ifc/geometry_shard_coordination.test.js +236 -0
  8. package/compiled/src/compat/web-ifc/ifc_api.d.ts +26 -0
  9. package/compiled/src/compat/web-ifc/ifc_api.d.ts.map +1 -1
  10. package/compiled/src/compat/web-ifc/ifc_api.js +33 -0
  11. package/compiled/src/compat/web-ifc/ifc_api_model_passthrough.d.ts +6 -0
  12. package/compiled/src/compat/web-ifc/ifc_api_model_passthrough.d.ts.map +1 -1
  13. package/compiled/src/compat/web-ifc/ifc_api_preview_channel.test.js +264 -1
  14. package/compiled/src/compat/web-ifc/ifc_api_proxy_ifc.d.ts +116 -5
  15. package/compiled/src/compat/web-ifc/ifc_api_proxy_ifc.d.ts.map +1 -1
  16. package/compiled/src/compat/web-ifc/ifc_api_proxy_ifc.js +256 -85
  17. package/compiled/src/compat/web-ifc/store_preview_channel.d.ts +79 -0
  18. package/compiled/src/compat/web-ifc/store_preview_channel.d.ts.map +1 -1
  19. package/compiled/src/compat/web-ifc/store_preview_channel.js +237 -14
  20. package/compiled/src/compat/web-ifc/store_preview_channel.test.js +270 -1
  21. package/compiled/src/compat/web-ifc/streamed_preview_channel.d.ts +106 -1
  22. package/compiled/src/compat/web-ifc/streamed_preview_channel.d.ts.map +1 -1
  23. package/compiled/src/compat/web-ifc/streamed_preview_channel.js +269 -17
  24. package/compiled/src/core/progress_log.d.ts +75 -2
  25. package/compiled/src/core/progress_log.d.ts.map +1 -1
  26. package/compiled/src/core/progress_log.js +59 -2
  27. package/compiled/src/core/progress_log.test.js +75 -1
  28. package/compiled/src/ifc/dangling_placement_error.d.ts +35 -0
  29. package/compiled/src/ifc/dangling_placement_error.d.ts.map +1 -0
  30. package/compiled/src/ifc/dangling_placement_error.js +38 -0
  31. package/compiled/src/ifc/geometry_dispatch.d.ts +74 -3
  32. package/compiled/src/ifc/geometry_dispatch.d.ts.map +1 -1
  33. package/compiled/src/ifc/geometry_dispatch.js +261 -5
  34. package/compiled/src/ifc/geometry_dispatch.test.js +147 -1
  35. package/compiled/src/ifc/ifc_geometry_extraction.d.ts +54 -0
  36. package/compiled/src/ifc/ifc_geometry_extraction.d.ts.map +1 -1
  37. package/compiled/src/ifc/ifc_geometry_extraction.js +153 -20
  38. package/compiled/src/index.d.ts +1 -1
  39. package/compiled/src/index.d.ts.map +1 -1
  40. package/compiled/src/index.js +1 -1
  41. package/compiled/src/step/dangling_reference_error.d.ts +34 -0
  42. package/compiled/src/step/dangling_reference_error.d.ts.map +1 -0
  43. package/compiled/src/step/dangling_reference_error.js +36 -0
  44. package/compiled/src/step/step_entity_base.d.ts +17 -0
  45. package/compiled/src/step/step_entity_base.d.ts.map +1 -1
  46. package/compiled/src/step/step_entity_base.js +28 -4
  47. package/compiled/src/version/version.js +1 -1
  48. package/compiled/tsconfig.tsbuildinfo +1 -1
  49. package/package.json +1 -1
@@ -7,6 +7,9 @@ import { WindowedStepBufferProvider } from "../../step/step_buffer_provider.js";
7
7
  import * as glmatrix from "gl-matrix";
8
8
  import { composeTransformF64, deriveCoordinationF64, IDENTITY_MAT4, NORMALIZE_MAT_F64, } from "./coordination_f64.js";
9
9
  import { emitSpatialStructureImposters } from "./spatial_imposter.js";
10
+ import { DanglingPlacementError } from "../../ifc/dangling_placement_error.js";
11
+ import { formatPreviewLine } from "../../core/progress_log.js";
12
+ import Logger from "../../logging/logger.js";
10
13
  import { releaseModelGeometry, } from "./streamed_preview_channel.js";
11
14
  /* eslint-disable no-magic-numbers */
12
15
  const TICK_INTERVAL_MS = 150;
@@ -24,13 +27,53 @@ const TICK_BUDGET_MS = 20;
24
27
  * Products a single tick may attempt, whatever the clock says.
25
28
  *
26
29
  * `deferDanglingPlacements` (Share#1744) defers any product whose
27
- * placement records the prefix does not hold yet, and Revit writes
28
- * per-product placements toward the file tail — so early ticks meet long
29
- * runs of products that cannot extract. Each such attempt still pages
30
- * source through the windowed provider, so a run of them is not free;
31
- * this caps the run rather than letting the deadline be the only bound.
30
+ * placement records the prefix does not hold yet, and some exporters
31
+ * write the tail of a placement chain toward the tail of the file — so
32
+ * early ticks meet long runs of products that cannot extract. Each such
33
+ * attempt still pages source through the windowed provider, so a run of
34
+ * them is not free; this caps the run rather than letting the deadline
35
+ * be the only bound.
36
+ *
37
+ * Measured across the corpus in conway#542, and note it is the WRITER
38
+ * that decides record order, not the authoring tool: files written by
39
+ * the ODA IFC SDK and Tekla's exporter are strictly backward-
40
+ * referencing and never hit this path, while DDS_IFC (Archicad's IFC
41
+ * add-on) and ST-DEVELOPER defer 100% of products. On the Archicad
42
+ * files the last record in a chain to arrive is always a LEAF —
43
+ * IfcDirection or IfcCartesianPoint, not the placement and not the
44
+ * geometry — which is why a head-only prefix cannot fix it however far
45
+ * it grows.
46
+ *
47
+ * An earlier version of this comment blamed Revit. The one
48
+ * Revit-authored file in that corpus is the BEST case measured (first
49
+ * preview mesh at 269 ms, zero deferrals) — though it was written out
50
+ * through the ODA SDK, so it says nothing about Revit's own exporter
51
+ * either way. The 398 MB Archicad file is the worst: 531 of 532
52
+ * attempts deferred, first mesh at 98.3% of the file, 8.9 s of blank
53
+ * screen.
32
54
  */
33
55
  const TICK_MAX_ATTEMPTS = 32;
56
+ /**
57
+ * Deferred products carried into the next generation for a second look.
58
+ *
59
+ * The channel used to be strictly forward-only: a product that deferred was
60
+ * attempted once, at the earliest — and therefore most index-starved —
61
+ * generation it appeared in, and never again, because `unitOrdinal_` only
62
+ * ever advances and a rebuilt generation regenerates `products` in the same
63
+ * dense parse order. On a file whose placement chains resolve late that
64
+ * throws away nearly everything: DOWA has 19,854 of its 45,860 products
65
+ * emittable once 70% of the file is indexed, and emitted 1, because all of
66
+ * them had been attempted and discarded before the index reached their
67
+ * leaves (conway#542).
68
+ *
69
+ * A retry is affordable in a way a first attempt is not: a deferring
70
+ * attempt costs ~0.17 ms — the whole 532-attempt run on DOWA is 93 ms,
71
+ * against 2.1 s for that load's three generation rebuilds — because it
72
+ * fails on an index lookup before any geometry is paged. The cap is here
73
+ * because the queue must not grow with the model; it only has to keep
74
+ * DEFAULT_MAX_PREVIEW_UNITS worth of candidates in play.
75
+ */
76
+ const RETRY_QUEUE_MAX = 4096;
34
77
  const FIRST_GENERATION_MIN_RECORDS = 1024;
35
78
  const GENERATION_GROWTH_FACTOR = 2;
36
79
  const DEFAULT_MAX_PREVIEW_UNITS = 512;
@@ -75,6 +118,36 @@ export class StorePreviewChannel {
75
118
  this.firstGenerationMinRecords = firstGenerationMinRecords;
76
119
  this.stopped_ = false;
77
120
  this.emittedUnits_ = 0;
121
+ /**
122
+ * Preview meshes handed to `onMesh_`. Tracked apart from
123
+ * {@link emittedUnits_} because the two genuinely diverge: a product can
124
+ * place several meshes, and a product with no Representation extracts
125
+ * cleanly while placing none — `extractProductGeometry` returns early on
126
+ * a null Representation and `extractProductGeometryByLocalID` still
127
+ * answers true. Reporting only units let the load log say "no mesh, 20
128
+ * emitted" about one load (codex round 2 on #543).
129
+ */
130
+ this.meshesEmitted_ = 0;
131
+ /* Products a tick attempted but could not extract, and how many of those
132
+ * were specifically waiting on a placement chain the prefix does not hold
133
+ * yet. Reported at the end of the preview so a blank first load is
134
+ * attributable: "9 812 of 9 840 attempts deferred, 9 810 on placements" is
135
+ * a file-layout problem that a sharded parse addresses, and anything else
136
+ * is not (conway#542). */
137
+ this.deferredUnits_ = 0;
138
+ this.deferredOnPlacement_ = 0;
139
+ /**
140
+ * Products that deferred under the CURRENT generation, promoted to
141
+ * {@link retryQueue_} when the next one is built. Held separately so a
142
+ * retry always runs against a longer index than the attempt that failed —
143
+ * re-running one inside its own generation would fail identically and
144
+ * spin.
145
+ */
146
+ this.deferredForRetry_ = [];
147
+ this.retryQueue_ = [];
148
+ this.retryCursor_ = 0;
149
+ this.retriedUnits_ = 0;
150
+ this.startedMs_ = Date.now();
78
151
  this.emittedBytes_ = 0;
79
152
  this.unitOrdinal_ = 0;
80
153
  this.lastInlineTick_ = 0;
@@ -105,6 +178,37 @@ export class StorePreviewChannel {
105
178
  get emittedUnits() {
106
179
  return this.emittedUnits_;
107
180
  }
181
+ /**
182
+ * What the preview delivered, and how fast.
183
+ *
184
+ * `firstMeshMs` is time-to-first-pixel measured from channel
185
+ * construction, undefined when no placed product was ever emitted.
186
+ * `meshes` counts what actually reached the consumer, `emitted` counts
187
+ * the products extraction accepted — they are not the same number in
188
+ * either direction, so the line reports both.
189
+ * `deferred` counts products a tick attempted and could not extract;
190
+ * `deferredOnPlacement` is the subset still waiting on a placement chain
191
+ * the prefix does not hold; `retried` counts attempts that were second
192
+ * looks at an earlier deferral rather than first attempts, so a channel
193
+ * whose retry path has silently stopped firing is visible rather than
194
+ * merely slower. A first load that shows nothing is a very
195
+ * different problem depending on that ratio — near 1.0 is the file-layout
196
+ * case a sharded parse attacks, anything else is not — and inferring it
197
+ * from a code comment is what conway#542 exists to stop.
198
+ *
199
+ * @return {object} `{firstMeshMs, meshes, emitted, deferred,
200
+ * deferredOnPlacement, retried}`
201
+ */
202
+ get previewYield() {
203
+ return {
204
+ firstMeshMs: this.firstMeshMs_,
205
+ meshes: this.meshesEmitted_,
206
+ emitted: this.emittedUnits_,
207
+ deferred: this.deferredUnits_,
208
+ deferredOnPlacement: this.deferredOnPlacement_,
209
+ retried: this.retriedUnits_,
210
+ };
211
+ }
108
212
  /** Products the current generation would run (test seam). */
109
213
  get productCount() {
110
214
  return this.generation_?.products.length ?? 0;
@@ -205,6 +309,25 @@ export class StorePreviewChannel {
205
309
  stop() {
206
310
  this.stopped_ = true;
207
311
  this.disposeGeneration_();
312
+ // Say what the preview delivered, and how fast, on the way out. The
313
+ // channel is a local of the open call — nothing outside holds it — so a
314
+ // counter that is not reported here is a counter nobody can read. And a
315
+ // first load that showed the user nothing is exactly the case where this
316
+ // line is the whole diagnosis: a deferral ratio near 1.0 ON PLACEMENTS
317
+ // is the file-layout problem, and anything else is a different problem
318
+ // wearing the same blank screen (conway#542). Rendered by the shared
319
+ // load-log formatter, so the resident path's line and this one are the
320
+ // same line.
321
+ //
322
+ // Unconditional, not gated on emittedUnits_ or deferredUnits_ being
323
+ // nonzero: a channel that never reached firstGenerationMinRecords, or
324
+ // whose every generation build threw, is precisely the worst-case
325
+ // blank-first-load this issue exists to make diagnosable — and
326
+ // formatPreviewLine already renders that as "no mesh, 0 emitted, 0
327
+ // deferred". Suppressing the line there made an enabled preview that
328
+ // produced nothing indistinguishable from a preview that never ran
329
+ // (codex round 1 on #543).
330
+ Logger.info(formatPreviewLine(this.previewYield));
208
331
  }
209
332
  /**
210
333
  * Test seam: drain until the current sink is exhausted or a cap hits.
@@ -232,10 +355,19 @@ export class StorePreviewChannel {
232
355
  return false;
233
356
  }
234
357
  const active = this.generation_;
235
- if (this.unitOrdinal_ >= active.products.length) {
358
+ if (!StorePreviewChannel.hasPending_(this.retryCursor_, this.retryQueue_.length, this.unitOrdinal_, active.products.length)) {
236
359
  return false;
237
360
  }
238
- const localID = active.products[this.unitOrdinal_++];
361
+ // Retries first: they are the products the index has most recently
362
+ // become able to place, and they are cheaper than a fresh attempt
363
+ // whichever way they go.
364
+ const isRetry = this.retryCursor_ < this.retryQueue_.length;
365
+ const localID = isRetry ?
366
+ this.retryQueue_[this.retryCursor_++] :
367
+ active.products[this.unitOrdinal_++];
368
+ if (isRetry) {
369
+ ++this.retriedUnits_;
370
+ }
239
371
  const model = active.model;
240
372
  const pins = new Set();
241
373
  const leafSpans = [];
@@ -246,8 +378,22 @@ export class StorePreviewChannel {
246
378
  ++this.emittedUnits_;
247
379
  }
248
380
  }
249
- catch {
250
- // Forward-ref / unplaced — durable pump extracts later.
381
+ catch (error) {
382
+ // Forward-ref / unplaced — durable pump extracts later. Counted by
383
+ // cause: a preview that stays blank because every product's placement
384
+ // is still ahead of the prefix wants a different fix from one blank
385
+ // for any other reason, and only this split can tell them apart
386
+ // (conway#542).
387
+ ++this.deferredUnits_;
388
+ if (error instanceof DanglingPlacementError) {
389
+ ++this.deferredOnPlacement_;
390
+ // Only placement deferrals are worth another look. Any other throw
391
+ // is a property of the product rather than of how much index exists,
392
+ // so a longer prefix does not change the answer.
393
+ if (this.deferredForRetry_.length < RETRY_QUEUE_MAX) {
394
+ this.deferredForRetry_.push(localID);
395
+ }
396
+ }
251
397
  }
252
398
  finally {
253
399
  model.releaseSourceViews(pins);
@@ -258,15 +404,62 @@ export class StorePreviewChannel {
258
404
  }
259
405
  return true;
260
406
  }
407
+ /**
408
+ * Whether either queue still has a product to attempt.
409
+ *
410
+ * Static and fully parameterised so the two callers — the tick and the
411
+ * generation gate — cannot drift apart on what "pending" means, which is
412
+ * the bug that would silently strand the retry queue.
413
+ *
414
+ * @param retryCursor How far into the retry queue this generation is.
415
+ * @param retryLength The retry queue's length.
416
+ * @param ordinal The forward cursor into the generation's products.
417
+ * @param productCount That generation's product count.
418
+ * @return {boolean} True when something is left to attempt.
419
+ */
420
+ static hasPending_(retryCursor, retryLength, ordinal, productCount) {
421
+ return retryCursor < retryLength || ordinal < productCount;
422
+ }
261
423
  /**
262
424
  * @return {Promise<boolean>} True when a generation with pending units exists.
263
425
  */
264
426
  async ensureGeneration_() {
265
427
  const active = this.generation_;
266
- if (active !== void 0 && this.unitOrdinal_ < active.products.length) {
428
+ const records = this.sink_.topLevelCount;
429
+ // Rebuild on INDEX GROWTH, not on running out of products. The order of
430
+ // these two tests is the whole behaviour of the channel on a large file.
431
+ //
432
+ // It used to keep the current generation for as long as that generation
433
+ // still had an unattempted product, and only then consider rebuilding.
434
+ // A generation snapshotted at FIRST_GENERATION_MIN_RECORDS already lists
435
+ // thousands of products, and the tick attempts at most
436
+ // TICK_MAX_ATTEMPTS of them per cadence interval, so on anything large
437
+ // the list never emptied and the rebuild never fired: DOWA spent all 9 s
438
+ // of its parse extracting against a snapshot of its first 1024 records,
439
+ // which is why 100% of its attempts deferred and its first mesh landed
440
+ // at 99.2% of the file (conway#542). The growth gate below was written
441
+ // to bound rebuild cost and instead became unreachable.
442
+ //
443
+ // Preempting makes generations arrive on the index's schedule —
444
+ // O(log records) of them, GENERATION_GROWTH_FACTOR apart — and the
445
+ // products the old generation had not reached carry over untouched,
446
+ // because `unitOrdinal_` indexes a dense parse-order list that a longer
447
+ // snapshot only extends (see ColumnarIndexSink.snapshot).
448
+ // Eligibility to rebuild at all — the original growth gate, unchanged.
449
+ const growthReady = records >= this.lastSnapshotRecords_ * GENERATION_GROWTH_FACTOR &&
450
+ records >= this.lastFailedSnapshotRecords_ * GENERATION_GROWTH_FACTOR;
451
+ // Whether to rebuild EARLY, without waiting for the current generation's
452
+ // products to run out. Only worth it for a generation that is actually
453
+ // deferring: a longer index cannot help one whose products extract fine,
454
+ // and preempting anyway costs the case that already works best — on PSB,
455
+ // which defers nothing, unconditional preemption pushed the first mesh
456
+ // from 269 ms out to 495 ms. Deferrals waiting for retry are precisely
457
+ // the evidence that more index is the missing ingredient.
458
+ const preempt = active !== void 0 && growthReady &&
459
+ this.deferredForRetry_.length > 0;
460
+ if (!preempt && active !== void 0 && StorePreviewChannel.hasPending_(this.retryCursor_, this.retryQueue_.length, this.unitOrdinal_, active.products.length)) {
267
461
  return true;
268
462
  }
269
- const records = this.sink_.topLevelCount;
270
463
  if (records < this.firstGenerationMinRecords) {
271
464
  return false;
272
465
  }
@@ -274,6 +467,10 @@ export class StorePreviewChannel {
274
467
  records < this.lastSnapshotRecords_ * GENERATION_GROWTH_FACTOR) {
275
468
  return false;
276
469
  }
470
+ // Unconditional, unlike the snapshot gate above: a prefix whose build
471
+ // THREW leaves no active generation, so folding this into `growthReady`
472
+ // and testing it only when one exists would let a structurally
473
+ // incomplete prefix re-snapshot and re-throw on every tick.
277
474
  if (records < this.lastFailedSnapshotRecords_ * GENERATION_GROWTH_FACTOR) {
278
475
  return false;
279
476
  }
@@ -289,7 +486,11 @@ export class StorePreviewChannel {
289
486
  this.lastFailReason = `no products in ${records} records`;
290
487
  return false;
291
488
  }
292
- if (products.length <= this.unitOrdinal_) {
489
+ // A generation that adds no new products is still worth building when
490
+ // deferred ones are waiting: what changed is the INDEX behind them,
491
+ // which is the only reason a retry could now succeed.
492
+ if (products.length <= this.unitOrdinal_ &&
493
+ this.deferredForRetry_.length === 0) {
293
494
  this.lastFailReason = `ordinal ${this.unitOrdinal_} >= ${products.length}`;
294
495
  return false;
295
496
  }
@@ -305,6 +506,21 @@ export class StorePreviewChannel {
305
506
  model.unpinLocalIDs(prepPins);
306
507
  }
307
508
  this.disposeGeneration_();
509
+ // Carry the OLD queue's unconsumed suffix forward, not just this
510
+ // generation's fresh deferrals. Preemption can fire mid-drain of
511
+ // retryQueue_ — bounded per-tick attempts (TICK_MAX_ATTEMPTS) against
512
+ // an index that grew enough to trigger a rebuild before the queue
513
+ // emptied — and every one of those un-popped entries is a product
514
+ // whose ordinal unitOrdinal_ has already passed, so replacing the
515
+ // queue outright (rather than concatenating) would strand them
516
+ // exactly the way abandoned deferrals were stranded before this fix
517
+ // existed (conway#542, codex round 1 on #543).
518
+ this.retryQueue_ = [
519
+ ...this.retryQueue_.slice(this.retryCursor_),
520
+ ...this.deferredForRetry_,
521
+ ].slice(0, RETRY_QUEUE_MAX);
522
+ this.retryCursor_ = 0;
523
+ this.deferredForRetry_ = [];
308
524
  this.generation_ = {
309
525
  model,
310
526
  extraction,
@@ -447,15 +663,18 @@ export class StorePreviewChannel {
447
663
  /**
448
664
  * Copy newly extracted instances out of wasm — same placement math
449
665
  * as {@link StreamedPreviewChannel}.
666
+ *
667
+ * @return {number} Meshes emitted by this pass.
450
668
  */
451
669
  captureNewInstances_() {
452
670
  const active = this.generation_;
453
671
  if (active === void 0) {
454
- return;
672
+ return 0;
455
673
  }
456
674
  const { extraction, capturedCounts } = active;
457
675
  const scene = extraction.scene;
458
676
  const seenThisPass = new Map();
677
+ let emitted = 0;
459
678
  for (const walked of scene.walk()) {
460
679
  const [, nativeTransform, geometry, material, entity] = walked;
461
680
  if (entity?.localID === void 0 || entity.expressID === void 0) {
@@ -515,10 +734,14 @@ export class StorePreviewChannel {
515
734
  this.emittedBytes_ +=
516
735
  (vertexData.length + indexData.length) * BYTES_PER_FLOAT;
517
736
  }
737
+ this.firstMeshMs_ ??= Date.now() - this.startedMs_;
518
738
  this.onMesh_(payload);
739
+ ++this.meshesEmitted_;
740
+ ++emitted;
519
741
  if (this.emittedBytes_ >= this.maxBytes) {
520
- return;
742
+ return emitted;
521
743
  }
522
744
  }
745
+ return emitted;
523
746
  }
524
747
  }
@@ -1,8 +1,9 @@
1
1
  /* eslint-disable no-magic-numbers */
2
2
  import * as fs from "fs";
3
- import { beforeAll, describe, expect, test } from "@jest/globals";
3
+ import { beforeAll, describe, expect, jest, test } from "@jest/globals";
4
4
  import { ConwayGeometry } from "../../../dependencies/conway-geom/index.js";
5
5
  import IfcStepParser from "../../ifc/ifc_step_parser.js";
6
+ import Logger from "../../logging/logger.js";
6
7
  import { BufferByteSource } from "../../step/parsing/byte_source.js";
7
8
  import { ColumnarIndexSink } from "../../step/parsing/columnar_index.js";
8
9
  import { ParseResult } from "../../step/parsing/step_parser.js";
@@ -73,6 +74,147 @@ describe("StorePreviewChannel", () => {
73
74
  expect(attempted).toBeLessThanOrEqual(internals.tickMaxAttempts_);
74
75
  channel.stop();
75
76
  });
77
+ test("a tail-placement file recovers its deferred products", async () => {
78
+ // data/index_tail_placements.ifc is index_georeferenced_multicell.ifc
79
+ // with every leaf point/direction record moved to the end of DATA --
80
+ // same ids, same text, hostile order. It is the Archicad shape at
81
+ // fixture scale: its products reference a placement chain that bottoms
82
+ // out after them, so an index stopping short of the tail cannot place
83
+ // any of them (conway#542). The multicell fixture rather than
84
+ // index.ifc because it carries enough products that a few ticks cannot
85
+ // exhaust the generation -- which is what leaves the preemption path,
86
+ // not the exhaustion path, as the only way to rebuild.
87
+ const tailText = fs.readFileSync("data/index_tail_placements.ifc", "utf8");
88
+ const lines = tailText.split("\n");
89
+ const firstLeaf = lines.findIndex((line) => /=\s*IFC(CARTESIANPOINT|DIRECTION)/.test(line));
90
+ expect(firstLeaf).toBeGreaterThan(0);
91
+ // A genuine prefix, closed off so it parses: every record up to the
92
+ // leaf block, and nothing after it. The full file is stage two.
93
+ const prefixText = `${lines.slice(0, firstLeaf).join("\n")}\nENDSEC;\nEND-ISO-10303-21;\n`;
94
+ const prefixBytes = new Uint8Array(Buffer.from(prefixText, "latin1"));
95
+ const tailBytes = new Uint8Array(Buffer.from(tailText, "latin1"));
96
+ const store = new InMemoryStepByteStore(tailBytes);
97
+ const sink = new ColumnarIndexSink();
98
+ const channel = new StorePreviewChannel(store, sink, conwayGeometry, true, () => { }, 64, 48 * 1024 * 1024, 1);
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ const internals = channel;
101
+ // One product per tick, so stage one leaves products UNATTEMPTED. That
102
+ // is what forces the preemption path: with products still pending, a
103
+ // channel that only rebuilds on exhaustion keeps its starved generation
104
+ // and never looks at the deferrals again.
105
+ internals.tickBudgetMs_ = Number.MAX_SAFE_INTEGER;
106
+ internals.tickMaxAttempts_ = 1;
107
+ // Stage one: index only the prefix, then let the channel work it.
108
+ expect(buildIndexStreaming(new BufferByteSource(prefixBytes), IfcStepParser.Instance, 4 * 1024, void 0, sink).result).toBe(ParseResult.COMPLETE);
109
+ internals.lastInlineTick_ = 0;
110
+ await channel.maybeTickAsync();
111
+ const afterPrefix = channel.previewYield;
112
+ // The fixture has to actually exercise the path, or the rest proves
113
+ // nothing about it -- and products must be left over, or the rebuild
114
+ // would happen by exhaustion and prove nothing about preemption.
115
+ expect(afterPrefix.deferredOnPlacement).toBeGreaterThan(0);
116
+ expect(afterPrefix.emitted).toBe(0);
117
+ expect(internals.unitOrdinal_).toBeLessThan(channel.productCount);
118
+ // Stage two: the same reset-and-replay the streaming builder itself
119
+ // performs when it grows its window, so top-level localIDs stay in
120
+ // dense parse order and the deferred ids the channel is holding still
121
+ // mean what they meant (see ColumnarIndexSink.reset).
122
+ sink.reset();
123
+ expect(buildIndexStreaming(new BufferByteSource(tailBytes), IfcStepParser.Instance, 4 * 1024, void 0, sink).result).toBe(ParseResult.COMPLETE);
124
+ // Exactly one tick, and one attempt inside it. That is what makes this
125
+ // an assertion about PREEMPTION: with products still pending, a channel
126
+ // that rebuilds only on exhaustion spends this tick on the next fresh
127
+ // product against the old starved generation, and the retry never
128
+ // happens.
129
+ internals.lastInlineTick_ = 0;
130
+ await channel.maybeTickAsync();
131
+ const afterFull = channel.previewYield;
132
+ // Both halves of the fix, and `retried` is what separates them from a
133
+ // channel that merely got lucky on a later product. Without the retry
134
+ // queue a deferred product is attempted once, at the most
135
+ // index-starved generation it appears in, and abandoned. Without
136
+ // preemptive rebuilds the generation is kept while products remain, so
137
+ // the deferrals are never revisited against the longer index. Either
138
+ // one missing holds this at zero.
139
+ expect(afterFull.retried).toBeGreaterThan(0);
140
+ expect(afterFull.emitted).toBeGreaterThan(0);
141
+ channel.stop();
142
+ }, 120000);
143
+ test("a generation is preempted only when it is deferring", async () => {
144
+ // The counterweight. Rebuilding on index growth alone regressed the
145
+ // case that already worked: PSB, which defers nothing, went from a
146
+ // first mesh at 269ms to 495ms because it kept paying for rebuilds it
147
+ // had no use for. index.ifc is that shape at fixture scale, so the
148
+ // same two-stage growth must leave its generation alone.
149
+ const store = new InMemoryStepByteStore(bytes);
150
+ const sink = new ColumnarIndexSink();
151
+ const channel = new StorePreviewChannel(store, sink, conwayGeometry, true, () => { }, 64, 48 * 1024 * 1024, 1);
152
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
153
+ const internals = channel;
154
+ // One product per tick, but with the wall clock lifted -- a zero
155
+ // budget would stop the tick before it ever built a generation.
156
+ internals.tickBudgetMs_ = Number.MAX_SAFE_INTEGER;
157
+ internals.tickMaxAttempts_ = 1;
158
+ expect(buildIndexStreaming(new BufferByteSource(bytes), IfcStepParser.Instance, 4 * 1024, void 0, sink).result).toBe(ParseResult.COMPLETE);
159
+ internals.lastInlineTick_ = 0;
160
+ await channel.maybeTickAsync();
161
+ const snapshotRecords = internals.lastSnapshotRecords_;
162
+ expect(snapshotRecords).toBeGreaterThan(0);
163
+ expect(channel.previewYield.deferredOnPlacement).toBe(0);
164
+ // Index growth well past GENERATION_GROWTH_FACTOR, with products still
165
+ // pending and nothing deferring: the generation must be kept.
166
+ sink.reset();
167
+ for (let repeat = 0; repeat < 3; ++repeat) {
168
+ expect(buildIndexStreaming(new BufferByteSource(bytes), IfcStepParser.Instance, 4 * 1024, void 0, sink).result).toBe(ParseResult.COMPLETE);
169
+ }
170
+ expect(sink.topLevelCount).toBeGreaterThan(snapshotRecords * 2);
171
+ internals.lastInlineTick_ = 0;
172
+ await channel.maybeTickAsync();
173
+ expect(internals.lastSnapshotRecords_).toBe(snapshotRecords);
174
+ expect(channel.previewYield.retried).toBe(0);
175
+ channel.stop();
176
+ }, 120000);
177
+ test("a throwing prefix build retires the attempt, not the channel", async () => {
178
+ // A mid-parse prefix can be structurally incomplete, and building a
179
+ // generation over it throws. That must retire THIS attempt and then
180
+ // wait for index growth: without the wait the channel re-snapshots and
181
+ // re-throws on every tick, which is a snapshot copy of the whole prefix
182
+ // per tick on a file large enough to matter.
183
+ //
184
+ // The gate has to hold with NO active generation, because a build that
185
+ // throws is exactly the case that leaves none — that is why it is
186
+ // tested on its own rather than folded in with the growth gate.
187
+ const store = new InMemoryStepByteStore(bytes);
188
+ const sink = new ColumnarIndexSink();
189
+ expect(buildIndexStreaming(new BufferByteSource(bytes), IfcStepParser.Instance, 4 * 1024, void 0, sink).result).toBe(ParseResult.COMPLETE);
190
+ let reportedRecords = sink.topLevelCount;
191
+ let snapshots = 0;
192
+ const flakySink = {
193
+ get topLevelCount() {
194
+ return reportedRecords;
195
+ },
196
+ snapshot: () => {
197
+ if (++snapshots === 1) {
198
+ throw new Error("structurally incomplete prefix");
199
+ }
200
+ return sink.snapshot();
201
+ },
202
+ };
203
+ const payloads = [];
204
+ const channel = new StorePreviewChannel(store, flakySink, conwayGeometry, true, (mesh) => payloads.push(mesh), 64, 48 * 1024 * 1024, 1);
205
+ await channel.drainForTest();
206
+ expect(snapshots).toBe(1);
207
+ expect(payloads.length).toBe(0);
208
+ // Same record count: the failure gate must hold the retry.
209
+ await channel.drainForTest();
210
+ expect(snapshots).toBe(1);
211
+ // The index grows past the gate: the retry builds and emits.
212
+ reportedRecords *= 4;
213
+ await channel.drainForTest();
214
+ expect(snapshots).toBe(2);
215
+ expect(payloads.length).toBeGreaterThan(0);
216
+ channel.stop();
217
+ }, 120000);
76
218
  test("an unproductive tick does not decay the cadence", async () => {
77
219
  const sink = new ColumnarIndexSink();
78
220
  // Nothing parsed into the sink, so no generation can build. Every
@@ -231,4 +373,131 @@ describe("StorePreviewChannel", () => {
231
373
  expect(spatial[0].color).toEqual({ x: 0, y: 0, z: 0, w: 0.3 });
232
374
  api.CloseModel(id);
233
375
  });
376
+ test("preemption preserves the unconsumed suffix of the retry queue", async () => {
377
+ // Codex round 1 on #543: preemption replaced retryQueue_ outright with
378
+ // this generation's fresh deferredForRetry_, discarding whatever the
379
+ // OLD queue's tail had not yet been popped. A retry queue can easily
380
+ // span more than one tick's TICK_MAX_ATTEMPTS budget, so index growth
381
+ // can preempt a generation mid-drain of it — and every un-popped entry
382
+ // is a product whose unitOrdinal_ has already passed, so replacing
383
+ // rather than concatenating strands it exactly the way abandoned
384
+ // deferrals were stranded before this queue existed (conway#542).
385
+ //
386
+ // The bug is purely mechanical (which array wins the assignment), so
387
+ // this seeds the private queue state directly and forces the growth
388
+ // gate via lastSnapshotRecords_, rather than engineering a real
389
+ // multi-tick deferral sequence — store_preview_channel.ts's extraction
390
+ // seam isn't pluggable the way the resident channel's
391
+ // PreviewSchemaAdapter is, so a real gen1/gen2 pair here means real
392
+ // IfcStepModel/IfcGeometryExtraction builds regardless.
393
+ const store = new InMemoryStepByteStore(bytes);
394
+ const sink = new ColumnarIndexSink();
395
+ expect(buildIndexStreaming(new BufferByteSource(bytes), IfcStepParser.Instance, 4 * 1024, void 0, sink).result).toBe(ParseResult.COMPLETE);
396
+ const channel = new StorePreviewChannel(store, sink, conwayGeometry, true, () => { }, 64, 48 * 1024 * 1024, 1);
397
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
398
+ const internals = channel;
399
+ // Generation 1: a real, successful build (active must be defined for
400
+ // the preemption branch to even be reachable).
401
+ expect(await internals.ensureGeneration_()).toBe(true);
402
+ // A retry queue spanning more than one tick's budget: 6 queued
403
+ // retries, only the first 3 consumed (retryCursor_ short of
404
+ // retryQueue_.length is exactly "mid-drain") before the index grew
405
+ // again. deferredForRetry_ is this generation's OWN fresh deferral —
406
+ // the only thing the buggy version kept.
407
+ internals.retryQueue_ = [10, 11, 12, 13, 14, 15];
408
+ internals.retryCursor_ = 3;
409
+ internals.deferredForRetry_ = [99];
410
+ // Force the growth gate trivially open on the next check, without
411
+ // needing the sink to actually grow: the gate is a ratio against
412
+ // lastSnapshotRecords_, not an absolute count.
413
+ internals.lastSnapshotRecords_ = 1;
414
+ // Preemption fires: active is defined, growthReady (real topLevelCount
415
+ // >= 1 * factor), and deferredForRetry_.length > 0.
416
+ expect(await internals.ensureGeneration_()).toBe(true);
417
+ // The unconsumed suffix of the old queue (indices 3..5) must survive,
418
+ // carried ahead of this generation's own fresh deferral — not replaced
419
+ // by it.
420
+ expect(internals.retryQueue_).toEqual([13, 14, 15, 99]);
421
+ expect(internals.retryCursor_).toBe(0);
422
+ channel.stop();
423
+ }, 120000);
424
+ test("a permanently malformed placement never enters the retry queue", async () => {
425
+ // Codex round 1 on #543: extractPlacementStrict_ tagged EVERY throw out
426
+ // of the strict placement read as DanglingPlacementError, so a placement
427
+ // that is broken rather than unscanned was classified as a file-layout
428
+ // deferral. That is not a cosmetic mislabel: the tag is what puts a
429
+ // product in deferredForRetry_, and a non-empty deferredForRetry_ is
430
+ // also the trigger for early generation PREEMPTION — so one malformed
431
+ // placement bought a full model/extraction rebuild on every growth step
432
+ // for a retry that could never come good.
433
+ //
434
+ // Two products, two causes, one file, so the assertion is about the
435
+ // SPLIT rather than about either case alone:
436
+ //
437
+ // #334 RelativePlacement -> #345, an IFCPOLYGONALFACESET. Indexed
438
+ // and present, but the field is an IfcAxis2Placement select, so
439
+ // IfcLocalPlacement's generated getter rejects it. No amount of
440
+ // further index changes that answer.
441
+ // #408 Location -> #999999, which no record in the file defines.
442
+ // Absent from the index, which on a prefix is exactly "not
443
+ // scanned yet" — the case that must still defer and retry.
444
+ const text = fs.readFileSync("data/index.ifc", "utf8");
445
+ expect(text).toContain("#334= IFCLOCALPLACEMENT(#152,#333);");
446
+ expect(text).toContain("#408= IFCAXIS2PLACEMENT3D(#406,#404,#402);");
447
+ expect(text).toContain("#345= IFCPOLYGONALFACESET(");
448
+ expect(text).not.toContain("#999999=");
449
+ const brokenText = text
450
+ .replace("#334= IFCLOCALPLACEMENT(#152,#333);", "#334= IFCLOCALPLACEMENT(#152,#345);")
451
+ .replace("#408= IFCAXIS2PLACEMENT3D(#406,#404,#402);", "#408= IFCAXIS2PLACEMENT3D(#999999,#404,#402);");
452
+ const brokenBytes = new Uint8Array(Buffer.from(brokenText, "latin1"));
453
+ const store = new InMemoryStepByteStore(brokenBytes);
454
+ const sink = new ColumnarIndexSink();
455
+ // The COMPLETE index, deliberately: it takes prefix-vs-tail out of the
456
+ // picture, so the only thing left to explain a deferral is the cause of
457
+ // the throw. It also means no generation is ever rebuilt (the record
458
+ // count cannot grow past the growth factor again), so the retry queue
459
+ // this inspects is the one the tick built and never drained.
460
+ expect(buildIndexStreaming(new BufferByteSource(brokenBytes), IfcStepParser.Instance, 4 * 1024, void 0, sink).result).toBe(ParseResult.COMPLETE);
461
+ const channel = new StorePreviewChannel(store, sink, conwayGeometry, true, () => { }, 64, 48 * 1024 * 1024, 1);
462
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
463
+ const internals = channel;
464
+ await channel.drainForTest();
465
+ const observed = channel.previewYield;
466
+ // Both products fail to extract — the split is in how they are counted,
467
+ // and in which of them is worth another attempt.
468
+ expect(observed.deferred).toBe(2);
469
+ expect(observed.deferredOnPlacement).toBe(1);
470
+ expect(internals.deferredForRetry_).toHaveLength(1);
471
+ // And the rest of the file is unaffected: these two are the only
472
+ // products that lost their geometry.
473
+ expect(observed.emitted).toBe(channel.productCount - 2);
474
+ channel.stop();
475
+ }, 120000);
476
+ test("reports a preview line even when nothing was ever attempted", () => {
477
+ // Codex round 1 on #543: stop() suppressed the Preview line unless
478
+ // emittedUnits_ or deferredUnits_ was nonzero. A channel that never
479
+ // reached firstGenerationMinRecords -- or whose every generation build
480
+ // threw -- leaves both at zero, and that is exactly the worst-case
481
+ // blank-first-load conway#542 exists to make diagnosable: an enabled
482
+ // preview that produced nothing must not read the same as a preview
483
+ // that never ran. formatPreviewLine already renders the zero case as
484
+ // "no mesh, 0 meshes from 0 units, 0 deferred".
485
+ const store = new InMemoryStepByteStore(bytes);
486
+ const emptySink = {
487
+ get topLevelCount() {
488
+ return 0;
489
+ },
490
+ snapshot: () => {
491
+ throw new Error("must not be called: topLevelCount never grows");
492
+ },
493
+ };
494
+ const infoSpy = jest.spyOn(Logger, "info").mockImplementation(() => { });
495
+ // Default firstGenerationMinRecords (1024): with topLevelCount pinned
496
+ // at 0, ensureGeneration_ never builds a generation, so the channel
497
+ // reaches stop() having attempted nothing at all.
498
+ const channel = new StorePreviewChannel(store, emptySink, conwayGeometry, false, () => { });
499
+ channel.stop();
500
+ expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("Preview: no mesh, 0 meshes from 0 units, 0 deferred"));
501
+ infoSpy.mockRestore();
502
+ });
234
503
  });