parseman 0.61.1 → 0.61.3

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.
@@ -1,6 +1,25 @@
1
- /** The sites to emit: highest weight per byte first, while they fit in `budget` bytes. */
1
+ /**
2
+ * The sites to emit: highest weight per byte first, stopping at the first that
3
+ * does not fit in `budget` bytes. Stopping rather than skipping is what makes a
4
+ * larger budget a superset: a skip lets a budget that just admits a big site lose
5
+ * the small ones a smaller budget took after skipping it. Insertion order is rank.
6
+ */
2
7
  export declare function selectHotSites(code: readonly number[], entries: readonly number[], siteBytes: ReadonlyMap<number, number>, budget: number,
3
8
  /** Measured calls per site (`countSiteVisits` over a corpus). Replaces the
4
9
  * static estimate when given. */
5
10
  profile?: ArrayLike<number>): Set<number>;
11
+ /**
12
+ * The largest prefix of the ranking whose real source fits `ceiling`. `siteBytes`
13
+ * leaves out what sites add around their bodies, so the estimate can overshoot:
14
+ * each overshoot drops the last site and measures again. Walking down the one
15
+ * order is what keeps a selection a prefix of any larger budget's: that budget has
16
+ * every candidate this one has, so it can never settle on fewer. `undefined` when
17
+ * not even the first site fits.
18
+ */
19
+ export declare function fitHotSites(code: readonly number[], entries: readonly number[], siteBytes: ReadonlyMap<number, number>,
20
+ /** What any selection costs before its first site body. */
21
+ fixed: number, ceiling: number, sourceBytes: (sites: ReadonlySet<number>) => number, profile?: ArrayLike<number>): {
22
+ readonly sites: ReadonlySet<number>;
23
+ readonly bytes: number;
24
+ } | undefined;
6
25
  //# sourceMappingURL=select-hot.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"select-hot.d.ts","sourceRoot":"","sources":["../../src/table/select-hot.ts"],"names":[],"mappings":"AAuBA,0FAA0F;AAC1F,wBAAgB,cAAc,CAC5B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EACtC,MAAM,EAAE,MAAM;AACd;iCACiC;AACjC,OAAO,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,GAC1B,GAAG,CAAC,MAAM,CAAC,CAiBb"}
1
+ {"version":3,"file":"select-hot.d.ts","sourceRoot":"","sources":["../../src/table/select-hot.ts"],"names":[],"mappings":"AAuBA;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EACtC,MAAM,EAAE,MAAM;AACd;iCACiC;AACjC,OAAO,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,GAC1B,GAAG,CAAC,MAAM,CAAC,CAgBb;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC;AACtC,2DAA2D;AAC3D,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,CAAC,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,KAAK,MAAM,EACnD,OAAO,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,GAC1B;IAAE,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAQ7E"}
package/dist/version.d.ts CHANGED
@@ -13,5 +13,5 @@
13
13
  * Kept in sync with `package.json` by `test/unit/version-sync.test.ts`, which fails
14
14
  * the build if this constant drifts from the published version.
15
15
  */
