ifc-lite-processing 6.0.1

Shared IFC processing pipeline and types used by server and FFI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

//! The extraction-level output cap and its diagnostics (#2937, #2938).
//!
//! `item_walk.rs` bounds ONE top-level representation item.
//! `extract_symbolic_data` calls that walk once per item of every
//! Plan/Annotation/FootPrint/Axis representation of every product and
//! accumulates into a single `SymbolicData`, so before this the file-level
//! total was `items x per-item bound` and nothing bounded the extraction:
//! 20,002,500 polylines and 2.74 GB RSS from a 1.13 MB upload, on a path the
//! HTTP server calls with raw uploaded bytes.
//!
//! EVERY test below was verified to FAIL against the production behaviour it
//! names, by running the mutation rather than reasoning about it. That check
//! found two tests that could not fail:
//!
//!   - the "every variable-length field is charged" test used a 2-byte content
//!     with a 4 KB alignment, so dropping the CONTENT charge changed nothing
//!     and it stayed green while claiming to pin both;
//!   - the "does not abandon the file" test asserted `len() > 1`, which passes
//!     even when a per-item bound wrongly sets exhaustion and abandons every
//!     later product.
//!
//! Both are now built to discriminate. This is the fourth and fifth time in
//! this change that a fixture written to prove a fix shared the fix's
//! assumption, so the mutation run is not optional here.
//!
//! RESIDUAL, measured and not fixed here: the bounds are on OUTPUT, so they
//! bound work only insofar as work produces output. A fan-out whose leaf is an
//! item type the extractor does not handle traverses fully and emits nothing,
//! so neither bound ever fires:
//!
//!   179 KB -> 426 ms, 0 emitted      739 KB -> 1661 ms, 0 emitted
//!   3.0 MB -> 7216 ms, 0 emitted
//!
//! Linear at ~2.4 ms/KB. Not a regression -- `main` behaved identically, and
//! it is #2937's original per-item-budget complaint in the shape where output
//! capping cannot reach it.
//!
//! CLOSED HERE (#3114): the revisit budget is now hoisted from per-item to
//! per-extraction, which is safe BECAUSE truncation is reported. `seen` stays
//! PER ITEM -- the two halves are separate on purpose, and both are pinned:
//! `re_placing_one_library_block_is_not_charged_as_a_revisit` is the `seen`
//! half, and hoisting `seen` too leaves every other test in the crate green.
//!
//! The two issues pulled in opposite directions -- bounding total work made
//! the silent truncation fire sooner, on smaller legitimate drawings -- so
//! they are fixed by one instrument: a cap on the OUTPUT, plus a diagnostics
//! field that says the cap was hit. Both halves are pinned here.
//!
use super::output_cap::{SymbolicAccumulator, SymbolicTruncationReason};
use super::primitives::SymbolicData;

/// Test-only constructors and the refusal counter, kept HERE rather than in
/// `output_cap.rs`.
///
/// They are `#[cfg(test)]` either way, so this changes nothing that ships. It
/// moves them because the module-size ratchet counts lines, not compiled code:
/// hoisting the revisit budget onto the accumulator took `output_cap.rs` to
/// 411, and test scaffolding is what should give way before production logic
/// or the measured DoS documentation the bounds carry. `*_tests.rs` is exempt
/// from that gate by name, which is exactly what the exemption is for.
///
/// The cost is that four fields widen from module-private to `pub(super)`.
/// That leaves `data` private, which is the one the accumulator's own doc
/// comment relies on — during extraction the vectors stay reachable only
/// through `push_*`, so the seam it describes is still real.
impl SymbolicAccumulator {
    /// Accumulator under caller-chosen bounds. Both are injectable so a test
    /// can exercise either bound without building a fixture that reaches the
    /// shipped ones.
    pub(super) fn with_limits(limit: usize, byte_limit: usize) -> Self {
        let mut acc = Self::new();
        acc.limit = limit;
        acc.byte_limit = byte_limit;
        acc
    }

    /// Accumulator under a caller-chosen count cap and the shipped byte cap.
    pub(super) fn with_limit(limit: usize) -> Self {
        let mut acc = Self::new();
        acc.limit = limit;
        acc
    }

    /// Accumulator with a small injected revisit budget (skips 200,000 real revisits).
    pub(super) fn with_revisit_budget(revisit_budget: u32) -> Self {
        let mut acc = Self::new();
        acc.revisit_budget = revisit_budget;
        acc
    }

    /// Refused appends since the last reset.
    ///
    /// Exists so the early exits can be pinned deterministically. They bound
    /// WORK, and work is invisible in the output -- a refused append leaves the
    /// result byte-identical -- but it is visible HERE, and the accumulator is
    /// already test-injectable. Claiming this was unpinnable was wrong.
    pub(super) fn refusals(&self) -> usize {
        self.refusals
    }
}

