(items: ReadonlyArray<Expression>)
| 53 | * `Filter(xs, p)` used to answer `[]`, `Any(xs, p)` `False`, `All(xs, p)` |
| 54 | * `True`, `Find(xs, p)` `Nothing` and `IndexWhere(xs, p)` `0` for such a |
| 55 | * source — answers a later `xs := [1, 5]` contradicts, and which the rest of |
| 56 | * the library (`Length`, `Total`, `Sort`, `Map`, `CountIf`, …) has always |
| 57 | * declined to give by staying inert. |
| 58 | * |
| 59 | * An EAGER collection operator (`Characters(s)`, `Divisors(n)`, `Eigenvalues`, |
| 60 | * … — 73 of them, against 47 with collection handlers) has no collection |
| 61 | * handlers either until it is evaluated, and is the one case |
| 62 | * `isEnumerableCollection` reports `undefined` for. The evaluated form is |
| 63 | * consulted for exactly that case — off the hot path, since a source that is |
| 64 | * already a collection answers on the first test. |
| 65 | * |
| 66 | * **On the discarded evaluation** (measured 2026-08-09, don't re-litigate): |
| 67 | * the fallback fires only for a source that is not already a collection, and |
| 68 | * only at the LEAF of a chain — an inner `Filter`/`Map`/`Take` answers `true` |
| 69 | * on the first test. Measured at nesting depth 6 over `Characters(400)`: 12 |
| 70 | * calls, 2 fallbacks; it does not compound with depth. The evaluated form is |
| 71 | * also what the subsequent walk materializes, and the lazy-collection evaluate |
| 72 | * memo keeps that second pass cheap. |
| 73 | * |
| 74 | * **Why the dedicated facet, and not the emptiness ones.** |
| 75 | * `isEmptyCollection`/`count` are HONEST about a valueless leaf (both |
| 76 | * `undefined`), and so is a wrapper over one (`Take(xs, 2)`, `Reverse(xs)`), |
| 77 | * where `isCollection` answers `true`. But reading them from here is |
| 78 | * **exponential**: `Filter.isEmpty` would call this, which reads the source's |
| 79 | * `isEmptyCollection`, which is the inner `Filter.isEmpty`, which calls this |
| 80 | * again — measured at exactly 2^(d+1) − 2 calls (6/14/30/62/126/254 at depths |
| 81 | * 2–7), the double-read shape-query class. `isEnumerableCollection` is the |
| 82 | * O(1) propagating facet that answers instead: a wrapper reads only its |
| 83 | * source's enumerability (never its own emptiness), so a depth-d chain costs |
| 84 | * d calls. |
| 85 | * |
| 86 | * **On evaluating an IMPURE source.** The fallback evaluates `xs` and throws |
| 87 | * the result away, so an eager IMPURE producer (`RandomShuffle`) runs one |
| 88 | * extra time. That is a pre-existing property of these paths rather than |
| 89 | * something this predicate introduced: counting handler invocations for a |
| 90 | * 5-element source, `Map(f, RandomShuffle(xs))` — which never reaches this |
| 91 | * function — evaluates the shuffle **8** times, `Filter(RandomShuffle(xs), p)` |
| 92 | * 5 (of which this contributes 1), `Any(…)` 2. The results stay correct (a |
| 93 | * filtered shuffle is still a filtered shuffle); what is not reproducible is |
| 94 | * the number of draws consumed. Tracked in `ROADMAP.md` as its own defect — |
| 95 | * fixing it here alone would not make the path reproducible. |
| 96 | * |
| 97 | * **Asymmetry with {@link isBroadcastableCollection}** — deliberate. That |
| 98 | * predicate has no such fallback because it answers a different question at a |
| 99 | * different place: it is consulted on operands that have ALREADY been |
| 100 | * evaluated (the broadcast gates run post-evaluation), and it fails CLOSED — |
| 101 | * a `false` means "do not broadcast", which leaves the expression symbolic. |
| 102 | * This one is consulted inside LAZY collection handlers, where the source may |
| 103 | * still be raw, and a wrong answer produces a definite wrong VALUE. Accuracy |
| 104 | * is worth an evaluation here and is not needed there. |
| 105 | */ |
| 106 | export function isEnumerableSource(xs: Expression): boolean { |
| 107 | // The facet answers structurally for everything that can be decided without |
| 108 | // evaluating: a leaf collection (`true`), a symbolic-bounds `Range` or a |
| 109 | // valueless symbol (`false`), and a wrapper over either (propagated). |
| 110 | const enumerable = xs.isEnumerableCollection; |
| 111 | if (enumerable !== undefined) return enumerable; |
| 112 |
no test coverage detected