16
- export declare const PARSEMAN_VERSION = "0.61.1";
16
+ export declare const PARSEMAN_VERSION = "0.61.3";
17
17
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parseman",
3
- "version": "0.61.1",
3
+ "version": "0.61.3",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "engines": {
6
6
  "node": "^20.19.0 || >=22.13.0"
@@ -17,6 +17,7 @@ import type { Combinator } from '../types.ts'
17
17
  import type { DispatchArm } from '../combinators/dispatch.ts'
18
18
  import { ref } from '../combinators/ref.ts'
19
19
  import { rules, RULES_RECIPE, type PriorRules, type RulesOptions, type RulesRecipe } from '../combinators/parser.ts'
20
+ import { parseSync } from 'oxc-parser'
20
21
  import * as parseman from '../index.ts'
21
22
  import { confirmedBuildParamUnused } from '../compiler/build-arity.ts'
22
23
  import { directBuilderBindings } from './direct-builder-static.ts'
@@ -72,7 +73,7 @@ function stripTsFromSource(node: Node, code: string): string {
72
73
  walk(node)
73
74
  const start = (node as { start: number }).start
74
75
  const end = (node as { end: number }).end
75
- if (cuts.length === 0) return code.slice(start, end)
76
+ if (cuts.length === 0) return stripComments(code.slice(start, end))
76
77
  cuts.sort((a, b) => a[0] - b[0])
77
78
  let out = ''
78
79
  let cur = start
@@ -81,7 +82,46 @@ function stripTsFromSource(node: Node, code: string): string {
81
82
  out += code.slice(cur, s)
82
83
  cur = e
83
84
  }
84
- return out + code.slice(cur, end)
85
+ return stripComments(out + code.slice(cur, end))
86
+ }
87
+
88
+ /** The four characters ECMAScript treats as ending a line. */
89
+ const LINE_TERMINATOR = /[\n\r\u2028\u2029]/
90
+
91
+ /**
92
+ * A reducer's source without its comments, and without the whitespace before them.
93
+ * A reducer ships as a string in the carried IR, where a bundler cannot strip it, and
94
+ * a comment must not change what a module ships. A comment still separates tokens and,
95
+ * if it crossed or followed a line end, still ends a line: automatic semicolon
96
+ * insertion and `return` depend on that line end.
97
+ */
98
+ function stripComments(text: string): string {
99
+ if (!text.includes('/*') && !text.includes('//')) return text
100
+ const wrapped = `(${text})`
101
+ let out = ''
102
+ let cur = 0
103
+ let lineEnd = false
104
+ const comments = parseSync('reducer.ts', wrapped).comments
105
+ comments.forEach((c, i) => {
106
+ const before = text.slice(cur, c.start - 1)
107
+ const kept = before.trimEnd()
108
+ out += kept
109
+ lineEnd ||= LINE_TERMINATOR.test(before.slice(kept.length)) || LINE_TERMINATOR.test(c.value)
110
+ cur = c.end - 1
111
+ const after = /^\s*/.exec(text.slice(cur))![0]
112
+ const endsLine = LINE_TERMINATOR.test(after)
113
+ if (comments[i + 1]?.start === c.end + after.length) {
114
+ lineEnd ||= endsLine
115
+ cur += after.length
116
+ return
117
+ }
118
+ if (out !== '' && cur + after.length < text.length && !endsLine) {
119
+ if (lineEnd) out += '\n'
120
+ else if (after === '') out += ' '
121
+ }
122
+ lineEnd = false
123
+ })
124
+ return out + text.slice(cur)
85
125
  }
86
126
 
87
127
  /**
@@ -170,11 +170,13 @@ export type ParsecraftPluginOptions = {
170
170
  */
171
171
  assemblyProfile?: AssemblyProfile
172
172
  /**
173
- * Generated assembly bytes each module may spend, as a multiple of its own source
174
- * length. **Default `0.25`.** `0` ships no assembly. The right number is
173
+ * Generated assembly bytes each module may spend, as a multiple of its own code,
174
+ * comments and whitespace aside. **Default `0.5`.** `0` ships no assembly. The right number is
175
175
  * per grammar: a larger one parses faster in steady state and costs import and
176
176
  * first-parse time, so measure both, and keep the artifact within 5× its source
177
- * (docs/design/runtime-and-size-contract.md, rules 4 and 6).
177
+ * (docs/design/runtime-and-size-contract.md, rules 4 and 6). A larger budget never
178
+ * loses a site, with one limit: a module with two AST grammars spends it in order, so
179
+ * a larger budget can buy the first a big site and leave the second fewer.
178
180
  */
179
181
  assemblyBudget?: number
180
182
  }
@@ -184,7 +186,9 @@ export type AssemblyProfile = {
184
186
  /** The rule each corpus file is parsed from, e.g. `'Stylesheet'`. A grammar
185
187
  * without this rule keeps the static ranking. */
186
188
  readonly entry: string
187
- /** Paths of representative inputs. */
189
+ /** Paths of representative inputs. At least one must have content, and each that
190
+ * does has to parse in full without recovery, or the build fails. A file with only
191
+ * whitespace counts as empty. */
188
192
  readonly files: readonly string[]
189
193
  }
190
194
 
@@ -206,11 +210,26 @@ const PARSEMAN_MODULE = 'parseman'
206
210
 