/// N annotations, each carrying one 24-level fan-out DAG. No cycle, so no path
/// guard fires; the leaf is reachable down 2^24 paths and only a work bound
/// stops it. This is the shape that measured 2.73 GB.
fn hostile_dag(annotations: usize) -> String {
    hostile_dag_with_leaf(annotations, 2)
}

/// Same shape, with a caller-chosen number of points in the single leaf
/// polyline. That leaf is re-emitted up to the cap, so its size multiplies.
fn hostile_dag_with_leaf(annotations: usize, leaf_points: usize) -> String {
    let mut s = String::from("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n");
    let mut id = 1000u32;
    let mut tops = Vec::new();
    for _ in 0..annotations {
        let mut next = 0u32;
        for level in 0..24 {
            let rm = id;
            id += 1;
            let r = id;
            id += 1;
            if level == 0 {
                let pl = id;
                id += 1;
                let p1 = id;
                id += 1;
                let p2 = id;
                id += 1;
                s.push_str(&format!("#{r}=IFCSHAPEREPRESENTATION($,$,$,(#{pl}));\n"));
                let pts: Vec<u32> = (0..leaf_points.max(2))
                    .map(|_| {
                        let q = id;
                        id += 1;
                        q
                    })
                    .collect();
                let refs = pts.iter().map(|q| format!("#{q}")).collect::<Vec<_>>().join(",");
                s.push_str(&format!("#{pl}=IFCPOLYLINE(({refs}));\n"));
                for (k, q) in pts.iter().enumerate() {
                    s.push_str(&format!("#{q}=IFCCARTESIANPOINT(({k}.,{k}.));\n"));
                }
                let _ = (p1, p2);
            } else {
                let a = id;
                id += 1;
                let b = id;
                id += 1;
                s.push_str(&format!("#{r}=IFCSHAPEREPRESENTATION($,$,$,(#{a},#{b}));\n"));
                s.push_str(&format!("#{a}=IFCMAPPEDITEM(#{next},$);\n"));
                s.push_str(&format!("#{b}=IFCMAPPEDITEM(#{next},$);\n"));
            }
            s.push_str(&format!("#{rm}=IFCREPRESENTATIONMAP($,#{r});\n"));
            next = rm;
        }
        let top = id;
        id += 1;
        s.push_str(&format!("#{top}=IFCMAPPEDITEM(#{next},$);\n"));
        tops.push(top);
    }
    let list = tops.iter().map(|t| format!("#{t}")).collect::<Vec<_>>().join(",");
    let prod = id;
    let shp = id + 1;
    let rep = id + 2;
    s.push_str(&format!(
        "#{prod}=IFCANNOTATION('x',$,$,$,$,$,#{shp});\n\
         #{shp}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{rep}));\n\
         #{rep}=IFCSHAPEREPRESENTATION($,'Annotation','Annotation',({list}));\n"
    ));
    s.push_str("ENDSEC;\nEND-ISO-10303-21;\n");
    s
}

#[test]
fn a_hostile_file_is_bounded_and_says_so() {
    // Cap injected at 500 rather than 2,000,000: the mechanism is identical and
    // the fixture stays small. One annotation of this shape emits ~66,675
    // primitives uncapped, so 500 is comfortably exceeded.
    let mut out = SymbolicAccumulator::with_limit(500);
    super::extract_symbolic_data_into(&hostile_dag(2).into_bytes(), &mut out);
    let out = out.into_data();

    assert_eq!(
        out.len(),
        500,
        "the cap must bound the TOTAL across every collection, not each one"
    );
    let truncation = out
        .truncated
        .as_ref()
        .expect("a truncated extraction must say so: silence here is #2938 verbatim");
    assert_eq!(truncation.limit, Some(500));
    assert_eq!(
        truncation.reason,
        SymbolicTruncationReason::ElementCount,
        "the diagnostic must name WHICH bound fired, not merely that one did"
    );
    assert_eq!(
        truncation.emitted, 500,
        "`emitted` must be the count at the moment extraction stopped"
    );
}

#[test]
fn a_file_that_fits_is_not_marked_truncated() {
    // The control. Without it the assertions above pass on an implementation
    // that marks EVERY extraction truncated, which would make the diagnostic
    // worthless in the direction that matters -- a consumer would show
    // "results incomplete" on every drawing and learn to ignore it.
    let mut out = SymbolicAccumulator::with_limit(500);
    super::extract_symbolic_data_into(&hostile_dag(0).into_bytes(), &mut out);
    let out = out.into_data();
    assert!(
        out.truncated.is_none(),
        "an extraction that never reached the cap must not report truncation"
    );
    assert!(out.truncated.is_none());
}