207
211
  /**
208
212
  * The default `assemblyBudget`: generated assembly bytes a module may spend, as a
209
- * multiple of its own source length. A quarter of the source keeps every jess
210
- * grammar far under the 5x ceiling (contract rule 4); the 0.57.0 CHANGELOG has the
211
- * measured curve.
213
+ * multiple of its own code (`codeBytes`). Half keeps every jess grammar far under the
214
+ * 5x ceiling (contract rule 4). A grammar that wants a different trade passes its own.
212
215
  */
213
- const DEFAULT_ASSEMBLY_BUDGET = 0.25
216
+ const DEFAULT_ASSEMBLY_BUDGET = 0.5
217
+
218
+ /**
219
+ * A module's code in bytes, without its comments and whitespace. It is what a budget
220
+ * is a share of, so editing a comment or reflowing a line cannot move a site in or
221
+ * out of the assembly, and with it the module's size and parse speed.
222
+ */
223
+ function codeBytes(code: string, comments: readonly { start: number; end: number }[], end = code.length): number {
224
+ let bytes = 0
225
+ let at = 0
226
+ for (const c of comments) {
227
+ if (c.start >= end) break
228
+ bytes += code.slice(at, c.start).replace(/\s+/g, '').length
229
+ at = c.end
230
+ }
231
+ return bytes + code.slice(at, end).replace(/\s+/g, '').length
232
+ }
214
233
 
215
234
  /**
216
235
  * The REASON a lowering refused, appended to the warning that reports it.
@@ -767,6 +786,8 @@ export type TransformMacroResult = {
767
786
  map: ReturnType<MagicString['generateMap']>
768
787
  /** Diagnostics for macro-referencing shapes that fell back to the interpreter. */
769
788
  warnings: string[]
789
+ /** The sites the module's `assemblyBudget` bought, by program offset, in rank order. */
790
+ assemblySites: number[]
770
791
  }
771
792
 
772
793
  /**
@@ -1749,7 +1770,9 @@ function transformMacroImpl(
1749
1770
 
1750
1771
  // Stable, reproducible per-artifact namespace: hash of module id + a label
1751
1772
  // (binding name / arg position) — never a counter, so rebuilds are byte-stable
1752
- // and two artifacts never collide when fused.
1773
+ // and two artifacts never collide when fused. A call is labelled by where it sits
1774
+ // in the module's code (`codeBytes`), so a comment above it leaves the name alone.
1775
+ const callLabel = (fn: string, start: number): string => `${fn}${codeBytes(code, result.comments, start)}`
1753
1776
  const nsFor = (label: string): string =>
1754
1777
  `_${createHash('sha1').update(`${id}#${label}`).digest('hex').slice(0, 8)}_`
1755
1778
 
@@ -2253,7 +2276,7 @@ function transformMacroImpl(
2253
2276
  const carried: CarriedItem[] = [] // re-lowerable; also SERIALIZED onto the value
2254
2277
  const importedFactories: string[] = []
2255
2278
  for (let i = 0; i < elements.length; i++) {
2256
- const r = argPieces(elements[i]!, `compose${init.start}_${i}`, composing)
2279
+ const r = argPieces(elements[i]!, `${callLabel('compose', init.start)}_${i}`, composing)
2257
2280
  if (!r) {
2258
2281
  // A build-compiled argument cannot fall back: runtime `compose()` links live
2259
2282
  // `rules()` grammars only and would throw at import. Fail here instead.
@@ -2342,14 +2365,15 @@ function transformMacroImpl(
2342
2365
  * SELECTIVE ASSEMBLY, BUDGETED PER MODULE. A fused grammar's default AST/no-lines
2343
2366
  * table carries generated code for its hottest sites only (`select-hot.ts`); the
2344
2367
  * closure engine runs the rest. The site bodies of every fused grammar in this
2345
- * module together may not exceed `assemblyBudget` times the module's own source.
2368
+ * module together may not exceed `assemblyBudget` times the module's own code (`codeBytes`).
2346
2369
  *
2347
2370
  * A module that declares a `composeLeaf` has named its terminal parser, so its
2348
2371
  * `compose()` results are composable PIECES: another build re-lowers them and
2349
2372
  * never parses through their table, so they spend nothing. Without a
2350
2373
  * `composeLeaf`, a `compose()` result is the module's parser.
2351
2374
  */
2352
- let assemblyBudget = Math.floor(code.length * assemblyBudgetShare)
2375
+ let assemblyBudget = Math.floor(codeBytes(code, result.comments) * assemblyBudgetShare)
2376
+ const assemblySites: number[] = []
2353
2377
  let leafDeclared: boolean | undefined
2354
2378
  const declaresLeaf = (): boolean => leafDeclared ??= (body as Statement[]).some(stmt => {
2355
2379
  const vd = unwrapVd(stmt)
@@ -2373,32 +2397,48 @@ function transformMacroImpl(
2373
2397
  warnings.push(`${id}:${lineOf(pos)} — ${label}: its precompiled assembly cannot emit ${refusal}; the artifact runs on the closure engine`)
2374
2398
  }
2375
2399
  assemblyBudget -= bytes
2400
+ assemblySites.push(...sites)
2376
2401
  return sites
2377
2402
  }
2378
2403
  /** Measured site calls over the optional corpus, or `undefined` for the static
2379
- * ranking. A file whose parse throws still contributes the calls it made. */
2404
+ * ranking. A corpus that cannot run fails the build: a static ranking standing in
2405
+ * for the measured one configured is a slower parser that nothing reports. */
2380
2406
  const profileOf = (compiled: CompiledRuleMapTable): Int32Array | undefined => {
2381
2407
  // Only the default AST/no-lines table carries an assembly, so only it is profiled.
2382
2408
  if (assemblyProfile === undefined || compiled.hostMode !== 'ast' || compiled.prog.lines === 1
2383
2409
  || !(assemblyProfile.entry in compiled.prog.rules)) return undefined
2384
- let parsed = 0
2410
+ const refuse = (why: string): never => { throw new Error(`${id} — assemblyProfile: ${why}`) }
2411
+ if (corpusTexts(assemblyProfile).every(text => text.trim() === '')) refuse('`files` has no non-empty file')
2412
+ let thrower: unknown
2385
2413
  // A builder re-lowered from carried IR is inert at build time; for COUNTING
2386
2414
  // only, a plain node stands in for it, so the corpus can run past it.
2387
2415
  const fns = compiled.prog.fns.map(fn => {
2388
2416
  const type = (fn as { [INERT_BUILDER]?: string } | null)?.[INERT_BUILDER]
2389
- return type === undefined ? fn
2390
- : (children: unknown, _fields: unknown, span: unknown) => ({ type, span, children })
2417
+ if (type !== undefined) return (children: unknown, _fields: unknown, span: unknown) => ({ type, span, children })
2418
+ return typeof fn !== 'function' ? fn : new Proxy(fn, {
2419
+ apply(target, self, args) {
2420
+ try { return Reflect.apply(target, self, args) as unknown } catch (e) { thrower ??= target; throw e }
2421
+ },
2422
+ })
2391
2423
  })
2392
- const counts = countSiteVisits(compiled.prog, rules => {
2424
+ return countSiteVisits(compiled.prog, rules => {
2393
2425
  const entry = rules[assemblyProfile.entry]!
2394
- for (const text of corpusTexts(assemblyProfile)) {
2395
- try { if (run(entry, text).ok) parsed++ } catch { /* the calls it made still count */ }
2396
- }
2426
+ corpusTexts(assemblyProfile).forEach((text, i) => {
2427
+ const file = assemblyProfile.files[i]!
2428
+ if (text.trim() === '') return
2429
+ let result
2430
+ try { result = run(entry, text) } catch (e) {
2431
+ const source = String(thrower).replace(/\s+/g, ' ').slice(0, 200)
2432
+ throw new Error(`${id} — assemblyProfile: ${thrower === undefined ? 'the parse' : `gate or reducer \`${source}\``} threw on ${file}: ${String(e)}`, { cause: e })
2433
+ }
2434
+ if (!result.ok || result.unconsumedFrom !== null) {
2435
+ refuse(`${file} does not parse from rule "${assemblyProfile.entry}": it stops at offset ${result.ok ? result.unconsumedFrom : result.span.end}`)
2436
+ }
2437
+ if (result.errors.length > 0) {
2438
+ refuse(`${file} parses only by recovering from ${result.errors.length} error(s), first at offset ${result.errors[0]!.span.start}`)
2439
+ }
2440
+ })
2397
2441
  }, fns)
2398
- if (parsed > 0) return counts
2399
- // Not `warn()`: nothing fell back to the interpreter — the build is only untuned.
2400
- warnings.push(`${id} — assemblyProfile: no corpus file parsed from rule "${assemblyProfile.entry}"; using the static ranking`)
2401
- return undefined
2402
2442
  }
2403
2443
  /** The fused table's replacement, with its selective default assembly if any. */
2404
2444
  const assembledReplacement = (compiled: CompiledRuleMapTable, metadata: string, select: ReadonlySet<number>): string =>
@@ -2428,7 +2468,7 @@ function transformMacroImpl(
2428
2468
  let localRules: ReadonlyMap<string, Combinator<unknown>> | null = null
2429
2469
  const importedFactories: string[] = []
2430
2470
  if (isRulesCall(localArg)) {
2431
- const evaluated = evaluateRulesFactory(localArg, `composeLeaf${init.start}`)
2471
+ const evaluated = evaluateRulesFactory(localArg, callLabel('composeLeaf', init.start))
2432
2472
  localRules = evaluated?.ruleMap ?? null
2433
2473
  if (evaluated?.importedFactory) importedFactories.push(evaluated.importedFactory)
2434
2474
  } else if (localArg.type === 'Identifier') {
@@ -2455,7 +2495,7 @@ function transformMacroImpl(
2455
2495
  const composing = composingTrivia(elements)
2456
2496
  const carried: CarriedItem[] = []
2457
2497
  for (let i = 0; i < elements.length - 1; i++) {
2458
- const r = argPieces(elements[i]!, `composeLeaf${init.start}_${i}`, composing)
2498
+ const r = argPieces(elements[i]!, `${callLabel('composeLeaf', init.start)}_${i}`, composing)
2459
2499
  if (!r) {
2460
2500
  warn(init.start, `composeLeaf(): argument ${i} isn't a build-resolvable recognition grammar`)
2461
2501
  return null
@@ -3277,6 +3317,7 @@ function transformMacroImpl(
3277
3317
  }
3278
3318
 
3279
3319
  return {
3320
+ assemblySites: applied.length > 0 ? assemblySites : [],
3280
3321
  code: ms.toString(),
3281
3322
  map: ms.generateMap({ hires: true }),
3282
3323
  warnings,
@@ -5,7 +5,7 @@ import { collectGrammarReflection, GRAMMAR_REFLECTION, type GrammarReflection }
5
5
  import { encodeTableProgram, type TableSettings } from './encode.ts'
6
6
  import { defaultAssemblyCfgs, emitTableExpression } from './emit.ts'
7
7
  import { emitAssemblySource, Unemittable } from './emit-assembly.ts'
8
- import { selectHotSites } from './select-hot.ts'
8
+ import { fitHotSites } from './select-hot.ts'
9
9
 
10
10
  /** The most the entry supercompiler may grow an assembly (`entry-supercompiler.ts`, its 5% source cap). */
11
11
  const SUPERCOMPILE_GROWTH = 1.05
@@ -372,29 +372,16 @@ export function compileRuleMap(
372
372
  // The estimate (prelude plus site bodies) leaves out what selected sites add
373
373
  // around their bodies — shared helpers and the `_px` wrappers of the closure
374
374
  // sites they call — and the supercompiler may grow the source by up to 5%. So
375
- // the selection is judged by the source it actually emits, scaled for that 5%,
376
- // and narrowed until that fits.
375
+ // the selection is judged by the source it actually emits, scaled for that 5%.
377
376
  const ceiling = budget / SUPERCOMPILE_GROWTH
378
377
  // A budget that cannot cover the prelude buys nothing, by design: no refusal.
379
378
  if (ceiling <= fixed) return none
380
- // Narrowed until it fits, with no attempt limit: the target shrinks by at
381
- // least 1% a round, so the selection either fits or empties. An empty one
382
- // says why — a build that asked for an assembly and silently shipped none is
383
- // a regression no test sees (the artifact still parses, on the closures).
384
- let target = ceiling
385
- for (;;) {
386
- const sites = selectHotSites(artifact.code, Object.values(artifact.rules), siteBytes, target - fixed, profile)
387
- if (sites.size === 0) {
388
- return {
389
- ...none,
390
- refusal: `a site within its ${Math.floor(ceiling)} B selection ceiling (the prelude alone is ${fixed} B)`,
391
- }
392
- }
393
- const bytes = emitAssemblySource(table, artifact, cfg, roots, true, sites, true).source.length
394
- if (bytes <= ceiling) return { sites, bytes: Math.ceil(bytes * SUPERCOMPILE_GROWTH) }
395
- // Scale by the overshoot, and by at least 1%: near the ceiling a site is
396
- // the unit, and the ratio alone can stall a few bytes over it.
397
- target *= Math.min(ceiling / bytes, 0.99)
379
+ const fit = fitHotSites(artifact.code, Object.values(artifact.rules), siteBytes, fixed, ceiling,
380
+ sites => emitAssemblySource(table, artifact, cfg, roots, true, sites, true).source.length, profile)
381
+ if (fit !== undefined) return { sites: fit.sites, bytes: Math.ceil(fit.bytes * SUPERCOMPILE_GROWTH) }
382
+ return {
383
+ ...none,
384
+ refusal: `a site within its ${Math.floor(ceiling)} B selection ceiling (the prelude alone is ${fixed} B)`,
398
385
  }
399
386
  }
400
387
 
@@ -21,7 +21,12 @@ const LOOP = 8
21
21
  const ROUNDS = 60
22
22
  const CAP = 1e12
23
23
 
24
- /** The sites to emit: highest weight per byte first, while they fit in `budget` bytes. */
24
+ /**
25
+ * The sites to emit: highest weight per byte first, stopping at the first that
26
+ * does not fit in `budget` bytes. Stopping rather than skipping is what makes a
27
+ * larger budget a superset: a skip lets a budget that just admits a big site lose
28
+ * the small ones a smaller budget took after skipping it. Insertion order is rank.
29
+ */
25
30
  export function selectHotSites(
26
31
  code: readonly number[],
27
32
  entries: readonly number[],
@@ -41,14 +46,40 @@ export function selectHotSites(
41
46
  let spent = 0
42
47
  for (const ip of ranked) {
43
48
  if (w(ip) === 0) break
44
- const bytes = siteBytes.get(ip)!
45
- if (spent + bytes > budget) continue
46
- spent += bytes
49
+ spent += siteBytes.get(ip)!
50
+ if (spent > budget) break
47
51
  selected.add(ip)
48
52
  }
49
53
  return selected
50
54
  }
51
55
 
56
+ /**
57
+ * The largest prefix of the ranking whose real source fits `ceiling`. `siteBytes`
58
+ * leaves out what sites add around their bodies, so the estimate can overshoot:
59
+ * each overshoot drops the last site and measures again. Walking down the one
60
+ * order is what keeps a selection a prefix of any larger budget's: that budget has
61
+ * every candidate this one has, so it can never settle on fewer. `undefined` when
62
+ * not even the first site fits.
63
+ */
64
+ export function fitHotSites(
65
+ code: readonly number[],
66
+ entries: readonly number[],
67
+ siteBytes: ReadonlyMap<number, number>,
68
+ /** What any selection costs before its first site body. */
69
+ fixed: number,
70
+ ceiling: number,
71
+ sourceBytes: (sites: ReadonlySet<number>) => number,
72
+ profile?: ArrayLike<number>,
73
+ ): { readonly sites: ReadonlySet<number>; readonly bytes: number } | undefined {
74
+ const ranked = [...selectHotSites(code, entries, siteBytes, ceiling - fixed, profile)]
75
+ for (; ranked.length > 0; ranked.pop()) {
76
+ const sites = new Set(ranked)
77
+ const bytes = sourceBytes(sites)
78
+ if (bytes <= ceiling) return { sites, bytes }
79
+ }
80
+ return undefined
81
+ }
82
+
52
83
  function staticWeights(code: readonly number[], entries: readonly number[]): Map<number, number> {
53
84
  let weight = new Map<number, number>()
54
85
  const kids: number[] = []
package/src/version.ts CHANGED
@@ -13,4 +13,4 @@
13
13
  * Kept in sync with `package.json` by `test/unit/version-sync.test.ts`, which fails
14
14
  * the build if this constant drifts from the published version.
15
15
  */
16
- export const PARSEMAN_VERSION = '0.61.1'
16
+ export const PARSEMAN_VERSION = '0.61.3'