#[test]
fn truncation_survives_the_wire_and_absence_still_deserializes() {
    // `apps/server/src/routes/parse/cache_keys.rs` reads cached JSON written
    // before this field existed, so absence must deserialize; and an
    // untruncated response must serialize exactly as it did before, or every
    // cache key in flight changes.
    let mut acc = SymbolicAccumulator::with_limit(1);
    super::extract_symbolic_data_into(&hostile_dag(1).into_bytes(), &mut acc);
    let truncated = acc.into_data();
    assert!(truncated.truncated.is_some(), "fixture must actually truncate");

    let json = serde_json::to_string(&truncated).expect("serializes");
    let back: SymbolicData = serde_json::from_str(&json).expect("round-trips");
    assert_eq!(back.truncated, truncated.truncated);

    let clean = SymbolicData::default();
    let clean_json = serde_json::to_string(&clean).expect("serializes");
    assert!(
        !clean_json.contains("truncated"),
        "an untruncated result must not gain a field on the wire: {clean_json}"
    );
    let old_shape: SymbolicData =
        serde_json::from_str(r#"{"grid_axes":[],"polylines":[],"circles":[],"texts":[],"fills":[]}"#)
            .expect("JSON cached before this field existed must still deserialize");
    assert!(old_shape.truncated.is_none());
}

#[test]
fn a_few_enormous_primitives_are_bounded_too() {
    // The attack a COUNT-only cap misses, and the reason this bound is in
    // bytes. Per-primitive size is attacker-controlled -- neither
    // `SymbolicPolyline.points` nor `SymbolicText.content` has a length limit
    // anywhere in the extractor -- and the fan-out re-emits ONE leaf up to the
    // cap, cloning its point vector every time. So the leaf is paid for once
    // in the file and N times in RAM.
    //
    // Measured against a count-only cap of 2,000,000, leaf size the only knob:
    //
    //   leaf pts   fixture     emitted     peak RSS
    //          2   0.15 MB   2,000,000       278 MB
    //        512   1.07 MB   2,000,000      8.47 GB
    //       1024   2.03 MB   2,000,000     16.70 GB
    //
    // Six times worse than the 2.74 GB the count cap was written to fix. A
    // count cap tuned on a 2-point fixture measures the fixture, not the bound.
    //
    // Injected budgets keep this fast: 4 KiB of output with a 400-point leaf,
    // so the COUNT bound (500) is nowhere near and only the BYTE bound can stop
    // it. If the byte charge is ever removed, this test is what fails.
    let mut acc = SymbolicAccumulator::with_limits(500, 4096);
    super::extract_symbolic_data_into(&hostile_dag_with_leaf(2, 400).into_bytes(), &mut acc);
    let out = acc.into_data();

    assert!(
        out.truncated.is_some(),
        "a file of few but enormous primitives must still be bounded"
    );
    assert!(
        out.len() < 500,
        "the BYTE bound must bite before the count bound: emitted {} of a 500 count cap, \
         so this stopped for the wrong reason and the byte charge is not working",
        out.len()
    );
    let emitted_payload: usize = out.polylines.iter().map(|p| p.points.len()).sum();
    assert!(
        emitted_payload * 8 <= 4096 + 400 * 8,
        "total emitted payload must respect the byte budget; got {emitted_payload} coords"
    );
}

/// A fan-out whose leaf is a TEXT literal with a large `BoxAlignment`.
///
/// `alignment` is read from the file with no length bound and cloned on every
/// emission. Charging only `content` made the byte bound a 13.5x under-count:
/// 800,100 texts charged 54.9 MB while the process held 3.45 GB and
/// `truncated` stayed None.
fn hostile_text_dag(content_len: usize, alignment_len: usize) -> String {
    let pad = "A".repeat(alignment_len);
    let body = "B".repeat(content_len.max(2));
    let mut s = String::from("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n");
    let mut id = 1000u32;
    let mut next = 0u32;
    for level in 0..12 {
        let rm = id;
        id += 1;
        let r = id;
        id += 1;
        if level == 0 {
            let tl = id;
            id += 1;
            let pt = id;
            id += 1;
            s.push_str(&format!("#{r}=IFCSHAPEREPRESENTATION($,$,$,(#{tl}));\n"));
            s.push_str(&format!(
                "#{tl}=IFCTEXTLITERALWITHEXTENT('{body}',#{pt},.RIGHT.,$,'{pad}');\n"
            ));
            s.push_str(&format!("#{pt}=IFCAXIS2PLACEMENT2D(#{},$);\n", pt + 1));
            s.push_str(&format!("#{}=IFCCARTESIANPOINT((0.,0.));\n", pt + 1));
            id += 2;
        } else {
            let a = id;
            id += 1;
            let b = id;
            id += 1;
            s.push_str(&format!("#{r}=IFCSHAPEREPRESENTATION($,$,$,(#{a},#{b}));\n"));
            s.push_str(&format!("#{a}=IFCMAPPEDITEM(#{next},$);\n"));
            s.push_str(&format!("#{b}=IFCMAPPEDITEM(#{next},$);\n"));
        }
        s.push_str(&format!("#{rm}=IFCREPRESENTATIONMAP($,#{r});\n"));
        next = rm;
    }
    let top = id;
    id += 1;
    s.push_str(&format!("#{top}=IFCMAPPEDITEM(#{next},$);\n"));
    let prod = id;
    let shp = id + 1;
    let rep = id + 2;
    s.push_str(&format!(
        "#{prod}=IFCANNOTATION('x',$,$,$,$,$,#{shp});\n\
         #{shp}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{rep}));\n\
         #{rep}=IFCSHAPEREPRESENTATION($,'Annotation','Annotation',(#{top}));\n"
    ));
    s.push_str("ENDSEC;\nEND-ISO-10303-21;\n");
    s
}

#[test]
fn every_variable_length_field_is_charged_not_just_the_obvious_one() {
    // The blind spot that shipped past one review. The previous cap charged
    // `points` for polylines and the test that "proved" the byte bound used a
    // POLYLINE leaf -- so the oracle shared the fix's assumption, and a TEXT
    // leaf with a huge `alignment` walked straight through a bound that
    // reported itself as holding.
    //
    // Byte budget deliberately tiny and count budget huge, so ONLY the byte
    // charge can stop this. If `alignment` stops being charged, the run blows
    // past the byte budget and this fails.
    // BOTH directions, because charging only one field still passes a fixture
    // that only makes the other one large. An earlier version used a 2-byte
    // content with a 4 KB alignment, so dropping the `content` charge changed
    // nothing and the test stayed green while claiming to pin "every"
    // variable-length field.
    for (content_len, alignment_len, which) in
        [(2usize, 4096usize, "alignment"), (4096, 2, "content")]
    {
        let mut acc = SymbolicAccumulator::with_limits(100_000, 8192);
        super::extract_symbolic_data_into(
            &hostile_text_dag(content_len, alignment_len).into_bytes(),
            &mut acc,
        );
        let out = acc.into_data();

        let truncation = out
            .truncated
            .as_ref()
            .unwrap_or_else(|| panic!("a {which}-heavy fan-out must be bounded and reported"));
        assert_eq!(
            truncation.reason,
            SymbolicTruncationReason::OutputBytes,
            "the BYTE bound must fire for a {which}-heavy file; ElementCount here \
             means {which} bytes are going uncharged"
        );
        let charged: usize =
            out.texts.iter().map(|t| t.content.len() + t.alignment.len()).sum();
        assert!(
            charged * 8 <= 8192 + 4096 * 8,
            "{which}-heavy payload must respect the byte budget; got {charged}"
        );
    }
}

#[test]
fn a_per_item_bound_reports_its_own_reason_and_does_not_abandon_the_file() {
    // #2938's LEAD case in miniature: content lost to a PER-ITEM bound while
    // the file-level totals sit far below the extraction bounds. Reporting
    // only the extraction bounds would say nothing here, which is exactly the
    // silence the issue is about.
    //
    // Also pins the separation that a first attempt got wrong: a per-item
    // bound marks the result truncated but must NOT set exhaustion, or one
    // deep item abandons every remaining product in the file.
    let mut acc = SymbolicAccumulator::with_limits(10_000_000, 64 * 1024 * 1024);
    super::extract_symbolic_data_into(&hostile_dag(2).into_bytes(), &mut acc);
    let out = acc.into_data();

    let truncation = out
        .truncated
        .as_ref()
        .expect("a per-item bound drops content and must say so");
    assert_eq!(truncation.reason, SymbolicTruncationReason::ItemRevisits);
    assert_eq!(
        truncation.limit, None,
        "a per-item bound has no file-level limit to compare `emitted` against"
    );
    // One product of this fixture emits exactly 66,675 primitives (measured),
    // so exceeding that is what proves the SECOND product was reached. The
    // previous assertion was `> 1`, which passes even when a per-item bound
    // wrongly sets exhaustion and abandons everything after the first item --
    // it could not fail in the direction it was pointed.
    assert!(
        out.len() > 66_675,
        "the rest of the file must still be extracted: a per-item bound is not \
         exhaustion, and treating it as such abandons every later product. \
         Got {} primitives, which is one product's worth or less",
        out.len()
    );
}

#[test]
fn the_early_exits_stop_the_walk_and_not_only_the_appends() {
    // The gap an earlier version of this file RECORDED as unpinnable, wrongly.
    // The early exits bound WORK, and work is invisible in the output -- a
    // refused append leaves the result byte-identical -- so the claim was that
    // only timing could observe them, and timing would be flaky.
    //
    // It is observable, deterministically, one level in: refused appends. With
    // the exits, the walk stops as soon as the accumulator is full and refusals
    // stay small. Without them it keeps traversing and every would-be append is
    // refused, so the count explodes. The accumulator was already
    // test-injectable; the observable was there all along.
    let mut acc = SymbolicAccumulator::with_limits(500, 64 * 1024 * 1024);
    super::extract_symbolic_data_into(&hostile_dag(2).into_bytes(), &mut acc);
    let refusals = acc.refusals();

    assert!(acc.is_exhausted(), "fixture must reach the cap");
    assert!(
        refusals < 5_000,
        "the walk must STOP once the accumulator is full, not keep traversing \
         and discarding: {refusals} refused appends means the early exits are \
         gone and the work is unbounded again"
    );
}

/// A mapped-item chain deeper than `MAX_ITEM_DEPTH`, emitting almost nothing.
/// Cheap way to make a per-item bound fire FIRST in scan order.
fn deep_chain_then(rest: &str, start: u32) -> String {
    let mut s = String::new();
    let mut id = start;
    let mut next = 0u32;
    for level in 0..40 {
        let rm = id;
        id += 1;
        let r = id;
        id += 1;
        if level == 0 {
            let pl = id;
            id += 1;
            s.push_str(&format!("#{r}=IFCSHAPEREPRESENTATION($,$,$,(#{pl}));\n"));
            s.push_str(&format!("#{pl}=IFCPOLYLINE((#{},#{}));\n", pl + 1, pl + 2));
            s.push_str(&format!("#{}=IFCCARTESIANPOINT((0.,0.));\n", pl + 1));
            s.push_str(&format!("#{}=IFCCARTESIANPOINT((1.,1.));\n", pl + 2));
            id += 2;
        } else {
            let a = id;
            id += 1;
            s.push_str(&format!("#{r}=IFCSHAPEREPRESENTATION($,$,$,(#{a}));\n"));
            s.push_str(&format!("#{a}=IFCMAPPEDITEM(#{next},$);\n"));
        }
        s.push_str(&format!("#{rm}=IFCREPRESENTATIONMAP($,#{r});\n"));
        next = rm;
    }
    let top = id;
    id += 1;
    s.push_str(&format!("#{top}=IFCMAPPEDITEM(#{next},$);\n"));
    let prod = id;
    s.push_str(&format!(
        "#{prod}=IFCANNOTATION('deep',$,$,$,$,$,#{});\n\
         #{}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{}));\n\
         #{}=IFCSHAPEREPRESENTATION($,'Annotation','Annotation',(#{top}));\n",
        prod + 1, prod + 1, prod + 2, prod + 2
    ));
    format!("{s}{rest}")
}

#[test]
fn an_extraction_bound_outranks_a_per_item_one_whatever_the_scan_order() {
    // The collision the sibling test is scoped to avoid, and which first-wins
    // got wrong. A per-item bound fires on an early product; the whole-output
    // cap fires later. Scan order is attacker-controlled, so first-wins let a
    // file that blew the DoS-scale ceiling report the mild `item-revisits`
    // with its numeric limit dropped.
    //
    // Bounds chosen so BOTH fire: the fan-out exhausts a per-item revisit
    // budget early, and 200 elements is low enough that the extraction cap
    // follows.
    // The deep chain (per-item ItemDepth) is scanned BEFORE the fan-out that
    // blows the element cap, so first-wins records the milder reason.
    let dag = hostile_dag(2);
    let body = dag
        .strip_prefix("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n")
        .expect("fixture prefix");
    let file = format!(
        "ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n{}",
        deep_chain_then(body, 500_000)
    );
    let mut acc = SymbolicAccumulator::with_limits(200, 64 * 1024 * 1024);
    super::extract_symbolic_data_into(&file.into_bytes(), &mut acc);
    let out = acc.into_data();

    let truncation = out.truncated.as_ref().expect("must be truncated");
    assert_eq!(
        truncation.reason,
        SymbolicTruncationReason::ElementCount,
        "the whole-output cap must outrank a per-item bound that happened to \
         fire earlier in scan order; reporting the milder reason understates \
         the most severe truncation there is"
    );
    assert_eq!(
        truncation.limit,
        Some(200),
        "and its numeric limit must survive: a per-item reason carries None, \
         so mislabelling also silently drops the number"
    );
}

#[test]
fn a_per_item_reason_omits_limit_on_the_wire_rather_than_sending_null() {
    // The TypeScript mirror in `packages/server-client/src/types.ts` declares
    // `limit?: number`, which means the key is ABSENT. `Option<usize>` with a
    // plain `Serialize` emits `"limit": null` instead, which type-checks on
    // both sides and still breaks a consumer that asks `'limit' in truncated`
    // before rendering "showing {emitted} of {limit}".
    //
    // Asserting `truncation.limit == None` (as the sibling test does) cannot
    // catch this: the Rust value is None either way. Only the serialized shape
    // distinguishes them, so this reads the JSON.
    let mut acc = SymbolicAccumulator::with_limits(10_000_000, 64 * 1024 * 1024);
    super::extract_symbolic_data_into(&hostile_dag(2).into_bytes(), &mut acc);
    let out = acc.into_data();

    let json = serde_json::to_value(&out).expect("serializes");
    let truncated = &json["truncated"];
    assert_eq!(truncated["reason"], "item-revisits");
    assert!(
        !truncated
            .as_object()
            .expect("truncated is an object")
            .contains_key("limit"),
        "a per-item reason must OMIT `limit`, not emit null: {truncated}"
    );

    // The other direction, so the fix cannot be "always skip": an extraction
    // bound still has to put its number on the wire.
    let mut acc = SymbolicAccumulator::with_limits(200, 64 * 1024 * 1024);
    super::extract_symbolic_data_into(&hostile_dag(2).into_bytes(), &mut acc);
    let json = serde_json::to_value(acc.into_data()).expect("serializes");
    assert_eq!(json["truncated"]["reason"], "element-count");
    assert_eq!(json["truncated"]["limit"], 200);
}

/// N top-level items, each a flat `IfcGeometricCurveSet` of `leaves` DISTINCT
/// polylines -- no shared ids anywhere, so no id is ever visited twice and no
/// revisit is ever charged, however many items or leaves there are. Companion
/// to `hostile_dag`, which is the opposite shape (few ids, deliberately
/// revisited); this one exists to prove the shared revisit pool does NOT
/// start charging FIRST visits just because they belong to a later item.
/// One curve set, PLACED SEVERAL TIMES as separate top-level items.
///
/// The distinction `flat_multi_item_dag` cannot express: there every top-level
/// item owns private entities, so no id is ever reached twice and the walk's
/// `seen` set is irrelevant to the outcome. Here every placement re-traverses
/// the SAME ids, which is what an ordinary library block looks like in a real
/// file -- and is the only shape that can tell a per-item `seen` from an
/// extraction-wide one.
fn shared_map_multi_placement(placements: usize, leaves: usize) -> String {
    let mut s = String::from("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n");
    let mut id = 1000u32;
    let set = id;
    id += 1;
    let mut elems = Vec::new();
    for _ in 0..leaves {
        let pl = id;
        id += 1;
        let p1 = id;
        id += 1;
        let p2 = id;
        id += 1;
        s.push_str(&format!("#{pl}=IFCPOLYLINE((#{p1},#{p2}));\n"));
        s.push_str(&format!("#{p1}=IFCCARTESIANPOINT((0.,0.));\n"));
        s.push_str(&format!("#{p2}=IFCCARTESIANPOINT((1.,1.));\n"));
        elems.push(pl);
    }
    let refs = elems.iter().map(|q| format!("#{q}")).collect::<Vec<_>>().join(",");
    s.push_str(&format!("#{set}=IFCGEOMETRICCURVESET(({refs}));\n"));

    // The SAME set id, repeated -- N placements of one block.
    let list = (0..placements).map(|_| format!("#{set}")).collect::<Vec<_>>().join(",");
    let prod = id;
    let shp = id + 1;
    let rep = id + 2;
    s.push_str(&format!(
        "#{prod}=IFCANNOTATION('x',$,$,$,$,$,#{shp});\n\
         #{shp}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{rep}));\n\
         #{rep}=IFCSHAPEREPRESENTATION($,'Annotation','Annotation',({list}));\n"
    ));
    s.push_str("ENDSEC;\nEND-ISO-10303-21;\n");
    s
}

fn flat_multi_item_dag(top_items: usize, leaves: usize) -> String {
    let mut s = String::from("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n");
    let mut id = 1000u32;
    let mut tops = Vec::new();
    for _ in 0..top_items {
        let set = id;
        id += 1;
        let mut elems = Vec::new();
        for _ in 0..leaves {
            let pl = id;
            id += 1;
            let p1 = id;
            id += 1;
            let p2 = id;
            id += 1;
            s.push_str(&format!("#{pl}=IFCPOLYLINE((#{p1},#{p2}));\n"));
            s.push_str(&format!("#{p1}=IFCCARTESIANPOINT((0.,0.));\n"));
            s.push_str(&format!("#{p2}=IFCCARTESIANPOINT((1.,1.));\n"));
            elems.push(pl);
        }
        let refs = elems.iter().map(|q| format!("#{q}")).collect::<Vec<_>>().join(",");
        s.push_str(&format!("#{set}=IFCGEOMETRICCURVESET(({refs}));\n"));
        tops.push(set);
    }
    let list = tops.iter().map(|t| format!("#{t}")).collect::<Vec<_>>().join(",");
    let prod = id;
    let shp = id + 1;
    let rep = id + 2;
    s.push_str(&format!(
        "#{prod}=IFCANNOTATION('x',$,$,$,$,$,#{shp});\n\
         #{shp}=IFCPRODUCTDEFINITIONSHAPE($,$,(#{rep}));\n\
         #{rep}=IFCSHAPEREPRESENTATION($,'Annotation','Annotation',({list}));\n"
    ));
    s.push_str("ENDSEC;\nEND-ISO-10303-21;\n");
    s
}

#[test]
fn the_revisit_budget_is_shared_once_across_the_whole_extraction_not_reset_per_item() {
    // #2937: `extract_symbolic_item` builds a fresh `ItemWalk` for every
    // top-level item, and before this fix that walk owned its OWN revisit
    // budget, initialised to the full constant on every call. N items shared
    // nothing, so the bound was `N x MAX_ITEM_REVISITS` -- it scaled with item
    // count instead of bounding the file.
    //
    // `hostile_dag(k)` places k independent 24-level fan-out DAGs as k
    // top-level items of ONE shape representation; each one alone can exhaust
    // a revisit budget many times over. That is the shape that tells "one
    // pool per item" apart from "one pool for the file": if the second item
    // gets its own fresh pool, it contributes roughly as much as the first;
    // if the pool is a single shared one and the first item already spent it,
    // the second item can barely contribute at all.
    const BUDGET: u32 = 50;

    let mut one = SymbolicAccumulator::with_revisit_budget(BUDGET);
    super::extract_symbolic_data_into(&hostile_dag(1).into_bytes(), &mut one);
    let one_data = one.into_data();
    let one_item_total = one_data.len();
    assert_eq!(
        one_data.truncated.as_ref().map(|t| t.reason),
        Some(SymbolicTruncationReason::ItemRevisits),
        "a budget of {BUDGET} must be nowhere near enough for a single 24-level \
         fan-out DAG, so this item alone must already be truncated: {:?}",
        one_data.truncated
    );

    let mut two = SymbolicAccumulator::with_revisit_budget(BUDGET);
    super::extract_symbolic_data_into(&hostile_dag(2).into_bytes(), &mut two);
    let two_data = two.into_data();
    let two_item_total = two_data.len();
    assert_eq!(
        two_data.truncated.as_ref().map(|t| t.reason),
        Some(SymbolicTruncationReason::ItemRevisits),
        "the second item must also end up truncated once the shared pool is \
         spent: {:?}",
        two_data.truncated
    );

    // The fixture has to emit SOMETHING or every bound below is vacuous.
    assert!(
        one_item_total > 0,
        "one item emitted nothing, so the comparison that follows would hold \
         for any implementation"
    );

    // Relative to the one-item total, not an absolute allowance. An earlier
    // version of this assertion read `two_item_total <= one_item_total + 200`,
    // which could not fail: the measured totals are 20 and 21, and a reverted
    // per-item budget would give roughly 40 -- comfortably inside a +200
    // slack. The bound has to be a MULTIPLE of the one-item total, because the
    // defect doubles that total rather than adding a constant to it.
    assert!(
        two_item_total < 2 * one_item_total,
        "a SECOND top-level item sharing the same tiny revisit budget as the \
         first must contribute almost nothing once the first item has spent \
         the pool -- got {one_item_total} primitives for one item and \
         {two_item_total} for two. Approaching twice the one-item total is \
         what a per-item {BUDGET}-revisit budget looks like, which is the \
         regression this test exists to catch"
    );
}

#[test]
fn re_placing_one_library_block_is_not_charged_as_a_revisit() {
    // The invariant this PR rests on is a PAIR: the revisit budget is
    // extraction-wide, while `seen` stays PER ITEM. The budget half is pinned
    // by the two tests below. This is the `seen` half, and without it the
    // whole crate stays green when `seen` is hoisted onto the accumulator
    // alongside the budget -- measured, not assumed: 0 failures across every
    // test binary under exactly that mutation.
    //
    // `flat_multi_item_dag` cannot catch it by construction. Its top-level
    // items own DISTINCT entities, so no id is ever reached twice and the
    // scope of `seen` never changes the answer. Only a file that re-places
    // the SAME representation can tell the two apart.
    //
    // That file is not exotic. It is the ordinary case `ItemWalk::seen`'s own
    // doc calls out -- "many placements of the same library block" -- and the
    // reason the budget was hoisted while `seen` was not. Under a hoisted
    // `seen` a 4-placement block collapses to its first placement plus
    // whatever the budget allows, and reports `ItemRevisits` for a file that
    // contains no fan-out at all.
    const BUDGET: u32 = 5;
    const PLACEMENTS: usize = 4;
    const LEAVES: usize = 50;

    let mut acc = SymbolicAccumulator::with_revisit_budget(BUDGET);
    super::extract_symbolic_data_into(
        &shared_map_multi_placement(PLACEMENTS, LEAVES).into_bytes(),
        &mut acc,
    );
    let out = acc.into_data();

    assert_eq!(
        out.polylines.len(),
        PLACEMENTS * LEAVES,
        "each placement of a shared block is a separate top-level item and \
         must emit its own geometry in full; a node first reached under THIS \
         item is a first visit even if an earlier item also reached it"
    );
    assert!(
        out.truncated.is_none(),
        "re-placing one block is not a revisit fan-out and must not be \
         reported as truncated: {:?}",
        out.truncated
    );
}

#[test]
fn the_shared_revisit_budget_does_not_charge_legitimate_first_visits_across_items() {
    // The other direction, so "share the pool" cannot be satisfied by "charge
    // every visit, first or not" -- that would also make a second item
    // contribute nothing, for the wrong reason, and would truncate a
    // perfectly well-formed multi-product file.
    //
    // Two top-level items below are flat, non-cyclic curve sets of DISTINCT
    // entities: by construction every visit inside them is a first visit, and
    // none should ever be charged against the revisit pool, however small
    // that pool is set. `leaves` is deliberately far larger than `BUDGET` so
    // an implementation that (even partially) charges first visits fails
    // here.
    const BUDGET: u32 = 5;
    const LEAVES: usize = 200;

    let mut acc = SymbolicAccumulator::with_revisit_budget(BUDGET);
    super::extract_symbolic_data_into(
        &flat_multi_item_dag(2, LEAVES).into_bytes(),
        &mut acc,
    );
    let out = acc.into_data();

    assert_eq!(
        out.polylines.len(),
        2 * LEAVES,
        "every element of two well-formed, non-cyclic flat sets must be \
         emitted regardless of how small the shared revisit budget is -- \
         first visits are never charged"
    );
    assert!(
        out.truncated.is_none(),
        "a file that never needed a single revisit must not be reported as \
         truncated just because the shared pool is tiny: {:?}",
        out.truncated
    );
}

#[test]
fn the_wire_spellings_match_serde() {
    // `as_wire_str` exists because the WASM boundary cannot pass a serde enum
    // to JavaScript. Two hand-kept spellings of one vocabulary drift, and the
    // drift is silent: the JSON consumer and the WASM consumer would simply
    // disagree about what `item-depth` is called. Derive one from the other.
    // The array below is hand-listed, so a fifth variant could be added with a
    // wrong spelling and this test would stay green while never checking it.
    // This match is exhaustive over the enum, so adding a variant fails to
    // compile here (E0004) until the array is extended too.
    match SymbolicTruncationReason::ElementCount {
        SymbolicTruncationReason::ElementCount
        | SymbolicTruncationReason::OutputBytes
        | SymbolicTruncationReason::ItemDepth
        | SymbolicTruncationReason::ItemRevisits
        | SymbolicTruncationReason::ItemCycle => {}
    }

    for reason in [
        SymbolicTruncationReason::ElementCount,
        SymbolicTruncationReason::OutputBytes,
        SymbolicTruncationReason::ItemDepth,
        SymbolicTruncationReason::ItemRevisits,
        SymbolicTruncationReason::ItemCycle,
    ] {
        let via_serde = serde_json::to_value(reason).expect("serializes");
        assert_eq!(
            via_serde,
            serde_json::Value::String(reason.as_wire_str().to_string()),
            "as_wire_str disagrees with Serialize for {reason:?}"
        );
    }
}