solve-engine 1.0.1 → 1.1.0

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 (237) hide show
  1. package/dist/{BytecodeBuilder-B0xskcv5.d.cts → BytecodeBuilder-Bp9xeTmX.d.cts} +4 -0
  2. package/dist/{BytecodeBuilder-B0xskcv5.d.ts → BytecodeBuilder-Bp9xeTmX.d.ts} +4 -0
  3. package/dist/{Configuration-B-G5gTRn.d.cts → Configuration-C9W8tJv_.d.cts} +53 -1
  4. package/dist/{Configuration-B-G5gTRn.d.ts → Configuration-C9W8tJv_.d.ts} +53 -1
  5. package/dist/{EngineError-LU7W7AgI.d.cts → EngineError-B61GS1jp.d.cts} +14 -0
  6. package/dist/{EngineError-LU7W7AgI.d.ts → EngineError-B61GS1jp.d.ts} +14 -0
  7. package/dist/FormattingSettings-CJHyxcYu.d.cts +27 -0
  8. package/dist/FormattingSettings-CJHyxcYu.d.ts +27 -0
  9. package/dist/{Lexer-D78E2yDW.d.cts → Lexer-BOs7euZe.d.cts} +58 -1
  10. package/dist/{Lexer-BN5mt30n.d.ts → Lexer-CSI_lwbW.d.ts} +58 -1
  11. package/dist/PackageCompatibility-B-7rK1TD.d.cts +76 -0
  12. package/dist/PackageCompatibility-Dh59eF-X.d.ts +76 -0
  13. package/dist/{PackageRegistry-B37mRvvz.d.cts → PackageRegistry-BHWJP83F.d.cts} +449 -11
  14. package/dist/{PackageRegistry-DCUEzbQt.d.ts → PackageRegistry-_8rDlvxI.d.ts} +449 -11
  15. package/dist/{Parselet-BaySkMV3.d.ts → Parselet-BBT8riYh.d.ts} +12 -3
  16. package/dist/{Parselet-DuI1Pjiq.d.cts → Parselet-BHcgK9S7.d.cts} +12 -3
  17. package/dist/{ScopeManager-B6GzdhVG.d.cts → ScopeManager-8vf02dwj.d.cts} +56 -5
  18. package/dist/{ScopeManager-udv4Twwq.d.ts → ScopeManager-CxA24W5n.d.ts} +56 -5
  19. package/dist/{Token-BzG5G4ja.d.cts → Token-B1hdkedD.d.cts} +9 -0
  20. package/dist/{Token-BzG5G4ja.d.ts → Token-B1hdkedD.d.ts} +9 -0
  21. package/dist/{TokenNormalizer-t_GotBxr.d.ts → TokenNormalizer-C6VzZgHa.d.ts} +1 -1
  22. package/dist/{TokenNormalizer-DGVa24Q-.d.cts → TokenNormalizer-DRc1Js1V.d.cts} +1 -1
  23. package/dist/{VMCheckpoints-DwLjivM7.d.cts → VMCheckpoints-ELYqITdF.d.cts} +3 -3
  24. package/dist/{VMCheckpoints-BiaIlOOY.d.ts → VMCheckpoints-MK--EBH2.d.ts} +3 -3
  25. package/dist/{Value-CXJqDH9J.d.cts → Value-BUi1RA3S.d.cts} +124 -5
  26. package/dist/{Value-CXJqDH9J.d.ts → Value-BUi1RA3S.d.ts} +124 -5
  27. package/dist/WorkerError-_-RkoQ5P.d.ts +75 -0
  28. package/dist/WorkerError-gzmaopj2.d.cts +75 -0
  29. package/dist/{chunk-GQCOSXMG.js → chunk-267JPOTF.js} +43 -5
  30. package/dist/chunk-267JPOTF.js.map +1 -0
  31. package/dist/{chunk-JMXUNXQS.cjs → chunk-2TZKENDH.cjs} +51 -8
  32. package/dist/chunk-2TZKENDH.cjs.map +1 -0
  33. package/dist/chunk-3OWCDIPN.js +215 -0
  34. package/dist/chunk-3OWCDIPN.js.map +1 -0
  35. package/dist/{chunk-VB37OC6I.js → chunk-4D6NIHE2.js} +40 -3
  36. package/dist/chunk-4D6NIHE2.js.map +1 -0
  37. package/dist/{chunk-TY3TLZAW.cjs → chunk-536WPM2V.cjs} +18 -2
  38. package/dist/chunk-536WPM2V.cjs.map +1 -0
  39. package/dist/chunk-5HRB36DK.js +405 -0
  40. package/dist/chunk-5HRB36DK.js.map +1 -0
  41. package/dist/chunk-5ON7PUAZ.js +12 -0
  42. package/dist/chunk-5ON7PUAZ.js.map +1 -0
  43. package/dist/{chunk-KV7UW6T6.js → chunk-7KYSUDQO.js} +12 -5
  44. package/dist/chunk-7KYSUDQO.js.map +1 -0
  45. package/dist/{chunk-6WFMPTGB.cjs → chunk-7ZX6B7SY.cjs} +774 -528
  46. package/dist/chunk-7ZX6B7SY.cjs.map +1 -0
  47. package/dist/chunk-ALYRJ72W.cjs +434 -0
  48. package/dist/chunk-ALYRJ72W.cjs.map +1 -0
  49. package/dist/{chunk-5KMIY374.cjs → chunk-B4HBEFTB.cjs} +17 -2
  50. package/dist/chunk-B4HBEFTB.cjs.map +1 -0
  51. package/dist/chunk-B7NLZMQ3.cjs +74 -0
  52. package/dist/chunk-B7NLZMQ3.cjs.map +1 -0
  53. package/dist/{chunk-UQ3UIZJC.js → chunk-BLI4NIQY.js} +6 -2
  54. package/dist/chunk-BLI4NIQY.js.map +1 -0
  55. package/dist/{chunk-BHTNFEGZ.cjs → chunk-C75AFGVD.cjs} +11 -11
  56. package/dist/{chunk-BHTNFEGZ.cjs.map → chunk-C75AFGVD.cjs.map} +1 -1
  57. package/dist/{chunk-G535KJEG.js → chunk-CCBZZQAE.js} +2 -2
  58. package/dist/{chunk-G535KJEG.js.map → chunk-CCBZZQAE.js.map} +1 -1
  59. package/dist/chunk-CKQMXMHR.cjs +219 -0
  60. package/dist/chunk-CKQMXMHR.cjs.map +1 -0
  61. package/dist/{chunk-PFUESQTW.cjs → chunk-EJ3ILXX6.cjs} +48 -2
  62. package/dist/chunk-EJ3ILXX6.cjs.map +1 -0
  63. package/dist/{chunk-526PMQOA.js → chunk-ENKKJYD3.js} +10 -4
  64. package/dist/chunk-ENKKJYD3.js.map +1 -0
  65. package/dist/{chunk-5X2PTP6F.cjs → chunk-ENRIK36Q.cjs} +2 -12
  66. package/dist/chunk-ENRIK36Q.cjs.map +1 -0
  67. package/dist/{chunk-3D7V24DG.js → chunk-ERCOHGXD.js} +17 -2
  68. package/dist/chunk-ERCOHGXD.js.map +1 -0
  69. package/dist/{chunk-IF532O7C.js → chunk-FD5ZZHEU.js} +3 -12
  70. package/dist/chunk-FD5ZZHEU.js.map +1 -0
  71. package/dist/{chunk-TBN7DEHO.js → chunk-G7Z4HJQA.js} +11 -5
  72. package/dist/chunk-G7Z4HJQA.js.map +1 -0
  73. package/dist/{chunk-QY25VWBF.js → chunk-GQMUHVE3.js} +142 -6
  74. package/dist/chunk-GQMUHVE3.js.map +1 -0
  75. package/dist/chunk-GUG7SNSV.js +1764 -0
  76. package/dist/chunk-GUG7SNSV.js.map +1 -0
  77. package/dist/{chunk-UM6BVY2S.cjs → chunk-HIQ5HSZL.cjs} +175 -43
  78. package/dist/chunk-HIQ5HSZL.cjs.map +1 -0
  79. package/dist/{chunk-HVQFNJKE.cjs → chunk-HVRVSI2Z.cjs} +104 -86
  80. package/dist/chunk-HVRVSI2Z.cjs.map +1 -0
  81. package/dist/{chunk-3LAEG75D.js → chunk-I4GAWIPW.js} +24 -6
  82. package/dist/chunk-I4GAWIPW.js.map +1 -0
  83. package/dist/{chunk-PA4VC73I.cjs → chunk-J45BCEZ4.cjs} +33 -27
  84. package/dist/chunk-J45BCEZ4.cjs.map +1 -0
  85. package/dist/{chunk-V5PYO44Y.js → chunk-JIPATHVY.js} +1853 -2638
  86. package/dist/chunk-JIPATHVY.js.map +1 -0
  87. package/dist/{chunk-YPPPYLSR.js → chunk-JJIXHXFQ.js} +5 -5
  88. package/dist/{chunk-YPPPYLSR.js.map → chunk-JJIXHXFQ.js.map} +1 -1
  89. package/dist/chunk-L2TE7PMO.cjs +14 -0
  90. package/dist/chunk-L2TE7PMO.cjs.map +1 -0
  91. package/dist/{chunk-B7TLZABL.cjs → chunk-MG6Q3DUO.cjs} +1962 -2746
  92. package/dist/chunk-MG6Q3DUO.cjs.map +1 -0
  93. package/dist/{chunk-R3PY4G7J.js → chunk-MVTOCRV2.js} +48 -3
  94. package/dist/chunk-MVTOCRV2.js.map +1 -0
  95. package/dist/{chunk-Y7FT4IQT.js → chunk-NKW7LKYU.js} +266 -28
  96. package/dist/chunk-NKW7LKYU.js.map +1 -0
  97. package/dist/{chunk-O3ANBHSA.js → chunk-NNQ2TYDF.js} +123 -5
  98. package/dist/chunk-NNQ2TYDF.js.map +1 -0
  99. package/dist/{chunk-5F4C26RU.js → chunk-OADDUPT3.js} +4 -4
  100. package/dist/{chunk-5F4C26RU.js.map → chunk-OADDUPT3.js.map} +1 -1
  101. package/dist/{chunk-3AFRJYP4.cjs → chunk-OTN6SZOD.cjs} +1868 -393
  102. package/dist/chunk-OTN6SZOD.cjs.map +1 -0
  103. package/dist/{chunk-5LI5EPGJ.cjs → chunk-QFTDTX6K.cjs} +40 -3
  104. package/dist/chunk-QFTDTX6K.cjs.map +1 -0
  105. package/dist/{chunk-HMOISHXR.js → chunk-RRLHC37V.js} +1530 -57
  106. package/dist/chunk-RRLHC37V.js.map +1 -0
  107. package/dist/chunk-SNHVVOJK.cjs +1768 -0
  108. package/dist/chunk-SNHVVOJK.cjs.map +1 -0
  109. package/dist/{chunk-HDP7VK3C.cjs → chunk-SRGQ72IR.cjs} +649 -349
  110. package/dist/chunk-SRGQ72IR.cjs.map +1 -0
  111. package/dist/{chunk-4B2CNWQU.cjs → chunk-SSV46KFA.cjs} +14 -7
  112. package/dist/chunk-SSV46KFA.cjs.map +1 -0
  113. package/dist/{chunk-AJA6LUI7.js → chunk-UO6BUV6K.js} +18 -2
  114. package/dist/chunk-UO6BUV6K.js.map +1 -0
  115. package/dist/chunk-WWQEY7BV.js +67 -0
  116. package/dist/chunk-WWQEY7BV.js.map +1 -0
  117. package/dist/{chunk-5WVP4YHP.js → chunk-WXEHD6TT.js} +333 -33
  118. package/dist/chunk-WXEHD6TT.js.map +1 -0
  119. package/dist/{chunk-47LRVGOT.cjs → chunk-XA4CKRML.cjs} +2 -2
  120. package/dist/{chunk-47LRVGOT.cjs.map → chunk-XA4CKRML.cjs.map} +1 -1
  121. package/dist/{chunk-6KFYJ6TD.cjs → chunk-XIYYHA65.cjs} +6 -2
  122. package/dist/chunk-XIYYHA65.cjs.map +1 -0
  123. package/dist/{chunk-KBSXGXPM.cjs → chunk-XJGJCF2R.cjs} +6 -6
  124. package/dist/{chunk-KBSXGXPM.cjs.map → chunk-XJGJCF2R.cjs.map} +1 -1
  125. package/dist/{chunk-YWQ6V4ZN.cjs → chunk-YTIYVHL7.cjs} +211 -75
  126. package/dist/chunk-YTIYVHL7.cjs.map +1 -0
  127. package/dist/{chunk-TMA4RCEN.js → chunk-Z7XGGLI2.js} +3 -3
  128. package/dist/{chunk-TMA4RCEN.js.map → chunk-Z7XGGLI2.js.map} +1 -1
  129. package/dist/{chunk-A2N2GFCG.cjs → chunk-ZVTWQLK4.cjs} +10 -10
  130. package/dist/{chunk-A2N2GFCG.cjs.map → chunk-ZVTWQLK4.cjs.map} +1 -1
  131. package/dist/{chunk-2XLQPQKI.cjs → chunk-ZXCESUJS.cjs} +15 -9
  132. package/dist/chunk-ZXCESUJS.cjs.map +1 -0
  133. package/dist/constants.cjs +5 -5
  134. package/dist/constants.d.cts +1 -1
  135. package/dist/constants.d.ts +1 -1
  136. package/dist/constants.js +2 -2
  137. package/dist/engine.cjs +52 -35
  138. package/dist/engine.d.cts +13 -13
  139. package/dist/engine.d.ts +13 -13
  140. package/dist/engine.js +26 -21
  141. package/dist/errors.cjs +44 -19
  142. package/dist/errors.d.cts +3 -2
  143. package/dist/errors.d.ts +3 -2
  144. package/dist/errors.js +2 -1
  145. package/dist/format.cjs +19 -169
  146. package/dist/format.cjs.map +1 -1
  147. package/dist/format.d.cts +18 -26
  148. package/dist/format.d.ts +18 -26
  149. package/dist/format.js +7 -171
  150. package/dist/format.js.map +1 -1
  151. package/dist/index.cjs +250 -31
  152. package/dist/index.cjs.map +1 -1
  153. package/dist/index.d.cts +110 -74
  154. package/dist/index.d.ts +110 -74
  155. package/dist/index.js +228 -23
  156. package/dist/index.js.map +1 -1
  157. package/dist/language.cjs +9 -9
  158. package/dist/language.d.cts +12 -12
  159. package/dist/language.d.ts +12 -12
  160. package/dist/language.js +3 -3
  161. package/dist/lexer.cjs +16 -16
  162. package/dist/lexer.d.cts +3 -3
  163. package/dist/lexer.d.ts +3 -3
  164. package/dist/lexer.js +5 -5
  165. package/dist/normalizer.cjs +10 -10
  166. package/dist/normalizer.d.cts +3 -3
  167. package/dist/normalizer.d.ts +3 -3
  168. package/dist/normalizer.js +4 -4
  169. package/dist/packages.cjs +45 -36
  170. package/dist/packages.d.cts +135 -31
  171. package/dist/packages.d.ts +135 -31
  172. package/dist/packages.js +14 -13
  173. package/dist/parser.cjs +16 -16
  174. package/dist/parser.d.cts +6 -5
  175. package/dist/parser.d.ts +6 -5
  176. package/dist/parser.js +6 -6
  177. package/dist/{pipeline-BEb3hujr.d.cts → pipeline-CtfJtPQc.d.cts} +3 -3
  178. package/dist/{pipeline-B6k5lCB7.d.ts → pipeline-DCd5M6Gk.d.ts} +3 -3
  179. package/dist/resolvers.d.cts +3 -3
  180. package/dist/resolvers.d.ts +3 -3
  181. package/dist/testing.cjs +478 -0
  182. package/dist/testing.cjs.map +1 -0
  183. package/dist/testing.d.cts +271 -0
  184. package/dist/testing.d.ts +271 -0
  185. package/dist/testing.js +470 -0
  186. package/dist/testing.js.map +1 -0
  187. package/dist/uom.cjs +16 -16
  188. package/dist/uom.d.cts +3 -3
  189. package/dist/uom.d.ts +3 -3
  190. package/dist/uom.js +6 -6
  191. package/dist/utilities.cjs +6 -5
  192. package/dist/utilities.js +2 -1
  193. package/dist/vm.cjs +34 -34
  194. package/dist/vm.d.cts +8 -8
  195. package/dist/vm.d.ts +8 -8
  196. package/dist/vm.js +9 -9
  197. package/dist/worker.cjs +493 -0
  198. package/dist/worker.cjs.map +1 -0
  199. package/dist/worker.d.cts +509 -0
  200. package/dist/worker.d.ts +509 -0
  201. package/dist/worker.js +484 -0
  202. package/dist/worker.js.map +1 -0
  203. package/package.json +21 -1
  204. package/dist/chunk-2XLQPQKI.cjs.map +0 -1
  205. package/dist/chunk-3AFRJYP4.cjs.map +0 -1
  206. package/dist/chunk-3D7V24DG.js.map +0 -1
  207. package/dist/chunk-3LAEG75D.js.map +0 -1
  208. package/dist/chunk-4B2CNWQU.cjs.map +0 -1
  209. package/dist/chunk-526PMQOA.js.map +0 -1
  210. package/dist/chunk-5KMIY374.cjs.map +0 -1
  211. package/dist/chunk-5LI5EPGJ.cjs.map +0 -1
  212. package/dist/chunk-5WVP4YHP.js.map +0 -1
  213. package/dist/chunk-5X2PTP6F.cjs.map +0 -1
  214. package/dist/chunk-6KFYJ6TD.cjs.map +0 -1
  215. package/dist/chunk-6WFMPTGB.cjs.map +0 -1
  216. package/dist/chunk-AJA6LUI7.js.map +0 -1
  217. package/dist/chunk-B7TLZABL.cjs.map +0 -1
  218. package/dist/chunk-GQCOSXMG.js.map +0 -1
  219. package/dist/chunk-HDP7VK3C.cjs.map +0 -1
  220. package/dist/chunk-HMOISHXR.js.map +0 -1
  221. package/dist/chunk-HVQFNJKE.cjs.map +0 -1
  222. package/dist/chunk-IF532O7C.js.map +0 -1
  223. package/dist/chunk-JMXUNXQS.cjs.map +0 -1
  224. package/dist/chunk-KV7UW6T6.js.map +0 -1
  225. package/dist/chunk-O3ANBHSA.js.map +0 -1
  226. package/dist/chunk-PA4VC73I.cjs.map +0 -1
  227. package/dist/chunk-PFUESQTW.cjs.map +0 -1
  228. package/dist/chunk-QY25VWBF.js.map +0 -1
  229. package/dist/chunk-R3PY4G7J.js.map +0 -1
  230. package/dist/chunk-TBN7DEHO.js.map +0 -1
  231. package/dist/chunk-TY3TLZAW.cjs.map +0 -1
  232. package/dist/chunk-UM6BVY2S.cjs.map +0 -1
  233. package/dist/chunk-UQ3UIZJC.js.map +0 -1
  234. package/dist/chunk-V5PYO44Y.js.map +0 -1
  235. package/dist/chunk-VB37OC6I.js.map +0 -1
  236. package/dist/chunk-Y7FT4IQT.js.map +0 -1
  237. package/dist/chunk-YWQ6V4ZN.cjs.map +0 -1
@@ -1,20 +1,24 @@
1
- import { ParseletRegistry, PrecedenceParser } from './chunk-3LAEG75D.js';
1
+ import { ParseletRegistry, PrecedenceParser } from './chunk-I4GAWIPW.js';
2
2
  import { ResolverRegistry } from './chunk-3VGDNEE3.js';
3
- import { abortLogger, djb2Hash } from './chunk-IF532O7C.js';
4
- import { executeBytecode, createVM, DependencyGraph, ScopeManager } from './chunk-5WVP4YHP.js';
5
- import { DiagnosticPipeline, TimelineDiagnosticCollector, DiagnosticEventType } from './chunk-G535KJEG.js';
6
- import { BUILTIN_PACKAGES, containsSymbolicCall } from './chunk-HMOISHXR.js';
7
- import { BytecodeBuilder } from './chunk-526PMQOA.js';
8
- import { TokenNormalizer, BUILTIN_PHRASES, implicitMultiplyRule } from './chunk-YPPPYLSR.js';
3
+ import { abortLogger } from './chunk-FD5ZZHEU.js';
4
+ import { executeBytecode, createVM, DependencyGraph, ScopeManager } from './chunk-WXEHD6TT.js';
5
+ import { DiagnosticPipeline, TimelineDiagnosticCollector, DiagnosticEventType } from './chunk-CCBZZQAE.js';
6
+ import { formatValue, DEFAULT_FORMATTING_SETTINGS } from './chunk-3OWCDIPN.js';
7
+ import { BUILTIN_PACKAGES, containsSymbolicCall } from './chunk-RRLHC37V.js';
8
+ import { BytecodeBuilder } from './chunk-ENKKJYD3.js';
9
+ import { TokenNormalizer, BUILTIN_PHRASES, implicitMultiplyRule } from './chunk-JJIXHXFQ.js';
9
10
  import { countLines } from './chunk-3BOK2LDK.js';
10
11
  import { createQueryClient, setActiveQueryClient, getActiveQueryClient } from './chunk-NLNDMWZC.js';
11
- import { sharedOpRegistry, createEngineContext, registerAsConverter, unregisterAsConverter, solveEquationValues, globalDagKey, sharedGlobalVariableStore } from './chunk-Y7FT4IQT.js';
12
- import { matrixMultiply, inverse } from './chunk-O3ANBHSA.js';
13
- import { pendingValue, errorValue, stringValue, numberValue, freezeIfDev, boolValue, uomValue, percentageValue, datetimeValue, bigIntValue, hexValue, enableValueArena, disableValueArena } from './chunk-GQCOSXMG.js';
14
- import { mergeEngineConfig, DEFAULT_CONFIG, ENGINE_VERSION } from './chunk-TBN7DEHO.js';
15
- import { Lexer, buildTokenLookup, sharedLexer } from './chunk-5F4C26RU.js';
16
- import { registerTokenCategory, unregisterTokenCategory } from './chunk-AJA6LUI7.js';
17
- import { normalizeUnknownError, ErrorFactory, EngineError } from './chunk-3D7V24DG.js';
12
+ import { sharedOpRegistry, createEngineContext, registerAsConverter, unregisterAsConverter, solveEquationValues, globalDagKey } from './chunk-NKW7LKYU.js';
13
+ import { matrixMultiply, inverse } from './chunk-NNQ2TYDF.js';
14
+ import { errorValue, pendingValue, stringValue, numberValue, freezeIfDev, Value, boolValue, uomValue, percentageValue, datetimeValue, bigIntValue, hexValue, ValueType } from './chunk-267JPOTF.js';
15
+ import { mergeEngineConfig, DEFAULT_CONFIG, ENGINE_VERSION } from './chunk-G7Z4HJQA.js';
16
+ import { Lexer, buildTokenLookup, sharedLexer } from './chunk-OADDUPT3.js';
17
+ import { registerTokenCategory, unregisterTokenCategory } from './chunk-UO6BUV6K.js';
18
+ import { LexerToken } from './chunk-GQMUHVE3.js';
19
+ import { TokenTypes, tokenTypeId } from './chunk-4D6NIHE2.js';
20
+ import { getLocale } from './chunk-XBTEO4OB.js';
21
+ import { normalizeUnknownError, ErrorFactory, EngineError } from './chunk-ERCOHGXD.js';
18
22
  import { __commonJS, __toESM } from './chunk-YLJ4XMA6.js';
19
23
 
20
24
  // node_modules/semver/internal/constants.js
@@ -1965,106 +1969,41 @@ var require_semver2 = __commonJS({
1965
1969
  }
1966
1970
  });
1967
1971
 
1968
- // src/cache/LineCache.ts
1969
- var LineCacheEntry = class {
1970
- constructor(result, bytecode, readVariables, writeVariable) {
1971
- this.result = result;
1972
- this.bytecode = bytecode;
1973
- this.readVariables = readVariables;
1974
- this.writeVariable = writeVariable;
1975
- }
1976
- };
1977
- var LineCache = class _LineCache {
1978
- constructor() {
1979
- /** line number → (expression, or "" for expressionless entries) → entry */
1980
- this.byLine = /* @__PURE__ */ new Map();
1981
- this.count = 0;
1982
- }
1983
- static exprKey(expression) {
1984
- return expression ?? "";
1985
- }
1986
- static displayKey(line, exprKey) {
1987
- return exprKey === "" ? `${line}` : `${line}:${exprKey}`;
1988
- }
1989
- get(line, expression) {
1990
- return this.byLine.get(line)?.get(_LineCache.exprKey(expression));
1991
- }
1992
- /** Find any cache entry for the given line number, regardless of expression suffix */
1993
- getEntryForLine(line) {
1994
- const entries = this.byLine.get(line);
1995
- if (!entries) return void 0;
1996
- for (const entry of entries.values()) {
1997
- return entry;
1998
- }
1999
- return void 0;
2000
- }
2001
- set(line, entry, expression) {
2002
- let entries = this.byLine.get(line);
2003
- if (!entries) {
2004
- entries = /* @__PURE__ */ new Map();
2005
- this.byLine.set(line, entries);
2006
- }
2007
- const key = _LineCache.exprKey(expression);
2008
- if (entries.size > 0 && !entries.has(key)) {
2009
- this.count -= entries.size;
2010
- entries.clear();
2011
- }
2012
- if (!entries.has(key)) this.count++;
2013
- entries.set(key, entry);
2014
- }
2015
- has(line, expression) {
2016
- return this.byLine.get(line)?.has(_LineCache.exprKey(expression)) ?? false;
2017
- }
2018
- remove(line, expression) {
2019
- const entries = this.byLine.get(line);
2020
- if (!entries) return;
2021
- if (entries.delete(_LineCache.exprKey(expression))) {
2022
- this.count--;
2023
- if (entries.size === 0) this.byLine.delete(line);
2024
- }
2025
- }
2026
- removeAllForLine(line) {
2027
- const entries = this.byLine.get(line);
2028
- if (!entries) return;
2029
- this.count -= entries.size;
2030
- this.byLine.delete(line);
2031
- }
2032
- clearLine(line) {
2033
- this.removeAllForLine(line);
2034
- }
2035
- /**
2036
- * Number of entries in the cache. Useful for diagnostics.
2037
- */
2038
- get size() {
2039
- return this.count;
2040
- }
2041
- clear() {
2042
- this.byLine.clear();
2043
- this.count = 0;
1972
+ // src/api/EngineVersionCompatibility.ts
1973
+ var import_semver = __toESM(require_semver2(), 1);
1974
+ function checkEngineVersionCompatibility(pkg, engineVersion = ENGINE_VERSION) {
1975
+ if (!pkg.engineVersion) return { compatible: true };
1976
+ if ((0, import_semver.validRange)(pkg.engineVersion) === null) {
1977
+ return { compatible: false, reason: "invalid-range", declaredRange: pkg.engineVersion, engineVersion };
2044
1978
  }
2045
- /**
2046
- * Iterate all cache entries for diagnostics/debugging.
2047
- */
2048
- forEach(callback) {
2049
- for (const [line, entries] of this.byLine) {
2050
- for (const [exprKey, entry] of entries) {
2051
- callback(_LineCache.displayKey(line, exprKey), entry);
2052
- }
2053
- }
1979
+ const comparableVersion = (0, import_semver.coerce)(engineVersion)?.version ?? engineVersion;
1980
+ if (!(0, import_semver.satisfies)(comparableVersion, pkg.engineVersion)) {
1981
+ return { compatible: false, reason: "range-not-satisfied", declaredRange: pkg.engineVersion, engineVersion };
2054
1982
  }
2055
- /**
2056
- * Get all entry keys.
2057
- */
2058
- keys() {
2059
- const result = [];
2060
- for (const [line, entries] of this.byLine) {
2061
- for (const exprKey of entries.keys()) {
2062
- result.push(_LineCache.displayKey(line, exprKey));
2063
- }
2064
- }
2065
- return result;
1983
+ return { compatible: true };
1984
+ }
1985
+ function assertEngineVersionCompatible(pkg, engineVersion = ENGINE_VERSION) {
1986
+ const result = checkEngineVersionCompatibility(pkg, engineVersion);
1987
+ if (result.compatible) return;
1988
+ if (result.reason === "invalid-range") {
1989
+ throw ErrorFactory.config({
1990
+ code: "PACKAGE_ENGINE_VERSION_INVALID_RANGE",
1991
+ message: `Package "${pkg.name}" declares an invalid engineVersion range: "${result.declaredRange}" is not a valid semver range.`,
1992
+ expected: `a valid semver range string (e.g. "^0.1.0", ">=0.2.0 <1.0.0")`,
1993
+ found: `"${result.declaredRange}"`,
1994
+ suggestion: `This is a range-syntax typo in "${pkg.name}"'s own descriptor, not a version mismatch with the running engine \u2014 check for a stray character.`,
1995
+ context: { packageName: pkg.name, declaredRange: result.declaredRange }
1996
+ });
2066
1997
  }
2067
- };
1998
+ throw ErrorFactory.config({
1999
+ code: "PACKAGE_ENGINE_VERSION_MISMATCH",
2000
+ message: `Package "${pkg.name}" declares engineVersion "${result.declaredRange}", which is not satisfied by the running engine version "${result.engineVersion}".`,
2001
+ expected: `an engine version satisfying "${result.declaredRange}"`,
2002
+ found: `engine version "${result.engineVersion}"`,
2003
+ suggestion: `Update "${pkg.name}" to a version built against solve-engine ${result.engineVersion}, or pin solve-engine to a version satisfying "${result.declaredRange}".`,
2004
+ context: { packageName: pkg.name, declaredRange: result.declaredRange, engineVersion: result.engineVersion }
2005
+ });
2006
+ }
2068
2007
 
2069
2008
  // src/api/PackageCompatibility.ts
2070
2009
  function collectParseletConflicts(kind, fieldName, existingPkg, candidate, out) {
@@ -2200,858 +2139,364 @@ function checkPackageCompatibility(candidate, existing) {
2200
2139
  };
2201
2140
  }
2202
2141
 
2203
- // src/api/EngineVersionCompatibility.ts
2204
- var import_semver = __toESM(require_semver2(), 1);
2205
- function checkEngineVersionCompatibility(pkg, engineVersion = ENGINE_VERSION) {
2206
- if (!pkg.engineVersion) return { compatible: true };
2207
- if ((0, import_semver.validRange)(pkg.engineVersion) === null) {
2208
- return { compatible: false, reason: "invalid-range", declaredRange: pkg.engineVersion, engineVersion };
2209
- }
2210
- const comparableVersion = (0, import_semver.coerce)(engineVersion)?.version ?? engineVersion;
2211
- if (!(0, import_semver.satisfies)(comparableVersion, pkg.engineVersion)) {
2212
- return { compatible: false, reason: "range-not-satisfied", declaredRange: pkg.engineVersion, engineVersion };
2213
- }
2214
- return { compatible: true };
2142
+ // src/engine/EngineSnapshot.ts
2143
+ var SNAPSHOT_FORMAT = "solve-engine/snapshot";
2144
+ var SNAPSHOT_VERSION = 1;
2145
+ function encodeNumber(n) {
2146
+ if (Number.isFinite(n)) return n;
2147
+ if (Number.isNaN(n)) return "NaN";
2148
+ return n > 0 ? "Infinity" : "-Infinity";
2215
2149
  }
2216
- function assertEngineVersionCompatible(pkg, engineVersion = ENGINE_VERSION) {
2217
- const result = checkEngineVersionCompatibility(pkg, engineVersion);
2218
- if (result.compatible) return;
2219
- if (result.reason === "invalid-range") {
2220
- throw ErrorFactory.config({
2221
- code: "PACKAGE_ENGINE_VERSION_INVALID_RANGE",
2222
- message: `Package "${pkg.name}" declares an invalid engineVersion range: "${result.declaredRange}" is not a valid semver range.`,
2223
- expected: `a valid semver range string (e.g. "^0.1.0", ">=0.2.0 <1.0.0")`,
2224
- found: `"${result.declaredRange}"`,
2225
- suggestion: `This is a range-syntax typo in "${pkg.name}"'s own descriptor, not a version mismatch with the running engine \u2014 check for a stray character.`,
2226
- context: { packageName: pkg.name, declaredRange: result.declaredRange }
2227
- });
2228
- }
2229
- throw ErrorFactory.config({
2230
- code: "PACKAGE_ENGINE_VERSION_MISMATCH",
2231
- message: `Package "${pkg.name}" declares engineVersion "${result.declaredRange}", which is not satisfied by the running engine version "${result.engineVersion}".`,
2232
- expected: `an engine version satisfying "${result.declaredRange}"`,
2233
- found: `engine version "${result.engineVersion}"`,
2234
- suggestion: `Update "${pkg.name}" to a version built against solve-engine ${result.engineVersion}, or pin solve-engine to a version satisfying "${result.declaredRange}".`,
2235
- context: { packageName: pkg.name, declaredRange: result.declaredRange, engineVersion: result.engineVersion }
2150
+ function decodeNumber(n) {
2151
+ if (typeof n === "number") return n;
2152
+ if (n === "NaN") return NaN;
2153
+ if (n === "Infinity") return Infinity;
2154
+ if (n === "-Infinity") return -Infinity;
2155
+ throw ErrorFactory.validation({
2156
+ code: SnapshotErrorCodes.SNAPSHOT_MALFORMED,
2157
+ message: `Snapshot holds an unrecognised number sentinel: ${JSON.stringify(n)}`,
2158
+ expected: `a JSON number, or one of "NaN", "Infinity", "-Infinity"`,
2159
+ found: JSON.stringify(n)
2236
2160
  });
2237
2161
  }
2238
-
2239
- // src/workers/engine.worker.ts
2240
- var engine_worker_default = (() => {
2241
- throw new Error("engine.worker.ts was not processed by esbuild-plugin-inline-worker, so worker offload is unavailable");
2242
- });
2243
- var compileEngine = null;
2244
- function getCompileEngine() {
2245
- if (!compileEngine) {
2246
- compileEngine = new ExpressionEngine("en", false);
2162
+ var SnapshotErrorCodes = {
2163
+ /** The object handed to `fromJSON` is not a snapshot at all, or its version does not match this engine's reader. */
2164
+ SNAPSHOT_VERSION_MISMATCH: "SNAPSHOT_VERSION_MISMATCH",
2165
+ /** A snapshot with the right envelope but internally inconsistent contents (a bad number sentinel, a missing field). */
2166
+ SNAPSHOT_MALFORMED: "SNAPSHOT_MALFORMED",
2167
+ /** A value the snapshot format cannot yet represent (a symbolic expression, a symbolic matrix cell). Deferred, see the module doc. */
2168
+ SNAPSHOT_UNSUPPORTED_VALUE: "SNAPSHOT_UNSUPPORTED_VALUE"
2169
+ };
2170
+ function serializeDecimal(d) {
2171
+ return { coef: d.coef.toString(), scale: d.scale };
2172
+ }
2173
+ function deserializeDecimal(d) {
2174
+ return { coef: BigInt(d.coef), scale: d.scale };
2175
+ }
2176
+ function serializeRational(r) {
2177
+ return { n: r.n.toString(), d: r.d.toString() };
2178
+ }
2179
+ function deserializeRational(r) {
2180
+ return { n: BigInt(r.n), d: BigInt(r.d) };
2181
+ }
2182
+ function valueTypeName(type) {
2183
+ return ValueType[type] ?? String(type);
2184
+ }
2185
+ function unsupportedValue(type, where) {
2186
+ throw ErrorFactory.validation({
2187
+ code: SnapshotErrorCodes.SNAPSHOT_UNSUPPORTED_VALUE,
2188
+ message: `A ${valueTypeName(type)} value (${where}) cannot be included in a snapshot yet.`,
2189
+ expected: "a concrete number, string, boolean, unit, range, matrix, bigint, or money value",
2190
+ found: `${valueTypeName(type)} value`,
2191
+ suggestion: "Symbolic (algebra) values are deferred to a follow-up. Evaluate the document without the symbolic line, or re-derive it after restoring.",
2192
+ context: { valueType: type, location: where }
2193
+ });
2194
+ }
2195
+ function serializeValue(value, where) {
2196
+ switch (value.type) {
2197
+ case 0 /* Number */: {
2198
+ const out = { t: 0 /* Number */, v: encodeNumber(value.value) };
2199
+ if (value.exact !== void 0) out.exact = serializeDecimal(value.exact);
2200
+ if (value.rational !== void 0) out.rational = serializeRational(value.rational);
2201
+ return out;
2202
+ }
2203
+ case 1 /* Hex */: {
2204
+ const raw = value.value;
2205
+ if (typeof raw === "bigint") {
2206
+ return { t: 1 /* Hex */, v: raw.toString(), big: true, base: value.unit };
2207
+ }
2208
+ return { t: 1 /* Hex */, v: encodeNumber(raw), base: value.unit };
2209
+ }
2210
+ case 2 /* BigInt */:
2211
+ return { t: 2 /* BigInt */, v: value.value.toString() };
2212
+ case 3 /* String */:
2213
+ return { t: 3 /* String */, v: value.value };
2214
+ case 4 /* Datetime */:
2215
+ return { t: 4 /* Datetime */, v: encodeNumber(value.value) };
2216
+ case 5 /* Percentage */:
2217
+ return { t: 5 /* Percentage */, v: encodeNumber(value.value) };
2218
+ case 6 /* Uom */: {
2219
+ const out = { t: 6 /* Uom */, v: encodeNumber(value.value), unit: value.unit ?? "" };
2220
+ if (value.exact !== void 0) out.exact = serializeDecimal(value.exact);
2221
+ return out;
2222
+ }
2223
+ case 7 /* Matrix */: {
2224
+ const m = value.value;
2225
+ if (m.hasSymbolic) unsupportedValue(9 /* Symbolic */, `${where} (symbolic matrix cell)`);
2226
+ const data = m.data.map((cell) => serializeMatrixCell(cell, where));
2227
+ return { t: 7 /* Matrix */, rows: m.rows, cols: m.cols, data };
2228
+ }
2229
+ case 8 /* Range */: {
2230
+ const r = value.value;
2231
+ return { t: 8 /* Range */, min: encodeNumber(r.min), max: encodeNumber(r.max) };
2232
+ }
2233
+ case 10 /* Boolean */:
2234
+ return { t: 10 /* Boolean */, v: value.value };
2235
+ case 13 /* Error */:
2236
+ return { t: 13 /* Error */, code: value.value, message: value.unit ?? "" };
2237
+ default:
2238
+ return unsupportedValue(value.type, where);
2247
2239
  }
2248
- return compileEngine;
2249
2240
  }
2250
- var executeVm = null;
2251
- function getExecuteVm() {
2252
- if (!executeVm) {
2253
- executeVm = createVM(sharedOpRegistry, 200, 5e4);
2241
+ function serializeMatrixCell(cell, where) {
2242
+ if (typeof cell === "boolean") return cell;
2243
+ if (typeof cell === "number") return encodeNumber(cell);
2244
+ return unsupportedValue(9 /* Symbolic */, `${where} (symbolic matrix cell)`);
2245
+ }
2246
+ function deserializeValue(sv) {
2247
+ switch (sv.t) {
2248
+ case 0 /* Number */: {
2249
+ const v = new Value(0 /* Number */, decodeNumber(sv.v));
2250
+ if (sv.exact !== void 0) v.exact = deserializeDecimal(sv.exact);
2251
+ if (sv.rational !== void 0) v.rational = deserializeRational(sv.rational);
2252
+ return v;
2253
+ }
2254
+ case 1 /* Hex */: {
2255
+ const value = sv.big ? BigInt(sv.v) : decodeNumber(sv.v);
2256
+ return new Value(1 /* Hex */, value, sv.base);
2257
+ }
2258
+ case 2 /* BigInt */:
2259
+ return new Value(2 /* BigInt */, BigInt(sv.v));
2260
+ case 3 /* String */:
2261
+ return new Value(3 /* String */, sv.v);
2262
+ case 4 /* Datetime */:
2263
+ return new Value(4 /* Datetime */, decodeNumber(sv.v));
2264
+ case 5 /* Percentage */:
2265
+ return new Value(5 /* Percentage */, decodeNumber(sv.v));
2266
+ case 6 /* Uom */: {
2267
+ const v = new Value(6 /* Uom */, decodeNumber(sv.v), sv.unit);
2268
+ if (sv.exact !== void 0) v.exact = deserializeDecimal(sv.exact);
2269
+ return v;
2270
+ }
2271
+ case 7 /* Matrix */: {
2272
+ const data = sv.data.map((cell) => typeof cell === "boolean" ? cell : decodeNumber(cell));
2273
+ const m = { rows: sv.rows, cols: sv.cols, data, hasSymbolic: false };
2274
+ return new Value(7 /* Matrix */, m);
2275
+ }
2276
+ case 8 /* Range */:
2277
+ return new Value(8 /* Range */, { min: decodeNumber(sv.min), max: decodeNumber(sv.max) });
2278
+ case 10 /* Boolean */:
2279
+ return new Value(10 /* Boolean */, sv.v);
2280
+ case 13 /* Error */:
2281
+ return new Value(13 /* Error */, sv.code, sv.message);
2282
+ default:
2283
+ throw ErrorFactory.validation({
2284
+ code: SnapshotErrorCodes.SNAPSHOT_MALFORMED,
2285
+ message: `Snapshot holds a value with an unknown type tag: ${JSON.stringify(sv.t)}`,
2286
+ expected: "a known SerializedValue type tag",
2287
+ found: JSON.stringify(sv.t)
2288
+ });
2254
2289
  }
2255
- return executeVm;
2256
2290
  }
2257
- function compileOne(item) {
2258
- const base = {
2259
- lineId: item.lineId,
2260
- expression: item.expression,
2261
- compiledAgainstHash: item.textHash,
2262
- strings: [],
2263
- reads: [],
2264
- writes: [],
2265
- isVariableDef: false,
2266
- error: null
2291
+ function serializeBytecode(program) {
2292
+ const out = {
2293
+ opcodes: Array.from(program.opcodes),
2294
+ numbers: Array.from(program.numbers, encodeNumber),
2295
+ strings: program.strings.slice(),
2296
+ hasAsync: program.hasAsync
2267
2297
  };
2268
- try {
2269
- const eng = getCompileEngine();
2270
- const { program, reads, writes } = eng.compileExpression(item.expression);
2271
- const isVariableDef = writes.length > 0;
2272
- const opcodes = program.opcodes;
2273
- const numbers = program.numbers;
2274
- const opcodesBuffer = opcodes.buffer.slice(
2275
- opcodes.byteOffset,
2276
- opcodes.byteOffset + opcodes.byteLength
2277
- );
2278
- const numbersBuffer = numbers.buffer.slice(
2279
- numbers.byteOffset,
2280
- numbers.byteOffset + numbers.byteLength
2281
- );
2282
- return {
2283
- ...base,
2284
- opcodesBuffer,
2285
- numbersBuffer,
2286
- opcodesLength: opcodes.length,
2287
- numbersLength: numbers.length,
2288
- strings: [...program.strings],
2289
- reads,
2290
- writes,
2291
- isVariableDef
2292
- };
2293
- } catch (e) {
2294
- return {
2295
- ...base,
2296
- opcodesBuffer: new ArrayBuffer(0),
2297
- numbersBuffer: new ArrayBuffer(0),
2298
- opcodesLength: 0,
2299
- numbersLength: 0,
2300
- error: e instanceof Error ? e.message : String(e)
2301
- };
2298
+ if (program.constants) out.constants = Array.from(program.constants.entries());
2299
+ if (program.userFunctionBodies) out.userFunctionBodies = program.userFunctionBodies.map(serializeUserFunction);
2300
+ if (program.anonymousBodies) {
2301
+ out.anonymousBodies = program.anonymousBodies.map((b) => ({ params: b.params.slice(), program: serializeBytecode(b.program) }));
2302
2302
  }
2303
+ return out;
2303
2304
  }
2304
- function handleCompileBatch(msg) {
2305
- const results = [];
2306
- const transferList = [];
2307
- for (const item of msg.items) {
2308
- const result = compileOne(item);
2309
- results.push(result);
2310
- if (result.opcodesBuffer.byteLength > 0) transferList.push(result.opcodesBuffer);
2311
- if (result.numbersBuffer.byteLength > 0) transferList.push(result.numbersBuffer);
2305
+ function deserializeBytecode(sb) {
2306
+ const program = {
2307
+ opcodes: Uint8Array.from(sb.opcodes),
2308
+ numbers: Float64Array.from(sb.numbers, decodeNumber),
2309
+ strings: sb.strings.slice(),
2310
+ hasAsync: sb.hasAsync
2311
+ };
2312
+ if (sb.constants) program.constants = new Map(sb.constants);
2313
+ if (sb.userFunctionBodies) program.userFunctionBodies = sb.userFunctionBodies.map(deserializeUserFunction);
2314
+ if (sb.anonymousBodies) {
2315
+ program.anonymousBodies = sb.anonymousBodies.map((b) => ({ params: b.params.slice(), program: deserializeBytecode(b.program) }));
2312
2316
  }
2313
- self.postMessage(
2314
- { id: msg.id, type: "COMPILE_RESULT", results },
2315
- transferList
2316
- );
2317
+ return program;
2317
2318
  }
2318
- function executeOne(item) {
2319
- const vmm = getExecuteVm();
2320
- vmm.reset();
2321
- const opcodes = new Uint8Array(item.opcodesBuffer, 0, item.opcodesLength);
2322
- const numbers = new Float64Array(item.numbersBuffer, 0, item.numbersLength);
2323
- const bytecode = { opcodes, numbers, strings: item.strings };
2324
- try {
2325
- const result = executeBytecode(bytecode, vmm);
2326
- if (result.type === "pending") {
2327
- return {
2328
- lineNumber: item.lineNumber,
2329
- valueType: 12 /* Pending */,
2330
- value: 0,
2331
- isPending: true,
2332
- queryKey: result.queryKey
2333
- };
2334
- }
2335
- if (result.type === "error") {
2336
- return {
2337
- lineNumber: item.lineNumber,
2338
- valueType: 13 /* Error */,
2339
- value: 0,
2340
- isPending: false,
2341
- unit: result.error.message
2342
- };
2343
- }
2344
- const val = result.value;
2345
- return {
2346
- lineNumber: item.lineNumber,
2347
- valueType: val.type,
2348
- value: typeof val.value === "number" ? val.value : typeof val.value === "bigint" ? Number(val.value) : 0,
2349
- unit: val.unit,
2350
- isPending: false
2351
- };
2352
- } catch {
2353
- return {
2354
- lineNumber: item.lineNumber,
2355
- valueType: 13 /* Error */,
2356
- value: 0,
2357
- isPending: false,
2358
- unit: "Worker execution failed"
2359
- };
2360
- }
2319
+ function serializeUserFunction(fn) {
2320
+ return { name: fn.name, params: fn.params.slice(), program: serializeBytecode(fn.program) };
2361
2321
  }
2362
- function handleExecuteBatch(msg) {
2363
- const results = [];
2364
- for (const item of msg.items) {
2365
- results.push(executeOne(item));
2366
- }
2367
- self.postMessage({
2368
- id: msg.id,
2369
- type: "EXECUTE_RESULT",
2370
- results
2371
- });
2322
+ function deserializeUserFunction(fn) {
2323
+ return { name: fn.name, params: fn.params.slice(), program: deserializeBytecode(fn.program) };
2372
2324
  }
2373
- var inWorkerScope = typeof self !== "undefined" && typeof window === "undefined";
2374
- var handleMessage = (event) => {
2375
- if (event.origin && self.location && event.origin !== self.location.origin) return;
2376
- const msg = event.data;
2377
- switch (msg.type) {
2378
- case "COMPILE_BATCH":
2379
- handleCompileBatch(msg);
2380
- break;
2381
- case "EXECUTE_BATCH":
2382
- handleExecuteBatch(msg);
2383
- break;
2384
- case "TERMINATE": {
2385
- if (compileEngine) {
2386
- compileEngine.clear();
2387
- compileEngine = null;
2388
- }
2389
- if (executeVm) {
2390
- executeVm.reset();
2391
- executeVm = null;
2392
- }
2393
- self.postMessage({
2394
- id: msg.id,
2395
- type: "COMPILE_RESULT",
2396
- results: []
2397
- });
2398
- break;
2399
- }
2400
- default: {
2401
- self.postMessage({
2402
- id: -1,
2403
- type: "COMPILE_RESULT",
2404
- results: [],
2405
- error: `Unknown message type: ${msg.type}`
2406
- });
2407
- }
2325
+ function assertRestorable(snapshot) {
2326
+ if (typeof snapshot !== "object" || snapshot === null) {
2327
+ throw ErrorFactory.validation({
2328
+ code: SnapshotErrorCodes.SNAPSHOT_VERSION_MISMATCH,
2329
+ message: "fromJSON was given something that is not a snapshot object.",
2330
+ expected: `an object produced by ExpressionEngine.toJSON() with format "${SNAPSHOT_FORMAT}"`,
2331
+ found: snapshot === null ? "null" : typeof snapshot
2332
+ });
2408
2333
  }
2409
- };
2410
- if (inWorkerScope) {
2411
- self.onmessage = handleMessage;
2412
- }
2413
-
2414
- // src/engine/ExecutionPool.ts
2415
- var WORKER_BATCH_TIMEOUT_MS = 3e4;
2416
- var WORKER_OFFLOAD_THRESHOLD = 50;
2417
- var ExecutionPool = class {
2418
- constructor(poolSize) {
2419
- this.workers = [];
2420
- this.nextWorker = 0;
2421
- this.nextId = 1;
2422
- this.pendingBatches = /* @__PURE__ */ new Map();
2423
- this.terminated = false;
2424
- // ── Public API ────────────────────────────────────────────────────────
2425
- /** Cached availability flag. Checked once; createExecutionWorker never called again. */
2426
- this._available = null;
2427
- this.poolSize = Math.min(poolSize ?? this.defaultPoolSize(), 4);
2334
+ const candidate = snapshot;
2335
+ if (candidate.format !== SNAPSHOT_FORMAT) {
2336
+ throw ErrorFactory.validation({
2337
+ code: SnapshotErrorCodes.SNAPSHOT_VERSION_MISMATCH,
2338
+ message: `This object is not a solve-engine snapshot (its "format" is ${JSON.stringify(candidate.format)}).`,
2339
+ expected: `format "${SNAPSHOT_FORMAT}"`,
2340
+ found: JSON.stringify(candidate.format)
2341
+ });
2428
2342
  }
2429
- defaultPoolSize() {
2430
- if (typeof navigator !== "undefined" && navigator.hardwareConcurrency) {
2431
- return Math.min(navigator.hardwareConcurrency, 4);
2432
- }
2433
- return 2;
2343
+ if (candidate.version !== SNAPSHOT_VERSION) {
2344
+ throw ErrorFactory.validation({
2345
+ code: SnapshotErrorCodes.SNAPSHOT_VERSION_MISMATCH,
2346
+ message: `This snapshot was written for format version ${candidate.version}, but this engine restores version ${SNAPSHOT_VERSION}.`,
2347
+ expected: `snapshot version ${SNAPSHOT_VERSION}`,
2348
+ found: `version ${candidate.version}`,
2349
+ suggestion: "Regenerate the snapshot with a matching engine version, or re-evaluate the document from source.",
2350
+ context: { snapshotVersion: candidate.version, readerVersion: SNAPSHOT_VERSION, engineVersion: candidate.engineVersion }
2351
+ });
2434
2352
  }
2435
- /**
2436
- * Whether worker-based execution is available.
2437
- * Returns false in Node.js / jsdom test environments where Worker
2438
- * may be polyfilled but execution workers aren't functional.
2439
- */
2440
- isAvailable() {
2441
- if (this.terminated) return false;
2442
- if (this._available !== null) return this._available;
2443
- if (typeof Worker === "undefined") {
2444
- this._available = false;
2445
- return false;
2446
- }
2447
- try {
2448
- const w = engine_worker_default();
2449
- w.terminate();
2450
- this._available = true;
2451
- return true;
2452
- } catch {
2453
- this._available = false;
2454
- return false;
2455
- }
2353
+ }
2354
+
2355
+ // src/cache/LineCache.ts
2356
+ var LineCacheEntry = class {
2357
+ constructor(result, bytecode, readVariables, writeVariable) {
2358
+ this.result = result;
2359
+ this.bytecode = bytecode;
2360
+ this.readVariables = readVariables;
2361
+ this.writeVariable = writeVariable;
2456
2362
  }
2457
- /**
2458
- * Execute a batch of line entries via the worker pool.
2459
- *
2460
- * Takes ordered line numbers and their LineCache entries. Clones bytecode
2461
- * ArrayBuffers for transfer, dispatches to workers round-robin, and
2462
- * returns the serialized results (with 30s timeout fallback).
2463
- *
2464
- * Falls back to undefined when workers are unavailable, caller should
2465
- * use the main-thread path.
2466
- *
2467
- * @returns ExecuteResult[] on success, undefined if workers unavailable.
2468
- */
2469
- executeBatch(orderedLineNumbers, entries) {
2470
- if (!this.isAvailable()) return void 0;
2471
- if (orderedLineNumbers.length === 0) return Promise.resolve([]);
2472
- this.ensureWorkers();
2473
- const items = [];
2474
- for (const lineNumber of orderedLineNumbers) {
2475
- const entry = entries.get(lineNumber);
2476
- if (!entry || entry.bytecode.opcodes.length === 0) continue;
2477
- const opcodes = entry.bytecode.opcodes;
2478
- const numbers = entry.bytecode.numbers;
2479
- const opcodesClone = opcodes.buffer.slice(
2480
- opcodes.byteOffset,
2481
- opcodes.byteOffset + opcodes.byteLength
2482
- );
2483
- const numbersClone = numbers.buffer.slice(
2484
- numbers.byteOffset,
2485
- numbers.byteOffset + numbers.byteLength
2486
- );
2487
- items.push({
2488
- lineNumber,
2489
- opcodesBuffer: opcodesClone,
2490
- numbersBuffer: numbersClone,
2491
- opcodesLength: opcodes.length,
2492
- numbersLength: numbers.length,
2493
- strings: [...entry.bytecode.strings]
2494
- });
2495
- }
2496
- if (items.length === 0) return Promise.resolve([]);
2497
- const transferList = [];
2498
- for (const item of items) {
2499
- if (item.opcodesBuffer.byteLength > 0) transferList.push(item.opcodesBuffer);
2500
- if (item.numbersBuffer.byteLength > 0) transferList.push(item.numbersBuffer);
2501
- }
2502
- const batchId = this.nextId++;
2503
- const worker = this.getNextWorker();
2504
- return new Promise((resolve) => {
2505
- const timeoutId = setTimeout(() => {
2506
- this.pendingBatches.delete(batchId);
2507
- resolve([]);
2508
- }, WORKER_BATCH_TIMEOUT_MS);
2509
- this.pendingBatches.set(batchId, {
2510
- id: batchId,
2511
- lineNumbers: orderedLineNumbers,
2512
- resolve: (results) => {
2513
- clearTimeout(timeoutId);
2514
- resolve(results);
2515
- }
2516
- });
2517
- worker.postMessage(
2518
- { type: "EXECUTE_BATCH", id: batchId, items },
2519
- transferList
2520
- );
2521
- });
2363
+ };
2364
+ var LineCache = class _LineCache {
2365
+ constructor() {
2366
+ /** line number (expression, or "" for expressionless entries) entry */
2367
+ this.byLine = /* @__PURE__ */ new Map();
2368
+ this.count = 0;
2522
2369
  }
2523
- /**
2524
- * Clean up all workers and pending batches. Called on engine clear.
2525
- * Workers are recreated lazily on next dispatch.
2526
- */
2527
- clear() {
2528
- for (const [, batch] of this.pendingBatches) {
2529
- batch.resolve([]);
2530
- }
2531
- this.pendingBatches.clear();
2532
- for (const w of this.workers) {
2533
- w.terminate();
2534
- }
2535
- this.workers = [];
2536
- this.nextWorker = 0;
2370
+ static exprKey(expression) {
2371
+ return expression ?? "";
2537
2372
  }
2538
- /**
2539
- * Full teardown. After destroy(), the pool is permanently unusable.
2540
- */
2541
- destroy() {
2542
- this.terminated = true;
2543
- this.clear();
2373
+ static displayKey(line, exprKey) {
2374
+ return exprKey === "" ? `${line}` : `${line}:${exprKey}`;
2544
2375
  }
2545
- // ── Private ────────────────────────────────────────────────────────────
2546
- ensureWorkers() {
2547
- if (this.workers.length > 0) return;
2548
- for (let i = 0; i < this.poolSize; i++) {
2549
- const worker = engine_worker_default();
2550
- worker.onmessage = (event) => {
2551
- this.handleWorkerMessage(event.data);
2552
- };
2553
- worker.onerror = (err) => {
2554
- console.error(`[ExecutionPool] Worker ${i} error:`, err.message);
2555
- for (const [id, batch] of this.pendingBatches) {
2556
- batch.resolve([]);
2557
- this.pendingBatches.delete(id);
2558
- }
2559
- };
2560
- this.workers.push(worker);
2561
- }
2562
- }
2563
- getNextWorker() {
2564
- const w = this.workers[this.nextWorker];
2565
- this.nextWorker = (this.nextWorker + 1) % this.workers.length;
2566
- return w;
2567
- }
2568
- handleWorkerMessage(data) {
2569
- if (data.type !== "EXECUTE_RESULT") return;
2570
- const batch = this.pendingBatches.get(data.id);
2571
- if (!batch) return;
2572
- this.pendingBatches.delete(data.id);
2573
- batch.resolve(data.results);
2574
- }
2575
- };
2576
- function reconstructValue(result) {
2577
- switch (result.valueType) {
2578
- case 0 /* Number */:
2579
- return numberValue(result.value);
2580
- case 1 /* Hex */:
2581
- return hexValue(result.value);
2582
- case 2 /* BigInt */:
2583
- return bigIntValue(BigInt(result.value));
2584
- case 3 /* String */:
2585
- return stringValue(String(result.value));
2586
- case 4 /* Datetime */:
2587
- return datetimeValue(result.value);
2588
- case 5 /* Percentage */:
2589
- return percentageValue(result.value);
2590
- case 6 /* Uom */:
2591
- return uomValue(result.value, result.unit ?? "");
2592
- case 7 /* Matrix */:
2593
- return numberValue(result.value);
2594
- case 10 /* Boolean */:
2595
- return boolValue(result.value !== 0);
2596
- case 12 /* Pending */:
2597
- return pendingValue(result.queryKey ?? "");
2598
- case 13 /* Error */:
2599
- return errorValue("WORKER_EXECUTION_ERROR", result.unit ?? "Unknown worker error");
2600
- default:
2601
- return numberValue(result.value);
2602
- }
2603
- }
2604
-
2605
- // src/engine/AsyncResolutionBatcher.ts
2606
- var _AsyncResolutionBatcher = class _AsyncResolutionBatcher {
2607
- constructor(dag, lineCache, vm, highWaterMark = _AsyncResolutionBatcher.DEFAULT_HIGH_WATER_MARK) {
2608
- this.pending = [];
2609
- this.scheduled = false;
2610
- /** Set to true by clearPending(), flush() checks this to abort stale work. */
2611
- this.cleared = false;
2612
- /**
2613
- * Worker pool for offloading VM re-execution when the affected line
2614
- * count exceeds WORKER_OFFLOAD_THRESHOLD (50). Lazily created on
2615
- * first dispatch; cleared on clearAll().
2616
- */
2617
- this.executionPool = null;
2618
- /**
2619
- * Number of flushes actually dispatched to the worker pool (as opposed
2620
- * to falling back to the main thread because Worker is unavailable).
2621
- * Exposed via {@link workerOffloadCount} for the Workers diagnostic tab.
2622
- */
2623
- this.workerOffloadDispatchCount = 0;
2624
- /**
2625
- * Controller for the internal event stream. Set during stream
2626
- * initialization; cleared on stream cancellation or clearAll().
2627
- */
2628
- this._streamController = null;
2629
- /**
2630
- * Test-only synchronous capture array. When enabled (non-null), every
2631
- * event is synchronously pushed here in addition to the stream.
2632
- * Tests read from this array to avoid async stream reader timing issues.
2633
- */
2634
- this._testCaptures = null;
2635
- /**
2636
- * Called for each line whose result is patched after an async resolution,
2637
- * on both the main-thread and worker-pool paths.
2638
- *
2639
- * **A host that displays async results must set this.** It is the only
2640
- * mechanism that moves a resolved value out of the LineCache and into the
2641
- * host's own document state. The engine cannot do it itself: it does not own
2642
- * a document, the host does, and the batcher has no reference to one.
2643
- *
2644
- * Nullable rather than a constructor parameter because it is cleared by
2645
- * `clearAll()` and re-wired on re-subscribe, so it cannot be readonly. That
2646
- * makes it easy to miss, which is why {@link warnIfUnwired} exists: leaving
2647
- * it unset means async values resolve into the cache and are never shown,
2648
- * with nothing to indicate why. A host that genuinely does not want async
2649
- * results should not register async resolvers at all.
2650
- */
2651
- this.onLineResult = null;
2652
- /**
2653
- * Whether {@link warnIfUnwired} has already fired.
2654
- *
2655
- * Once per instance, not once per resolution. A document with fifty live
2656
- * data lines would otherwise produce fifty identical warnings on the first
2657
- * fetch, which is how a useful warning becomes noise people filter out.
2658
- */
2659
- this.warnedAboutMissingHook = false;
2660
- this.dag = dag;
2661
- this.lineCache = lineCache;
2662
- this.vm = vm;
2663
- this.highWaterMark = highWaterMark;
2664
- this._eventStream = this.createEventStream();
2665
- }
2666
- /**
2667
- * Warn once if an async result resolved with no {@link onLineResult} wired.
2668
- *
2669
- * The failure this catches is silent by nature: the value arrives, the cache
2670
- * updates, and the line keeps showing pending forever. Without this a host
2671
- * author has no thread to pull on.
2672
- */
2673
- warnIfUnwired() {
2674
- if (this.onLineResult || this.warnedAboutMissingHook) return;
2675
- this.warnedAboutMissingHook = true;
2676
- console.warn(
2677
- "[solve-engine] An async result resolved but AsyncResolutionBatcher.onLineResult is not set, so the value cannot reach your document and the line will keep showing as pending. Set it to mirror resolved values into your own state. This warning appears once per batcher."
2678
- );
2679
- }
2680
- /**
2681
- * Create a fresh internal event stream and wire its controller.
2682
- * Called from the constructor and again from clearAll() so the batcher
2683
- * keeps emitting events after an engine clear, the engine instance
2684
- * (and this batcher) live on across clear() calls.
2685
- */
2686
- createEventStream() {
2687
- return new ReadableStream({
2688
- start: (controller) => {
2689
- this._streamController = controller;
2690
- },
2691
- cancel: () => {
2692
- this._streamController = null;
2693
- }
2694
- }, new CountQueuingStrategy({ highWaterMark: this.highWaterMark }));
2695
- }
2696
- // ── Public API ────────────────────────────────────────────────────
2697
- /**
2698
- * Add a resolved query key to the pending batch.
2699
- *
2700
- * Called by ExpressionEngine.resolveAsync() after a promise resolves or errors.
2701
- * If this is the first entry in the current tick, schedules a microtask flush.
2702
- */
2703
- add(entry) {
2704
- this.cleared = false;
2705
- for (const existing of this.pending) {
2706
- if (existing.packageId === entry.packageId && existing.queryKey === entry.queryKey) {
2707
- return;
2708
- }
2709
- }
2710
- this.pending.push(entry);
2711
- if (!this.scheduled) {
2712
- this.scheduled = true;
2713
- queueMicrotask(() => {
2714
- try {
2715
- this.flush();
2716
- } catch (e) {
2717
- const engineError = normalizeUnknownError(e);
2718
- console.error(`[AsyncResolutionBatcher] flush() failed unexpectedly \u2014 this should never happen; please report: ${engineError.format()}`);
2719
- }
2720
- });
2721
- }
2722
- }
2723
- /**
2724
- * Get the native event stream for stream-based consumers.
2725
- *
2726
- * Use this for backpressure, cancellation, or the ability
2727
- * to `pipeTo()` / `pipeThrough()` the event flow.
2728
- *
2729
- * @returns A {@link ReadableStream} that emits {@link AsyncResolutionEvent}
2730
- * items as the batcher processes async resolutions.
2731
- */
2732
- getEventStream() {
2733
- return this._eventStream;
2734
- }
2735
- /** Number of resolutions currently queued for the next flush. */
2736
- get pendingCount() {
2737
- return this.pending.length;
2738
- }
2739
- /** Number of pending entries collapsed by (packageId, queryKey) deduplication. */
2740
- get dedupCount() {
2741
- const dedup = /* @__PURE__ */ new Set();
2742
- for (const entry of this.pending) {
2743
- dedup.add(`${entry.packageId}:${entry.queryKey}`);
2744
- }
2745
- return Math.max(0, this.pending.length - dedup.size);
2746
- }
2747
- /**
2748
- * Whether the internal event stream currently has an active reader.
2749
- * `1` if a consumer has called `getEventStream().getReader()` (or
2750
- * otherwise locked the stream) and not released it, `0` otherwise.
2751
- */
2752
- get listenerCount() {
2753
- return this._eventStream.locked ? 1 : 0;
2754
- }
2755
- /** Number of flushes that were actually dispatched to the worker pool. */
2756
- get workerOffloadCount() {
2757
- return this.workerOffloadDispatchCount;
2758
- }
2759
- /** Remove all listeners and cancel pending batch. Called on engine clear. */
2760
- clearAll() {
2761
- this.pending = [];
2762
- this.scheduled = false;
2763
- this.cleared = true;
2764
- this._testCaptures = null;
2765
- this.onLineResult = null;
2766
- try {
2767
- this._streamController?.close();
2768
- } catch {
2769
- }
2770
- this._streamController = null;
2771
- this._eventStream = this.createEventStream();
2772
- if (this.executionPool) {
2773
- this.executionPool.clear();
2774
- this.executionPool = null;
2775
- }
2776
- this.workerOffloadDispatchCount = 0;
2777
- }
2778
- // ── Private: flush ────────────────────────────────────────────────
2779
- /**
2780
- * Flush all pending resolutions in a single batched pass.
2781
- *
2782
- * Called automatically via queueMicrotask. Never called directly.
2783
- */
2784
- flush() {
2785
- this.scheduled = false;
2786
- if (this.cleared) return;
2787
- if (this.pending.length === 0) return;
2788
- const batch = this.pending;
2789
- this.pending = [];
2790
- const deduped = /* @__PURE__ */ new Map();
2791
- for (const entry of batch) {
2792
- const compositeKey = `${entry.packageId}:${entry.queryKey}`;
2793
- deduped.set(compositeKey, entry);
2794
- }
2795
- const errorEntries = [];
2796
- const okEntries = [];
2797
- for (const entry of deduped.values()) {
2798
- if (entry.isError) {
2799
- errorEntries.push(entry);
2800
- } else {
2801
- okEntries.push(entry);
2802
- }
2803
- }
2804
- for (const entry of errorEntries) {
2805
- if (entry.signal.aborted) continue;
2806
- this.notifyListeners({
2807
- type: "error",
2808
- queryKey: entry.queryKey,
2809
- packageId: entry.packageId,
2810
- error: entry.error ?? new Error("Unknown async resolution error")
2811
- });
2812
- }
2813
- const allAffected = /* @__PURE__ */ new Set();
2814
- const allQueryKeys = [];
2815
- const allEntries = [...okEntries, ...errorEntries];
2816
- for (const entry of allEntries) {
2817
- if (entry.signal.aborted) continue;
2818
- allQueryKeys.push(entry.queryKey);
2819
- const affected = this.dag.getAffectedLinesByDataSource(
2820
- entry.packageId,
2821
- [entry.queryKey]
2822
- );
2823
- for (const line of affected) {
2824
- allAffected.add(line);
2825
- }
2376
+ get(line, expression) {
2377
+ return this.byLine.get(line)?.get(_LineCache.exprKey(expression));
2378
+ }
2379
+ /** Find any cache entry for the given line number, regardless of expression suffix */
2380
+ getEntryForLine(line) {
2381
+ const entries = this.byLine.get(line);
2382
+ if (!entries) return void 0;
2383
+ for (const entry of entries.values()) {
2384
+ return entry;
2826
2385
  }
2827
- if (allAffected.size === 0) {
2828
- this.notifyListeners({
2829
- type: "lines-updated",
2830
- lineNumbers: [],
2831
- affectedQueryKeys: allQueryKeys
2832
- });
2833
- return;
2386
+ return void 0;
2387
+ }
2388
+ set(line, entry, expression) {
2389
+ let entries = this.byLine.get(line);
2390
+ if (!entries) {
2391
+ entries = /* @__PURE__ */ new Map();
2392
+ this.byLine.set(line, entries);
2834
2393
  }
2835
- const ordered = this.topologicalSort(Array.from(allAffected));
2836
- if (ordered.length > WORKER_OFFLOAD_THRESHOLD) {
2837
- const entryMap = /* @__PURE__ */ new Map();
2838
- for (const lineNumber of ordered) {
2839
- entryMap.set(lineNumber, this.lineCache.getEntryForLine(lineNumber));
2840
- }
2841
- void this.reExecuteViaWorkerPool(ordered, entryMap, allQueryKeys).catch((e) => {
2842
- const engineError = normalizeUnknownError(e);
2843
- console.error(`[AsyncResolutionBatcher] reExecuteViaWorkerPool() failed unexpectedly: ${engineError.format()}`);
2844
- });
2845
- return;
2394
+ const key = _LineCache.exprKey(expression);
2395
+ if (entries.size > 0 && !entries.has(key)) {
2396
+ this.count -= entries.size;
2397
+ entries.clear();
2846
2398
  }
2847
- this.reExecuteMainThread(ordered, allQueryKeys);
2399
+ if (!entries.has(key)) this.count++;
2400
+ entries.set(key, entry);
2401
+ }
2402
+ has(line, expression) {
2403
+ return this.byLine.get(line)?.has(_LineCache.exprKey(expression)) ?? false;
2404
+ }
2405
+ remove(line, expression) {
2406
+ const entries = this.byLine.get(line);
2407
+ if (!entries) return;
2408
+ if (entries.delete(_LineCache.exprKey(expression))) {
2409
+ this.count--;
2410
+ if (entries.size === 0) this.byLine.delete(line);
2411
+ }
2412
+ }
2413
+ removeAllForLine(line) {
2414
+ const entries = this.byLine.get(line);
2415
+ if (!entries) return;
2416
+ this.count -= entries.size;
2417
+ this.byLine.delete(line);
2418
+ }
2419
+ clearLine(line) {
2420
+ this.removeAllForLine(line);
2848
2421
  }
2849
- // ── Private: topological sort ─────────────────────────────────────
2850
2422
  /**
2851
- * Topologically sort affected lines using Kahn's algorithm.
2852
- *
2853
- * Lines that produce variables come before lines that consume them.
2854
- * This ensures correct evaluation order when multiple interdependent
2855
- * lines are affected by async resolution.
2423
+ * Number of entries in the cache. Useful for diagnostics.
2856
2424
  */
2857
- topologicalSort(lines) {
2858
- if (lines.length <= 1) return lines;
2859
- const inDegree = /* @__PURE__ */ new Map();
2860
- const adjacency = /* @__PURE__ */ new Map();
2861
- const producerOf = /* @__PURE__ */ new Map();
2862
- for (const line of lines) {
2863
- inDegree.set(line, 0);
2864
- adjacency.set(line, []);
2865
- }
2866
- for (const line of lines) {
2867
- const writes = this.dag.getWrites(line);
2868
- for (const w of writes) {
2869
- producerOf.set(w, line);
2870
- }
2871
- }
2872
- for (const line of lines) {
2873
- const reads = this.dag.getDependencies(line);
2874
- for (const readVar of reads) {
2875
- const producer = producerOf.get(readVar);
2876
- if (producer !== void 0 && producer !== line) {
2877
- adjacency.get(producer).push(line);
2878
- inDegree.set(line, (inDegree.get(line) ?? 0) + 1);
2879
- }
2880
- }
2881
- }
2882
- const queue = [];
2883
- for (const [line, degree] of inDegree) {
2884
- if (degree === 0) queue.push(line);
2885
- }
2886
- if (queue.length === 0 && lines.length > 0) {
2887
- return [...lines].sort((a, b) => a - b);
2888
- }
2889
- const ordered = [];
2890
- while (queue.length > 0) {
2891
- const current = queue.shift();
2892
- ordered.push(current);
2893
- for (const downstream of adjacency.get(current) ?? []) {
2894
- const newDegree = (inDegree.get(downstream) ?? 1) - 1;
2895
- inDegree.set(downstream, newDegree);
2896
- if (newDegree === 0) queue.push(downstream);
2897
- }
2898
- }
2899
- if (ordered.length < lines.length) {
2900
- const remaining = lines.filter((l) => !ordered.includes(l)).sort((a, b) => a - b);
2901
- ordered.push(...remaining);
2902
- }
2903
- return ordered;
2425
+ get size() {
2426
+ return this.count;
2427
+ }
2428
+ clear() {
2429
+ this.byLine.clear();
2430
+ this.count = 0;
2904
2431
  }
2905
- // ── Private: worker-pool re-execution ─────────────────────────────
2906
2432
  /**
2907
- * Offload VM re-execution to the worker pool for large batches.
2908
- *
2909
- * Called when ordered.length > WORKER_OFFLOAD_THRESHOLD (50).
2910
- * Clones bytecode ArrayBuffers, dispatches to workers, and asynchronously
2911
- * patches results back into LineCache before notifying listeners.
2912
- *
2913
- * Handles pending results: lines that return { type: 'pending' } from the
2914
- * worker are NOT marked as updated, the engine's resolveAsync will handle
2915
- * them when the async resolver completes.
2916
- *
2917
- * Safety: checks this.cleared before applying results, if the engine was
2918
- * cleared while the worker batch was in-flight, results are discarded.
2433
+ * Iterate all cache entries for diagnostics/debugging.
2919
2434
  */
2920
- async reExecuteViaWorkerPool(ordered, entryMap, allQueryKeys) {
2921
- if (!this.executionPool) {
2922
- this.executionPool = new ExecutionPool();
2923
- }
2924
- const results = this.executionPool.executeBatch(ordered, entryMap);
2925
- if (!results) {
2926
- this.reExecuteMainThread(ordered, allQueryKeys, entryMap);
2927
- return;
2928
- }
2929
- this.workerOffloadDispatchCount++;
2930
- const workerResults = await results;
2931
- if (this.cleared) return;
2932
- const updatedLineNumbers = [];
2933
- for (const wr of workerResults) {
2934
- const entry = entryMap.get(wr.lineNumber);
2935
- if (!entry) continue;
2936
- if (wr.isPending) {
2937
- continue;
2435
+ forEach(callback) {
2436
+ for (const [line, entries] of this.byLine) {
2437
+ for (const [exprKey, entry] of entries) {
2438
+ callback(_LineCache.displayKey(line, exprKey), entry);
2938
2439
  }
2939
- const value = reconstructValue(wr);
2940
- entry.result = value;
2941
- this.warnIfUnwired();
2942
- this.onLineResult?.(wr.lineNumber, value);
2943
- updatedLineNumbers.push(wr.lineNumber);
2944
- }
2945
- if (this.cleared) return;
2946
- if (updatedLineNumbers.length > 0 || allQueryKeys.length > 0) {
2947
- this.notifyListeners({
2948
- type: "lines-updated",
2949
- lineNumbers: updatedLineNumbers,
2950
- affectedQueryKeys: allQueryKeys
2951
- });
2952
2440
  }
2953
2441
  }
2954
- // ── Private: main-thread re-execution ────────────────────────────
2955
2442
  /**
2956
- * Execute ordered lines on the main thread, update LineCache, and notify
2957
- * listeners. Returns the list of line numbers that actually changed.
2958
- *
2959
- * Used by both flush() (≤50 lines) and reExecuteViaWorkerPool() (fallback
2960
- * when workers are unavailable). Extracted to avoid code duplication.
2961
- *
2962
- * **Per-line containment (fatal-bug fix)**: `executeBytecode()` used to
2963
- * run here with NO try/catch anywhere in this method's call chain, and
2964
- * this whole batch runs inside a bare `queueMicrotask` (see `add()`) with
2965
- * no surrounding try/catch at any caller either, so if any ONE line's
2966
- * cached bytecode threw (a stack/instruction-limit error, an undefined
2967
- * variable, a corrupted-bytecode `TypeError`), the `for` loop aborted
2968
- * immediately: every line scheduled AFTER the failure in this batch was
2969
- * silently never re-executed or notified even though nothing was wrong
2970
- * with them, every line BEFORE it had already had its `entry.result`
2971
- * mutated in-place but `notifyListeners()` was never reached (a silent
2972
- * `LineCache`/host desync), and, because a bare `queueMicrotask`
2973
- * callback has no caller to catch it, the exception was uncatchable:
2974
- * an `uncaughtException` that could crash the host process outright.
2975
- * (`__tests__/async/AsyncResolutionBatcher.spec.ts`'s topological-sort
2976
- * describe block used to have a test skipped specifically because of
2977
- * this. See that file, now un-skipped and rewritten.) Each line's
2978
- * execution is now its own try/catch: a failure is recorded as an
2979
- * `Error` `Value` for THAT line (still counted as "updated" so the host
2980
- * learns about it and stops showing a stale Pending state) and the loop
2981
- * continues, one line's failure can no longer take out its neighbors.
2443
+ * Structured iteration for serialization: one record per stored entry,
2444
+ * carrying the raw line number and expression key rather than the lossy
2445
+ * "line:expression" display key `keys()`/`forEach()` produce (an expression
2446
+ * containing a colon cannot be split back apart from that string). Used by
2447
+ * the engine snapshot to walk every entry without re-parsing display keys.
2982
2448
  */
2983
- reExecuteMainThread(ordered, allQueryKeys, entryMap) {
2984
- const updatedLineNumbers = [];
2985
- for (const lineNumber of ordered) {
2986
- const entry = entryMap ? entryMap.get(lineNumber) : this.lineCache.getEntryForLine(lineNumber);
2987
- if (!entry || entry.bytecode.opcodes.length === 0) continue;
2988
- const stackBefore = this.vm.getStack().length;
2989
- try {
2990
- const result = executeBytecode(entry.bytecode, this.vm);
2991
- while (this.vm.getStack().length > stackBefore) {
2992
- this.vm.pop();
2993
- }
2994
- if (result.type === "value") {
2995
- entry.result = result.value;
2996
- this.warnIfUnwired();
2997
- this.onLineResult?.(lineNumber, result.value);
2998
- updatedLineNumbers.push(lineNumber);
2999
- } else if (result.type === "error") {
3000
- const value = errorValue(result.error.code, result.error.message);
3001
- entry.result = value;
3002
- this.warnIfUnwired();
3003
- this.warnIfUnwired();
3004
- this.onLineResult?.(lineNumber, value);
3005
- updatedLineNumbers.push(lineNumber);
3006
- }
3007
- } catch (e) {
3008
- while (this.vm.getStack().length > stackBefore) {
3009
- this.vm.pop();
3010
- }
3011
- const engineError = normalizeUnknownError(e);
3012
- const value = errorValue(engineError.code, engineError.message);
3013
- entry.result = value;
3014
- this.warnIfUnwired();
3015
- this.onLineResult?.(lineNumber, value);
3016
- updatedLineNumbers.push(lineNumber);
2449
+ snapshotEntries() {
2450
+ const out = [];
2451
+ for (const [line, entries] of this.byLine) {
2452
+ for (const [exprKey, entry] of entries) {
2453
+ out.push({ line, expression: exprKey, entry });
3017
2454
  }
3018
2455
  }
3019
- if (updatedLineNumbers.length > 0 || allQueryKeys.length > 0) {
3020
- this.notifyListeners({
3021
- type: "lines-updated",
3022
- lineNumbers: updatedLineNumbers,
3023
- affectedQueryKeys: allQueryKeys
3024
- });
3025
- }
3026
- return updatedLineNumbers;
2456
+ return out;
3027
2457
  }
3028
2458
  /**
3029
- * Notify all consumers of an async resolution event.
3030
- *
3031
- * Enqueues the event into the internal {@link ReadableStream}.
3032
- * If the stream has been closed or errored (consumer cancelled),
3033
- * the enqueue silently fails (caught by try/catch).
3034
- */
3035
- notifyListeners(event) {
3036
- if (this._testCaptures) {
3037
- this._testCaptures.push(event);
3038
- }
3039
- if (this._streamController) {
3040
- try {
3041
- this._streamController.enqueue(event);
3042
- } catch {
2459
+ * Get all entry keys.
2460
+ */
2461
+ keys() {
2462
+ const result = [];
2463
+ for (const [line, entries] of this.byLine) {
2464
+ for (const exprKey of entries.keys()) {
2465
+ result.push(_LineCache.displayKey(line, exprKey));
3043
2466
  }
3044
2467
  }
2468
+ return result;
3045
2469
  }
3046
2470
  };
3047
- // ── Web Streams API integration ──────────────────────────────────
3048
- /**
3049
- * Default high-water mark for the internal event stream.
3050
- * Limits the internal buffer size before the stream signals to
3051
- * consumers that they need to catch up.
3052
- */
3053
- _AsyncResolutionBatcher.DEFAULT_HIGH_WATER_MARK = 64;
3054
- var AsyncResolutionBatcher = _AsyncResolutionBatcher;
2471
+
2472
+ // src/vm/HolidayCalendar.ts
2473
+ function localDateKey(epochMs) {
2474
+ const d = new Date(epochMs);
2475
+ const month = String(d.getMonth() + 1).padStart(2, "0");
2476
+ const day = String(d.getDate()).padStart(2, "0");
2477
+ return `${d.getFullYear()}-${month}-${day}`;
2478
+ }
2479
+ function entryToKey(entry) {
2480
+ if (entry instanceof Date) return localDateKey(entry.getTime());
2481
+ if (typeof entry === "number") {
2482
+ return Number.isFinite(entry) ? localDateKey(entry) : null;
2483
+ }
2484
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(entry.trim());
2485
+ if (!match) return null;
2486
+ return `${match[1]}-${match[2]}-${match[3]}`;
2487
+ }
2488
+ function resolveHolidayPredicate(calendar) {
2489
+ if (calendar === void 0) return void 0;
2490
+ if (typeof calendar === "function") {
2491
+ return (epochMs) => calendar(new Date(epochMs));
2492
+ }
2493
+ const keys = /* @__PURE__ */ new Set();
2494
+ for (const entry of calendar) {
2495
+ const key = entryToKey(entry);
2496
+ if (key !== null) keys.add(key);
2497
+ }
2498
+ return (epochMs) => keys.has(localDateKey(epochMs));
2499
+ }
3055
2500
 
3056
2501
  // src/telemetry/AllocationTracker.ts
3057
2502
  var AllocationTracker = class {
@@ -3346,8 +2791,318 @@ function findInlineSolvesInLine(lineText, lineNumber) {
3346
2791
  }));
3347
2792
  }
3348
2793
 
2794
+ // src/packages/uom/UserUnitTable.ts
2795
+ function pluralInsensitiveKey(words) {
2796
+ const last = words[words.length - 1];
2797
+ const singular = last.length > 1 && last.endsWith("s") ? last.slice(0, -1) : last;
2798
+ return [...words.slice(0, -1), singular].join(" ");
2799
+ }
2800
+ var UserUnitTable = class {
2801
+ constructor() {
2802
+ this.byKey = /* @__PURE__ */ new Map();
2803
+ this.longestName = 0;
2804
+ }
2805
+ /** Whether any unit has been defined, a cheap guard for the hot path. */
2806
+ get isEmpty() {
2807
+ return this.byKey.size === 0;
2808
+ }
2809
+ /** The most words any registered name spans, bounding the lookup scan. */
2810
+ get maxWordCount() {
2811
+ return this.longestName;
2812
+ }
2813
+ /**
2814
+ * Register (or replace) a unit named by `nameWords`, defined as `ratioText`
2815
+ * of `baseUnit`. Re-defining a name overwrites the earlier definition, so a
2816
+ * corrected line wins over the one above it.
2817
+ */
2818
+ define(nameWords, ratioText, baseUnit) {
2819
+ const key = pluralInsensitiveKey(nameWords);
2820
+ this.byKey.set(key, {
2821
+ displayName: nameWords.join(" "),
2822
+ ratioText,
2823
+ baseUnit
2824
+ });
2825
+ if (nameWords.length > this.longestName) this.longestName = nameWords.length;
2826
+ }
2827
+ /**
2828
+ * Longest registered name that a run of identifier `words` begins with, or
2829
+ * `null` if none. `words` is the consecutive identifiers following a value,
2830
+ * so `story points each` matches the 2-word `story point` and leaves `each`.
2831
+ */
2832
+ match(words) {
2833
+ const upper = Math.min(words.length, this.longestName);
2834
+ for (let length = upper; length >= 1; length--) {
2835
+ const definition = this.byKey.get(pluralInsensitiveKey(words.slice(0, length)));
2836
+ if (definition !== void 0) return { definition, wordCount: length };
2837
+ }
2838
+ return null;
2839
+ }
2840
+ /** Drop every definition, called when a fresh document pass begins. */
2841
+ clear() {
2842
+ this.byKey.clear();
2843
+ this.longestName = 0;
2844
+ }
2845
+ };
2846
+
2847
+ // src/packages/uom/normalizer/UserUnitNormalizerRule.ts
2848
+ function userUnitExpansionRule(table, priority = 82) {
2849
+ return {
2850
+ name: "uom:user-unit",
2851
+ priority,
2852
+ match(tokens, pos) {
2853
+ if (table.isEmpty) return null;
2854
+ const value = tokens[pos];
2855
+ if (value.type !== "NUMBER" && value.type !== "RPAREN") return null;
2856
+ const words = [];
2857
+ const maxWords = table.maxWordCount;
2858
+ for (let i = pos + 1; i < tokens.length && words.length < maxWords; i++) {
2859
+ if (tokens[i].type !== "IDENT") break;
2860
+ words.push(tokens[i].value);
2861
+ }
2862
+ if (words.length === 0) return null;
2863
+ const matched = table.match(words);
2864
+ if (matched === null) return null;
2865
+ const after = tokens[pos + 1 + matched.wordCount];
2866
+ if (after?.type === "EQUALS") return null;
2867
+ const { ratioText, baseUnit } = matched.definition;
2868
+ const star = new LexerToken("STAR", tokenTypeId("STAR"), "*", "*", value.offset, 0, value.line, value.col);
2869
+ const ratio = new LexerToken("NUMBER", tokenTypeId("NUMBER"), ratioText, ratioText, value.offset, 0, value.line, value.col);
2870
+ const unit = new LexerToken("UNIT", tokenTypeId("UNIT"), baseUnit, baseUnit, value.offset, 0, value.line, value.col);
2871
+ return {
2872
+ consumed: 1 + matched.wordCount,
2873
+ replacement: [value, star, ratio, unit],
2874
+ ruleName: "uom:user-unit"
2875
+ };
2876
+ }
2877
+ };
2878
+ }
2879
+
2880
+ // src/explain/LineExplainer.ts
2881
+ var SUM = 30;
2882
+ var PRODUCT = 40;
2883
+ var EXPONENT = 50;
2884
+ var ON_OFF = 24;
2885
+ function infixBindingPower(type) {
2886
+ switch (type) {
2887
+ case TokenTypes.PLUS:
2888
+ case TokenTypes.MINUS:
2889
+ return SUM;
2890
+ case TokenTypes.STAR:
2891
+ case TokenTypes.SLASH:
2892
+ case TokenTypes.MOD:
2893
+ case TokenTypes.OF:
2894
+ return PRODUCT;
2895
+ case TokenTypes.CARET:
2896
+ return EXPONENT;
2897
+ case TokenTypes.PCT_ON:
2898
+ case TokenTypes.PCT_OFF:
2899
+ return ON_OFF;
2900
+ default:
2901
+ return 0;
2902
+ }
2903
+ }
2904
+ function connective(type) {
2905
+ switch (type) {
2906
+ case TokenTypes.PLUS:
2907
+ return "plus";
2908
+ case TokenTypes.MINUS:
2909
+ return "minus";
2910
+ case TokenTypes.STAR:
2911
+ return "times";
2912
+ case TokenTypes.SLASH:
2913
+ return "divided by";
2914
+ case TokenTypes.MOD:
2915
+ return "mod";
2916
+ case TokenTypes.CARET:
2917
+ return "to the power of";
2918
+ case TokenTypes.OF:
2919
+ return "of";
2920
+ case TokenTypes.PCT_ON:
2921
+ return "plus";
2922
+ case TokenTypes.PCT_OFF:
2923
+ return "less";
2924
+ default:
2925
+ return type.toLowerCase();
2926
+ }
2927
+ }
2928
+ function tokenEnd(t) {
2929
+ return t.sourceEnd ?? t.offset + t.value.length;
2930
+ }
2931
+ var OPERAND_TOKEN_TYPES = /* @__PURE__ */ new Set([
2932
+ TokenTypes.NUMBER,
2933
+ TokenTypes.BIGINT,
2934
+ TokenTypes.IDENT,
2935
+ TokenTypes.UNIT,
2936
+ TokenTypes.PERCENT,
2937
+ TokenTypes.PI,
2938
+ TokenTypes.E,
2939
+ TokenTypes.TRUE,
2940
+ TokenTypes.FALSE,
2941
+ TokenTypes.DOLLAR,
2942
+ TokenTypes.POUND,
2943
+ TokenTypes.EURO,
2944
+ TokenTypes.YEN,
2945
+ TokenTypes.RUBLE,
2946
+ TokenTypes.WON,
2947
+ TokenTypes.CURRENCY_SYMBOL
2948
+ ]);
2949
+ function isBoundary(type) {
2950
+ return !OPERAND_TOKEN_TYPES.has(type);
2951
+ }
2952
+ var Parser = class {
2953
+ constructor(tokens) {
2954
+ this.tokens = tokens;
2955
+ this.pos = 0;
2956
+ }
2957
+ peek() {
2958
+ return this.tokens[this.pos];
2959
+ }
2960
+ next() {
2961
+ return this.tokens[this.pos++];
2962
+ }
2963
+ /** Parse the whole stream, requiring every token to be consumed. */
2964
+ parseAll() {
2965
+ const node = this.parseExpression(0);
2966
+ if (this.pos !== this.tokens.length) {
2967
+ throw new Error("explain: unconsumed tokens");
2968
+ }
2969
+ return node;
2970
+ }
2971
+ parseExpression(minBindingPower) {
2972
+ let left = this.parseOperand();
2973
+ for (; ; ) {
2974
+ const t = this.peek();
2975
+ if (!t) break;
2976
+ const bindingPower = infixBindingPower(t.type);
2977
+ if (bindingPower === 0 || bindingPower <= minBindingPower) break;
2978
+ this.next();
2979
+ const rightBindingPower = t.type === TokenTypes.CARET ? bindingPower - 1 : bindingPower;
2980
+ const right = this.parseExpression(rightBindingPower);
2981
+ left = {
2982
+ kind: "binary",
2983
+ op: t.type,
2984
+ left,
2985
+ right,
2986
+ start: left.start,
2987
+ end: right.end
2988
+ };
2989
+ }
2990
+ return left;
2991
+ }
2992
+ /** A parenthesised group, an optional sign, then a run of operand tokens. */
2993
+ parseOperand() {
2994
+ const first = this.peek();
2995
+ if (!first) throw new Error("explain: expected an operand");
2996
+ if (first.type === TokenTypes.LPAREN) {
2997
+ return this.parseGroup();
2998
+ }
2999
+ const signStart = first.offset;
3000
+ let sawSign = false;
3001
+ while (this.peek() && (this.peek().type === TokenTypes.PLUS || this.peek().type === TokenTypes.MINUS)) {
3002
+ this.next();
3003
+ sawSign = true;
3004
+ }
3005
+ if (sawSign && this.peek() && this.peek().type === TokenTypes.LPAREN) {
3006
+ const group = this.parseGroup();
3007
+ return { kind: "wrap", child: group, start: signStart, end: group.end };
3008
+ }
3009
+ let end = -1;
3010
+ while (this.peek() && !isBoundary(this.peek().type)) {
3011
+ end = tokenEnd(this.next());
3012
+ }
3013
+ if (end === -1) {
3014
+ throw new Error("explain: expected an operand");
3015
+ }
3016
+ return { kind: "leaf", start: signStart, end };
3017
+ }
3018
+ parseGroup() {
3019
+ const open = this.next();
3020
+ const inner = this.parseExpression(0);
3021
+ const close = this.peek();
3022
+ if (!close || close.type !== TokenTypes.RPAREN) {
3023
+ throw new Error("explain: unbalanced parentheses");
3024
+ }
3025
+ this.next();
3026
+ return { kind: "wrap", child: inner, start: open.offset, end: tokenEnd(close) };
3027
+ }
3028
+ };
3029
+ var Builder = class {
3030
+ constructor(source, evaluate, locale) {
3031
+ this.source = source;
3032
+ this.evaluate = evaluate;
3033
+ this.steps = [];
3034
+ this.cache = /* @__PURE__ */ new Map();
3035
+ this.resultPrefix = getLocale(locale).display.resultPrefix;
3036
+ }
3037
+ build(root) {
3038
+ this.emit(root);
3039
+ return { steps: this.steps, result: this.valueOf(root) };
3040
+ }
3041
+ /** Walk in evaluation order, emitting one step per operation. */
3042
+ emit(node) {
3043
+ if (node.kind === "leaf") return;
3044
+ if (node.kind === "wrap") {
3045
+ this.emit(node.child);
3046
+ return;
3047
+ }
3048
+ this.emit(node.left);
3049
+ this.emit(node.right);
3050
+ this.steps.push({
3051
+ description: this.describe(node),
3052
+ value: this.valueOf(node)
3053
+ });
3054
+ }
3055
+ describe(node) {
3056
+ const word = connective(node.op);
3057
+ if (node.op === TokenTypes.PCT_ON || node.op === TokenTypes.PCT_OFF) {
3058
+ return `${this.operand(node.right)} ${word} ${this.operand(node.left)}`;
3059
+ }
3060
+ return `${this.operand(node.left)} ${word} ${this.operand(node.right)}`;
3061
+ }
3062
+ /** How an operand appears inside a step's description. */
3063
+ operand(node) {
3064
+ if (node.kind === "leaf") {
3065
+ return this.source.slice(node.start, node.end).trim();
3066
+ }
3067
+ return this.render(this.valueOf(node));
3068
+ }
3069
+ /** A node's value, evaluated once from its own span and then cached. */
3070
+ valueOf(node) {
3071
+ const cached = this.cache.get(node);
3072
+ if (cached) return cached;
3073
+ const value = this.evaluate(this.source.slice(node.start, node.end));
3074
+ this.cache.set(node, value);
3075
+ return value;
3076
+ }
3077
+ /** Format a value for inline display, without the result prefix ("= "). */
3078
+ render(value) {
3079
+ const formatted = formatValue(value, DEFAULT_FORMATTING_SETTINGS);
3080
+ return formatted.startsWith(this.resultPrefix) ? formatted.slice(this.resultPrefix.length) : formatted;
3081
+ }
3082
+ };
3083
+ function buildExplanation(params) {
3084
+ const { expression, tokens, evaluate, locale } = params;
3085
+ const terminal = () => ({
3086
+ expression,
3087
+ steps: [],
3088
+ result: evaluate(expression)
3089
+ });
3090
+ let root;
3091
+ try {
3092
+ root = new Parser(tokens).parseAll();
3093
+ } catch {
3094
+ return terminal();
3095
+ }
3096
+ try {
3097
+ const { steps, result } = new Builder(expression, evaluate, locale).build(root);
3098
+ return { expression, steps, result };
3099
+ } catch {
3100
+ return terminal();
3101
+ }
3102
+ }
3103
+
3349
3104
  // src/engine/ExpressionEngine.ts
3350
- var ExpressionEngine = class {
3105
+ var _ExpressionEngine = class _ExpressionEngine {
3351
3106
  //#endregion
3352
3107
  //#region Constructor
3353
3108
  constructor(localeCode = "en", diagnosticMode = false, config, diagnosticPipeline, packages) {
@@ -3384,6 +3139,25 @@ var ExpressionEngine = class {
3384
3139
  * lifecycle itself.
3385
3140
  */
3386
3141
  this.documentModel = null;
3142
+ /**
3143
+ * How deep goal seek is currently re-entering line evaluation, so a goal
3144
+ * seek whose target line is itself a goal seek is refused rather than
3145
+ * multiplying the search cost. One goal-seek line already re-runs its
3146
+ * target up to `maxGoalSeekIterations` times; allowing a nested one would
3147
+ * make that a product. See {@link makeLineContext}'s `evaluateLineWithBinding`.
3148
+ */
3149
+ this.goalSeekDepth = 0;
3150
+ /**
3151
+ * Batch cross-line source, set only for the duration of a
3152
+ * `parseDocument`/`evaluateLines` pass (see {@link processScanResults}).
3153
+ * The incremental path uses {@link documentModel}; the batch path has no
3154
+ * such model, so cross-line closures read earlier lines from the scan and
3155
+ * the results array the pass is already building. Both are references to
3156
+ * arrays that exist regardless, so a document that uses no cross-line
3157
+ * feature pays nothing: the closures are simply never called.
3158
+ */
3159
+ this.batchScanResults = null;
3160
+ this.batchParsedLines = null;
3387
3161
  /**
3388
3162
  * Package-contributed completion candidates (`IEnginePackage.completionItems`),
3389
3163
  * keyed by package name, engine-instance-local, not a shared registry
@@ -3403,6 +3177,14 @@ var ExpressionEngine = class {
3403
3177
  * are aborted together.
3404
3178
  */
3405
3179
  this.keystrokeSignal = null;
3180
+ /**
3181
+ * Units defined by the current document (`1 sprint = 2 weeks`). Read by the
3182
+ * user-unit normalizer rule to expand a name back to its definition, written
3183
+ * by {@link tryDefineUserUnit} on a definition line. Document-scoped: cleared
3184
+ * at the start of every {@link parseDocument} pass, so definitions never
3185
+ * cross between documents.
3186
+ */
3187
+ this.userUnits = new UserUnitTable();
3406
3188
  // Bytecode cache, avoids re-parsing identical expressions.
3407
3189
  // Bounded by config.performance.defaultCacheSize: when full, the oldest
3408
3190
  // entry (Map insertion order) is evicted so unique expressions across a
@@ -3441,6 +3223,7 @@ var ExpressionEngine = class {
3441
3223
  50,
3442
3224
  (word) => this.normalizer.canStartPhrase(word)
3443
3225
  ));
3226
+ this.normalizer.register(userUnitExpansionRule(this.userUnits));
3444
3227
  const pkgList = packages ?? BUILTIN_PACKAGES;
3445
3228
  for (const pkg of pkgList) {
3446
3229
  try {
@@ -3463,7 +3246,11 @@ var ExpressionEngine = class {
3463
3246
  this.config.vm.maxFunctionCalls,
3464
3247
  this.config.date.maxOffsetYears,
3465
3248
  this.config.date.minOffsetYears,
3466
- this.context
3249
+ this.context,
3250
+ // Resolved once here, not per evaluation: a host list becomes a
3251
+ // Set lookup the walk can run cheaply. Undefined stays undefined,
3252
+ // which the VM reads as weekends-only.
3253
+ resolveHolidayPredicate(this.config.date.holidays)
3467
3254
  );
3468
3255
  this.queryClient = createQueryClient();
3469
3256
  this.batcher = new AsyncResolutionBatcher(this.dag, this.lineCache, this.vm);
@@ -3486,16 +3273,96 @@ var ExpressionEngine = class {
3486
3273
  */
3487
3274
  makeLineContext(lineNumber) {
3488
3275
  const doc = this.documentModel;
3276
+ const scan = doc ? null : this.batchScanResults;
3277
+ const parsed = doc ? null : this.batchParsedLines;
3489
3278
  return {
3490
3279
  lineIndex: lineNumber,
3491
- getLineResult: doc ? (n) => doc.getLineAt(n)?.result ?? void 0 : void 0,
3280
+ getLineResult: doc ? (n) => doc.getLineAt(n)?.result ?? void 0 : parsed ? (n) => parsed[n - 1]?.result ?? void 0 : void 0,
3492
3281
  isLineBoundary: doc ? (n) => {
3493
3282
  const state = doc.getLineAt(n);
3494
3283
  if (!state) return true;
3495
3284
  return state.isEmpty || /^\s*#/.test(state.text);
3496
- } : void 0
3285
+ } : scan ? (n) => {
3286
+ const sr = scan[n - 1];
3287
+ if (!sr) return true;
3288
+ return sr.classification.skip || /^\s*#/.test(sr.text);
3289
+ } : void 0,
3290
+ getLineReads: doc ? (n) => {
3291
+ const state = doc.getLineAt(n);
3292
+ if (!state || state.bytecodes.length === 0) return void 0;
3293
+ return state.reads;
3294
+ } : void 0,
3295
+ evaluateLineWithBinding: doc ? (n, variable, bound, symbolicTolerant) => this.evaluateLineWithBinding(n, variable, bound, symbolicTolerant) : void 0,
3296
+ goalSeekMaxIterations: this.config.vm.maxGoalSeekIterations,
3297
+ // Raw source text of a line, for features that read markdown the
3298
+ // evaluator skipped (a table's rows). See the tables package. Reads
3299
+ // from the document when evaluating incrementally, and from the scan
3300
+ // in the batch parseDocument/evaluateLines path, so a table column
3301
+ // aggregate resolves the same way through both, matching the other
3302
+ // cross-line closures above.
3303
+ getLineText: doc ? (n) => doc.getLineAt(n)?.text : scan ? (n) => scan[n - 1]?.text : void 0
3497
3304
  };
3498
3305
  }
3306
+ /**
3307
+ * Re-evaluate line `targetLine`'s compiled expression with `variable` bound
3308
+ * to `bound` for that one run, the primitive goal seek drives while
3309
+ * narrowing in on an input. See {@link LineExecutionContext.evaluateLineWithBinding}.
3310
+ *
3311
+ * The binding is a call frame, so `LOAD_VAR` reads it in preference to the
3312
+ * document's own value and the document's value is untouched once the frame
3313
+ * is popped, the same shadowing a function parameter gets. The target's
3314
+ * bytecode is run as-is, so a line that defines a variable is refused: its
3315
+ * `STORE_VAR` would write the probe's candidate into the real variable store
3316
+ * as a side effect. The re-entry is bounded three ways, by
3317
+ * {@link goalSeekDepth} against nesting, by the VM's own per-run instruction
3318
+ * limit, and by the caller's iteration cap.
3319
+ *
3320
+ * @param targetLine - 1-based line whose expression is re-evaluated.
3321
+ * @param variable - The unknown to bind for this run.
3322
+ * @param bound - The value to bind it to (a number to probe, a symbolic node to read the relationship in closed form).
3323
+ * @param symbolicTolerant - Whether an otherwise-undefined variable reads as a symbolic placeholder, needed for the closed-form read.
3324
+ * @returns The line's re-evaluated Value, or an error Value when it cannot be probed.
3325
+ */
3326
+ evaluateLineWithBinding(targetLine, variable, bound, symbolicTolerant) {
3327
+ const doc = this.documentModel;
3328
+ if (!doc) {
3329
+ return errorValue("GOAL_SEEK_NO_DOCUMENT", "Goal seek needs a document to solve against, which the single-expression entry point does not have.");
3330
+ }
3331
+ if (this.goalSeekDepth >= _ExpressionEngine.GOAL_SEEK_MAX_NESTING_DEPTH) {
3332
+ return errorValue("GOAL_SEEK_NESTED", `A goal-seek line cannot target another goal-seek line, since each already re-runs its target many times.`);
3333
+ }
3334
+ const state = doc.getLineAt(targetLine);
3335
+ if (!state || state.bytecodes.length === 0) {
3336
+ return errorValue("GOAL_SEEK_LINE_NOT_READY", `Line ${targetLine} has no evaluated expression to solve (forward reference, out of range, or not an expression).`);
3337
+ }
3338
+ if (state.isVariableDef) {
3339
+ return errorValue("GOAL_SEEK_TARGET_IS_DEFINITION", `Line ${targetLine} defines a variable, so re-running it would overwrite that variable. Target the line that uses ${variable}, not the one that sets a value.`);
3340
+ }
3341
+ const program = state.bytecodes[0];
3342
+ this.goalSeekDepth++;
3343
+ try {
3344
+ const frame = /* @__PURE__ */ new Map([[variable, bound]]);
3345
+ const stackBefore = this.vm.getStack().length;
3346
+ this.vm.pushCallFrame(frame);
3347
+ try {
3348
+ const result = executeBytecode(program, this.vm, void 0, void 0, this.makeLineContext(targetLine), symbolicTolerant);
3349
+ if (result.type === "pending") {
3350
+ return errorValue("GOAL_SEEK_ASYNC_UNSUPPORTED", `Line ${targetLine} depends on an async value (weather, stocks, currency, ...), which goal seek cannot re-run.`);
3351
+ }
3352
+ if (result.type === "error") {
3353
+ return errorValue(result.error.code ?? "GOAL_SEEK_TARGET_ERROR", result.error.message);
3354
+ }
3355
+ return result.value;
3356
+ } finally {
3357
+ this.vm.popCallFrame();
3358
+ while (this.vm.getStack().length > stackBefore) this.vm.pop();
3359
+ }
3360
+ } catch (e) {
3361
+ return errorValue("GOAL_SEEK_TARGET_ERROR", normalizeUnknownError(e).message);
3362
+ } finally {
3363
+ this.goalSeekDepth--;
3364
+ }
3365
+ }
3499
3366
  /**
3500
3367
  * Insert into the bytecode cache, evicting the oldest entry when full.
3501
3368
  *
@@ -3888,6 +3755,8 @@ var ExpressionEngine = class {
3888
3755
  { maxLines }
3889
3756
  );
3890
3757
  }
3758
+ if (!this.userUnits.isEmpty) this.bytecodeCache.clear();
3759
+ this.userUnits.clear();
3891
3760
  const scanResults = this.lexer.scanDocument(input);
3892
3761
  const processedLines = this.processScanResults(scanResults);
3893
3762
  const result = {
@@ -3933,6 +3802,8 @@ var ExpressionEngine = class {
3933
3802
  * bypassing the split→join roundtrip that this method performs.
3934
3803
  */
3935
3804
  evaluateLines(lines) {
3805
+ if (!this.userUnits.isEmpty) this.bytecodeCache.clear();
3806
+ this.userUnits.clear();
3936
3807
  const documentText = lines.join("\n");
3937
3808
  const scanResults = this.lexer.scanDocument(documentText);
3938
3809
  return this.processScanResults(scanResults);
@@ -3947,63 +3818,74 @@ var ExpressionEngine = class {
3947
3818
  */
3948
3819
  processScanResults(scanResults) {
3949
3820
  const result = [];
3950
- for (const scanResult of scanResults) {
3951
- const lineText = scanResult.text;
3952
- const lineNumber = scanResult.lineNumber;
3953
- const startPosition = scanResult.startOffset;
3954
- const endPosition = scanResult.endOffset;
3955
- const isEmpty = scanResult.classification.skip;
3956
- const inlineSolves = scanResult.inlineSolves.map((s) => ({
3957
- start: s.start,
3958
- end: s.end,
3959
- expression: s.expression,
3960
- lineNumber,
3961
- columnNumber: s.columnNumber
3962
- }));
3963
- const hasInlineSolves = inlineSolves.length > 0;
3964
- const parsedLine = {
3965
- lineNumber,
3966
- text: lineText,
3967
- startPosition,
3968
- endPosition,
3969
- isEmpty,
3970
- hasInlineSolves,
3971
- inlineSolves,
3972
- expression: null,
3973
- result: null,
3974
- error: null
3975
- };
3976
- if (!isEmpty) {
3977
- const isVariableAssignment = lineText.trim().startsWith(":");
3978
- if (hasInlineSolves && !isVariableAssignment) {
3979
- for (const solve of inlineSolves) {
3980
- try {
3981
- const values = this.evaluateLine(lineNumber, solve.expression);
3982
- solve.result = values[0];
3983
- } catch (error) {
3984
- const errorMessage = error instanceof Error ? error.message : String(error);
3985
- solve.error = errorMessage;
3821
+ const previousBatchScanResults = this.batchScanResults;
3822
+ const previousBatchParsedLines = this.batchParsedLines;
3823
+ this.batchScanResults = scanResults;
3824
+ this.batchParsedLines = result;
3825
+ try {
3826
+ for (const scanResult of scanResults) {
3827
+ const lineText = scanResult.text;
3828
+ const lineNumber = scanResult.lineNumber;
3829
+ const startPosition = scanResult.startOffset;
3830
+ const endPosition = scanResult.endOffset;
3831
+ const isEmpty = scanResult.classification.skip;
3832
+ const inlineSolves = scanResult.inlineSolves.map((s) => ({
3833
+ start: s.start,
3834
+ end: s.end,
3835
+ expression: s.expression,
3836
+ lineNumber,
3837
+ columnNumber: s.columnNumber
3838
+ }));
3839
+ const hasInlineSolves = inlineSolves.length > 0;
3840
+ const parsedLine = {
3841
+ lineNumber,
3842
+ text: lineText,
3843
+ startPosition,
3844
+ endPosition,
3845
+ isEmpty,
3846
+ hasInlineSolves,
3847
+ inlineSolves,
3848
+ expression: null,
3849
+ result: null,
3850
+ error: null
3851
+ };
3852
+ if (!isEmpty) {
3853
+ const isVariableAssignment = lineText.trim().startsWith(":");
3854
+ if (hasInlineSolves && !isVariableAssignment) {
3855
+ for (const solve of inlineSolves) {
3856
+ try {
3857
+ const values = this.evaluateLine(lineNumber, solve.expression);
3858
+ solve.result = values[0];
3859
+ } catch (error) {
3860
+ const errorMessage = error instanceof Error ? error.message : String(error);
3861
+ solve.error = errorMessage;
3862
+ }
3986
3863
  }
3987
- }
3988
- } else {
3989
- const expression = lineText.trim();
3990
- if (expression) {
3991
- try {
3992
- const values = this.evaluateLineWithPreTokenized(
3993
- lineNumber,
3994
- expression,
3995
- scanResult.tokens
3996
- );
3997
- parsedLine.expression = expression;
3998
- parsedLine.result = values[0];
3999
- } catch (error) {
4000
- const errorMessage = error instanceof Error ? error.message : String(error);
4001
- parsedLine.error = errorMessage;
3864
+ } else {
3865
+ const contentOffset = scanResult.classification.contentOffset;
3866
+ const evaluable = contentOffset === void 0 ? lineText : lineText.slice(contentOffset - startPosition);
3867
+ const expression = evaluable.trim();
3868
+ if (expression) {
3869
+ try {
3870
+ const values = this.evaluateLineWithPreTokenized(
3871
+ lineNumber,
3872
+ expression,
3873
+ scanResult.tokens
3874
+ );
3875
+ parsedLine.expression = expression;
3876
+ parsedLine.result = values[0];
3877
+ } catch (error) {
3878
+ const errorMessage = error instanceof Error ? error.message : String(error);
3879
+ parsedLine.error = errorMessage;
3880
+ }
4002
3881
  }
4003
3882
  }
4004
3883
  }
3884
+ result.push(parsedLine);
4005
3885
  }
4006
- result.push(parsedLine);
3886
+ } finally {
3887
+ this.batchScanResults = previousBatchScanResults;
3888
+ this.batchParsedLines = previousBatchParsedLines;
4007
3889
  }
4008
3890
  return result;
4009
3891
  }
@@ -4242,6 +4124,54 @@ var ExpressionEngine = class {
4242
4124
  * session's own Phase H.2 scope decision (full symbolic MATRICES, not
4243
4125
  * a general CAS).
4244
4126
  */
4127
+ /**
4128
+ * Registers a document-scoped user unit from a `1 <name> = <n> <unit>` line
4129
+ * and returns the `<name> defined` confirmation, or `null` when the line is
4130
+ * not a unit definition (so ordinary processing continues unchanged).
4131
+ *
4132
+ * Like the symbolic-grammar shapes it sits beside, this has an effect (a
4133
+ * stored definition) that no cached bytecode program could represent, so it
4134
+ * short-circuits compilation the same way. See {@link UserUnitTable}.
4135
+ *
4136
+ * The pattern is kept deliberately narrow so it cannot swallow an equation:
4137
+ *
4138
+ * - The coefficient must be exactly `1`. `2 x = 10` stays a scalar equation
4139
+ * (`x =>` solves it); only the natural `1 sprint = 2 weeks` shape is a
4140
+ * definition.
4141
+ * - The name is one or more identifiers, never a built-in unit (those lex as
4142
+ * UNIT, not IDENT), so a built-in unit cannot be redefined.
4143
+ * - The base must be a recognized unit (a UNIT token). A non-unit right side
4144
+ * declines here and is left to whatever handled it before, so a defined
4145
+ * unit is always dimensioned, never free-standing.
4146
+ *
4147
+ * `tokens` are already normalized, so the implicit-multiply pass has usually
4148
+ * inserted a STAR between the coefficient and the name (`1 * sprint`); it is
4149
+ * tolerated and skipped.
4150
+ */
4151
+ tryDefineUserUnit(tokens) {
4152
+ if (tokens.length < 5) return null;
4153
+ if (tokens[0].type !== "NUMBER" || Number(tokens[0].value) !== 1) return null;
4154
+ let i = 1;
4155
+ if (tokens[i].type === "STAR") i++;
4156
+ const nameWords = [];
4157
+ while (i < tokens.length && tokens[i].type === "IDENT") {
4158
+ nameWords.push(tokens[i].value);
4159
+ i++;
4160
+ }
4161
+ if (nameWords.length === 0) return null;
4162
+ if (tokens[i]?.type !== "EQUALS") return null;
4163
+ i++;
4164
+ if (tokens[i]?.type !== "NUMBER") return null;
4165
+ const ratioToken = tokens[i];
4166
+ i++;
4167
+ if (tokens[i]?.type !== "UNIT") return null;
4168
+ const baseToken = tokens[i];
4169
+ i++;
4170
+ if (i !== tokens.length) return null;
4171
+ this.userUnits.define(nameWords, ratioToken.value, baseToken.value);
4172
+ this.bytecodeCache.clear();
4173
+ return stringValue(`${nameWords.join(" ")} defined`);
4174
+ }
4245
4175
  trySymbolicGrammar(normalizedTokens, lineNumber = -1) {
4246
4176
  if (normalizedTokens.length === 0) return null;
4247
4177
  const last = normalizedTokens[normalizedTokens.length - 1];
@@ -4269,6 +4199,7 @@ var ExpressionEngine = class {
4269
4199
  return this.simplifySymbolically(normalizedTokens, lineNumber);
4270
4200
  }
4271
4201
  if (normalizedTokens[0].type === "COLON" || normalizedTokens[0].type === "GLOBAL") return null;
4202
+ if (normalizedTokens[0].type === "GOAL_SEEK") return null;
4272
4203
  const eqIdx = normalizedTokens.findIndex((t) => t.type === "EQUALS");
4273
4204
  if (eqIdx === -1) return null;
4274
4205
  const names = this.parseFactorChain(normalizedTokens.slice(0, eqIdx));
@@ -4459,7 +4390,7 @@ var ExpressionEngine = class {
4459
4390
  }
4460
4391
  let symbolicResult;
4461
4392
  try {
4462
- symbolicResult = this.trySymbolicGrammar(normalizedTokens, lineNumber);
4393
+ symbolicResult = this.tryDefineUserUnit(normalizedTokens) ?? this.trySymbolicGrammar(normalizedTokens, lineNumber);
4463
4394
  } catch (e) {
4464
4395
  const { reads: reads2, writes: writes2 } = extractReadsAndWrites(normalizedTokens);
4465
4396
  return { kind: "error", stage: "parse", error: normalizeUnknownError(e), reads: reads2, writes: writes2, normalizedTokens };
@@ -5612,6 +5543,70 @@ var ExpressionEngine = class {
5612
5543
  evaluateExpression(expression) {
5613
5544
  return this.evaluateLine(-1, expression);
5614
5545
  }
5546
+ /**
5547
+ * Explain how a line reached its answer, as a readable derivation.
5548
+ *
5549
+ * This is a companion to {@link evaluateExpression}, not a replacement:
5550
+ * `explainLine` is for the person reading the note, whereas the diagnostic
5551
+ * pipeline (`evaluateLineWithDiagnostic`) is for the developer and reports
5552
+ * stages, opcodes and timings. A host puts a derivation behind a hover or a
5553
+ * disclosure, so this is an API rather than an `explain` keyword: it never
5554
+ * consumes a word that a prose line might use, and it annotates a line the
5555
+ * host has already chosen to explain.
5556
+ *
5557
+ * The returned {@link Explanation} walks the line's operations in evaluation
5558
+ * order, each with the value it arrives at, and its `result` is identical to
5559
+ * what {@link evaluateExpression} returns for the same line. Every value in
5560
+ * the derivation is the engine's own, the operations are re-evaluated rather
5561
+ * than re-derived, so a step can never disagree with the answer.
5562
+ *
5563
+ * A line with nothing to break down (a bare literal), or one built from a
5564
+ * construct this slice does not derive yet (matrices, dates, function
5565
+ * calls), comes back with an empty `steps` array and the answer in
5566
+ * `result`, rather than an error.
5567
+ *
5568
+ * @param expression - The raw line to explain.
5569
+ * @returns The ordered derivation and final value.
5570
+ * @throws {EngineError} When the line does not evaluate at all, or resolves
5571
+ * data asynchronously (a derivation has no meaning for either).
5572
+ */
5573
+ explainLine(expression) {
5574
+ const { tokens } = this.lexToTokens(expression);
5575
+ const exprTokens = tokens.filter((t) => t.type !== "COMMENT");
5576
+ const normalized = this.normalizer.normalize(exprTokens);
5577
+ return buildExplanation({
5578
+ expression,
5579
+ tokens: normalized,
5580
+ evaluate: (source) => this.evaluateIsolated(source),
5581
+ locale: this.localeCode
5582
+ });
5583
+ }
5584
+ /**
5585
+ * Evaluate a self-contained sub-expression without touching document state.
5586
+ *
5587
+ * Used only by {@link explainLine}. Unlike `evaluateExpression`, this never
5588
+ * writes to the line cache or the dependency graph: it is handed the spans
5589
+ * of a single line's own sub-expressions, and evaluating those to build a
5590
+ * derivation must not disturb the document the line belongs to. An async
5591
+ * (pending) result is rejected, a derivation cannot represent one.
5592
+ */
5593
+ evaluateIsolated(expression) {
5594
+ const { tokens, hasParens } = this.lexToTokens(expression);
5595
+ const prep = this.prepareExpression(expression, tokens, hasParens, void 0, -1);
5596
+ if (prep.kind === "empty") return numberValue(0);
5597
+ if (prep.kind === "error") throw prep.error;
5598
+ if (prep.kind === "symbolic-solve") return prep.value;
5599
+ const result = this.executeRaw(prep.program, -1);
5600
+ if (result.type === "error") throw result.error;
5601
+ if (result.type === "pending") {
5602
+ throw ErrorFactory.execution(
5603
+ "EXPLAIN_ASYNC_UNSUPPORTED",
5604
+ `A derivation cannot be built for a line that resolves data asynchronously: "${expression}"`,
5605
+ { expression }
5606
+ );
5607
+ }
5608
+ return result.value;
5609
+ }
5615
5610
  //#endregion
5616
5611
  //#region Compilation, Bytecode-only path
5617
5612
  /**
@@ -5782,1796 +5777,1016 @@ var ExpressionEngine = class {
5782
5777
  this.scopeManager.clear();
5783
5778
  this.bytecodeCache.clear();
5784
5779
  this.vm.reset();
5780
+ this.userUnits.clear();
5785
5781
  this.lastTelemetry = null;
5786
5782
  }
5787
5783
  //#endregion
5788
- //#region Public API, Incremental evaluation
5789
- /**
5790
- * Incrementally re-evaluate lines affected by a variable change.
5791
- * Walks the DAG from the changed variable to find exactly which lines
5792
- * need re-execution, no dirty-set indirection, no sorting guesswork.
5793
- * Uses Kahn's algorithm for topological ordering: producers always
5794
- * execute before consumers, regardless of document line order.
5784
+ //#region Public API, Snapshot / restore
5785
+ /**
5786
+ * Serialise this engine's session state into a plain, JSON-safe snapshot.
5787
+ *
5788
+ * Carries the three things a session accumulates in memory: named
5789
+ * {@link ExpressionEngine} variables, user-defined functions, and the
5790
+ * per-line result/bytecode cache (plus the expression-keyed bytecode cache).
5791
+ * Restore it with {@link ExpressionEngine.fromJSON} onto a fresh engine, and
5792
+ * later expressions resolve exactly as they would have on the engine that
5793
+ * evaluated the document.
5794
+ *
5795
+ * What is deliberately NOT carried:
5796
+ * - **Resolved async values.** Weather, stocks, currency, any package that
5797
+ * fetches: those results are point-in-time and must be re-fetched, not
5798
+ * restored stale. Every line backed by an async resolver (a DAG
5799
+ * data-source dependency, or an async plugin call in its bytecode) is
5800
+ * dropped from the line cache, and any variable whose most-recent
5801
+ * definition was such a line is dropped too. An in-flight (Pending) value
5802
+ * is likewise never written.
5803
+ * - **Package-contributed state.** Core state only for v1; a package opt-in
5804
+ * is a follow-up.
5805
+ * - **Symbolic (algebra) values.** Deferred: a variable holding one makes
5806
+ * this method throw {@link SnapshotErrorCodes.SNAPSHOT_UNSUPPORTED_VALUE}
5807
+ * (refused by name rather than dropped silently), and a cached line whose
5808
+ * result is symbolic is skipped (it re-evaluates on restore, algebra is
5809
+ * synchronous).
5810
+ *
5811
+ * The result survives `JSON.stringify` then `JSON.parse` unchanged.
5812
+ *
5813
+ * @returns A snapshot safe to store and hand back to `fromJSON`.
5814
+ * @throws {@link SnapshotErrorCodes.SNAPSHOT_UNSUPPORTED_VALUE} if a variable
5815
+ * holds a value this v1 format cannot represent (a symbolic value).
5795
5816
  */
5796
- evaluateIncremental(variable, newValue) {
5797
- const affectedLines = this.dag.getAffectedLinesInOrder(variable);
5798
- this.vm.setVar(variable, numberValue(newValue));
5799
- const updated = /* @__PURE__ */ new Map();
5800
- for (const lineNumber of affectedLines) {
5801
- const entry = this.lineCache.getEntryForLine(lineNumber);
5802
- if (!entry || entry.bytecode.opcodes.length === 0) continue;
5803
- const evalResult = this.executeRaw(entry.bytecode, lineNumber);
5804
- if (evalResult.type === "pending") {
5805
- void this.resolveAsync(evalResult);
5806
- continue;
5807
- }
5808
- if (evalResult.type === "error") {
5809
- const value = errorValue(evalResult.error.code, evalResult.error.message);
5810
- updated.set(lineNumber, value);
5811
- entry.result = value;
5812
- continue;
5817
+ toJSON() {
5818
+ const dagSnapshot = this.dag.getSnapshot();
5819
+ const asyncLines = /* @__PURE__ */ new Set();
5820
+ for (const key of Object.keys(dagSnapshot.dataSourceDeps)) {
5821
+ const line = Number(key);
5822
+ if (!Number.isNaN(line)) asyncLines.add(line);
5823
+ }
5824
+ const lineEntries = this.lineCache.snapshotEntries();
5825
+ const latestWriter = /* @__PURE__ */ new Map();
5826
+ for (const { line, entry } of lineEntries) {
5827
+ const isAsync = asyncLines.has(line) || entry.bytecode.hasAsync;
5828
+ if (isAsync) asyncLines.add(line);
5829
+ const writeVar = entry.writeVariable;
5830
+ if (writeVar) {
5831
+ const prev = latestWriter.get(writeVar);
5832
+ if (!prev || line >= prev.line) latestWriter.set(writeVar, { line, async: isAsync });
5833
+ }
5834
+ }
5835
+ const variables = {};
5836
+ for (const [name, value] of this.vm.getVariableEntries()) {
5837
+ if (value.type === 12 /* Pending */) continue;
5838
+ if (latestWriter.get(name)?.async) continue;
5839
+ variables[name] = serializeValue(value, `variable "${name}"`);
5840
+ }
5841
+ const userFunctions = this.vm.getUserFunctionDefs().map(serializeUserFunction);
5842
+ const lineCache = [];
5843
+ for (const { line, expression, entry } of lineEntries) {
5844
+ if (asyncLines.has(line)) continue;
5845
+ if (entry.result.type === 12 /* Pending */) continue;
5846
+ let result;
5847
+ try {
5848
+ result = serializeValue(entry.result, `line ${line}`);
5849
+ } catch (e) {
5850
+ if (e instanceof EngineError && e.code === SnapshotErrorCodes.SNAPSHOT_UNSUPPORTED_VALUE) continue;
5851
+ throw e;
5813
5852
  }
5814
- const result = evalResult.value;
5815
- updated.set(lineNumber, result);
5816
- entry.result = result;
5817
- }
5818
- return updated;
5819
- }
5820
- //#endregion
5821
- };
5822
-
5823
- // src/engine/SegmentTree.ts
5824
- var SegmentTree = class {
5825
- constructor() {
5826
- this.root = null;
5827
- }
5828
- // ── Public API ───────────────────────────────────────────────────────
5829
- /** Total number of elements in the tree. */
5830
- get length() {
5831
- return this.root ? this.root.size : 0;
5832
- }
5833
- /** Whether the tree is empty. */
5834
- get isEmpty() {
5835
- return this.root === null;
5836
- }
5837
- /**
5838
- * Get the lineId at the given 0-based index.
5839
- * Returns undefined if index is out of bounds.
5840
- * O(log N) expected.
5841
- */
5842
- getAt(index) {
5843
- if (index < 0 || index >= (this.root?.size ?? 0)) return void 0;
5844
- return this.nodeAt(this.root, index).lineId;
5845
- }
5846
- /**
5847
- * Insert a single lineId at the given 0-based index.
5848
- * O(log N) expected.
5849
- */
5850
- insertAt(index, lineId) {
5851
- const [left, right] = split(this.root, index);
5852
- this.root = merge(merge(left, createNode(lineId)), right);
5853
- }
5854
- /**
5855
- * Delete a single element at the given 0-based index.
5856
- * Returns the removed lineId, or undefined if out of bounds.
5857
- * O(log N) expected.
5858
- */
5859
- deleteAt(index) {
5860
- if (index < 0 || index >= (this.root?.size ?? 0)) return void 0;
5861
- const [left, midRight] = split(this.root, index);
5862
- const [mid, right] = split(midRight, 1);
5863
- const removed = mid ? mid.lineId : void 0;
5864
- this.root = merge(left, right);
5865
- return removed;
5866
- }
5867
- /**
5868
- * Bulk splice: delete `deleteCount` elements at `startIndex` (0-based),
5869
- * then insert `newIds` in their place.
5870
- *
5871
- * Returns the removed lineId array.
5872
- * O(log N + deleteCount + insertCount) expected.
5873
- */
5874
- spliceAt(startIndex, deleteCount, newIds) {
5875
- const clampedStart = Math.max(0, startIndex);
5876
- const clampedCount = Math.min(
5877
- Math.max(0, deleteCount),
5878
- (this.root?.size ?? 0) - clampedStart
5879
- );
5880
- const [left, midRight] = split(this.root, clampedStart);
5881
- const [mid, right] = split(midRight, clampedCount);
5882
- const removed = collectInOrder(mid);
5883
- const newTree = buildTreap(newIds, 0, newIds.length);
5884
- this.root = merge(merge(left, newTree), right);
5885
- return removed;
5886
- }
5887
- /**
5888
- * Replace the entire tree with a new set of lineIds.
5889
- * O(N), builds a balanced treap from a flat array.
5890
- */
5891
- replaceAll(lineIds) {
5892
- this.root = buildTreap(lineIds, 0, lineIds.length);
5893
- }
5894
- /**
5895
- * Get a contiguous range of lineIds by index.
5896
- * O(rangeSize + log N), single in-order walk instead of N × O(log N).
5897
- *
5898
- * This is the hot path for viewport rendering in DocumentModel.getVisibleLines().
5899
- */
5900
- getRange(startIndex, endIndex) {
5901
- const clampedStart = Math.max(0, startIndex);
5902
- const clampedEnd = Math.min((this.root?.size ?? 0) - 1, endIndex);
5903
- if (clampedStart > clampedEnd) return [];
5904
- const result = [];
5905
- this.collectRange(this.root, clampedStart, clampedEnd, 0, result);
5906
- return result;
5907
- }
5908
- /**
5909
- * Clear the tree.
5910
- */
5911
- clear() {
5912
- this.root = null;
5913
- }
5914
- // ── Iteration ─────────────────────────────────────────────────────────
5915
- /**
5916
- * In-order iterator yielding all lineIds.
5917
- */
5918
- *[Symbol.iterator]() {
5919
- yield* this.inOrder(this.root);
5920
- }
5921
- *inOrder(node) {
5922
- if (!node) return;
5923
- yield* this.inOrder(node.left);
5924
- yield node.lineId;
5925
- yield* this.inOrder(node.right);
5926
- }
5927
- // ── Private helpers ───────────────────────────────────────────────────
5928
- nodeAt(node, index) {
5929
- const leftSize = node.left ? node.left.size : 0;
5930
- if (index < leftSize) {
5931
- return this.nodeAt(node.left, index);
5932
- } else if (index === leftSize) {
5933
- return node;
5934
- } else {
5935
- return this.nodeAt(node.right, index - leftSize - 1);
5936
- }
5937
- }
5938
- /**
5939
- * Collect all node values in the range [targetStart, targetEnd] into result.
5940
- * `offset` is the 0-based start of the current subtree within the full tree.
5941
- * O(rangeSize + log N), skips entire subtrees outside the target range.
5942
- */
5943
- collectRange(node, targetStart, targetEnd, offset, result) {
5944
- if (!node) return;
5945
- const leftSize = node.left ? node.left.size : 0;
5946
- const nodeIndex = offset + leftSize;
5947
- if (nodeIndex > targetStart && node.left) {
5948
- this.collectRange(node.left, targetStart, targetEnd, offset, result);
5949
- }
5950
- if (nodeIndex >= targetStart && nodeIndex <= targetEnd) {
5951
- result.push(node.lineId);
5952
- }
5953
- if (nodeIndex < targetEnd && node.right) {
5954
- this.collectRange(node.right, targetStart, targetEnd, nodeIndex + 1, result);
5853
+ lineCache.push({
5854
+ line,
5855
+ expression,
5856
+ result,
5857
+ bytecode: serializeBytecode(entry.bytecode),
5858
+ reads: entry.readVariables.slice(),
5859
+ writeVar: entry.writeVariable
5860
+ });
5955
5861
  }
5862
+ const bytecodeCache = Array.from(this.bytecodeCache.entries()).map(([expression, program]) => ({
5863
+ expression,
5864
+ program: serializeBytecode(program)
5865
+ }));
5866
+ return {
5867
+ format: SNAPSHOT_FORMAT,
5868
+ version: SNAPSHOT_VERSION,
5869
+ engineVersion: ENGINE_VERSION,
5870
+ locale: this.localeCode,
5871
+ variables,
5872
+ userFunctions,
5873
+ lineCache,
5874
+ bytecodeCache
5875
+ };
5956
5876
  }
5957
- };
5958
- function createNode(lineId) {
5959
- return {
5960
- lineId,
5961
- size: 1,
5962
- priority: Math.random(),
5963
- left: null,
5964
- right: null
5965
- };
5966
- }
5967
- function updateSize(node) {
5968
- node.size = 1 + (node.left ? node.left.size : 0) + (node.right ? node.right.size : 0);
5969
- }
5970
- function merge(left, right) {
5971
- if (!left) return right;
5972
- if (!right) return left;
5973
- if (left.priority > right.priority) {
5974
- left.right = merge(left.right, right);
5975
- updateSize(left);
5976
- return left;
5977
- } else {
5978
- right.left = merge(left, right.left);
5979
- updateSize(right);
5980
- return right;
5981
- }
5982
- }
5983
- function split(node, index) {
5984
- if (!node) return [null, null];
5985
- const leftSize = node.left ? node.left.size : 0;
5986
- if (index <= leftSize) {
5987
- const [left, right] = split(node.left, index);
5988
- node.left = right;
5989
- updateSize(node);
5990
- return [left, node];
5991
- } else {
5992
- const [left, right] = split(node.right, index - leftSize - 1);
5993
- node.right = left;
5994
- updateSize(node);
5995
- return [node, right];
5996
- }
5997
- }
5998
- function collectInOrder(node) {
5999
- const result = [];
6000
- collect(node, result);
6001
- return result;
6002
- }
6003
- function collect(node, result) {
6004
- if (!node) return;
6005
- collect(node.left, result);
6006
- result.push(node.lineId);
6007
- collect(node.right, result);
6008
- }
6009
- function buildTreap(lineIds, start, end) {
6010
- if (start >= end) return null;
6011
- const mid = Math.floor((start + end) / 2);
6012
- const priority = pseudoRandom(mid);
6013
- const node = {
6014
- lineId: lineIds[mid],
6015
- size: end - start,
6016
- priority,
6017
- left: buildTreap(lineIds, start, mid),
6018
- right: buildTreap(lineIds, mid + 1, end)
6019
- };
6020
- return node;
6021
- }
6022
- function pseudoRandom(seed) {
6023
- let x = seed;
6024
- x = (x >> 16 ^ x) * 73244475;
6025
- x = (x >> 16 ^ x) * 73244475;
6026
- x = x >> 16 ^ x;
6027
- return x / 4294967295;
6028
- }
6029
-
6030
- // src/engine/DocumentModel.ts
6031
- var DocumentModel = class {
6032
- /**
6033
- * @param maxLines - Ceiling on the line count, defaulting to the engine's
6034
- * configured one. Every line costs a LineState with six arrays in it
6035
- * whatever the line says, so the cost of a document is its line count and
6036
- * nothing else bounds it: two hundred thousand lines of `1 + 1` exhausted
6037
- * the heap here, before a single expression had been looked at.
6038
- */
6039
- constructor(maxLines = DEFAULT_CONFIG.performance.maxDocumentLines) {
6040
- /** Persistent line ID → LineState. */
6041
- this.lines = /* @__PURE__ */ new Map();
6042
- /** Order-statistic treap representing the current document line order. */
6043
- this.orderTree = new SegmentTree();
6044
- /** Monotonically increasing counter for new line IDs. */
6045
- this.nextLineId = 1;
6046
- /**
6047
- * Lazy position cache: lineId → 1-based position.
6048
- * Built on first `getLinePosition()` call, invalidated on structural edits.
6049
- */
6050
- this._positionCache = null;
6051
- /**
6052
- * Line IDs currently marked dirty, maintained alongside every
6053
- * `state.dirty` mutation (in this class and in every other module that
6054
- * holds a direct `LineState` reference: ThreeTierEvaluator, PageManager).
6055
- * Lets {@link hasAnyDirtyLineBefore} answer "is anything before position
6056
- * X dirty" in O(d log N), d = current dirty count, typically tiny once
6057
- * a document has settled after its initial evaluation, instead of
6058
- * O(N log N), which used to mean every scroll event re-walked the WHOLE
6059
- * document via `getLineAt()` regardless of how little of it was actually
6060
- * dirty. Benchmarked: ~10.6ms per setViewport() call scrolled near the
6061
- * bottom of a 20k-line document before this fix.
6062
- */
6063
- this.dirtyLineIds = /* @__PURE__ */ new Set();
6064
- this.maxLines = maxLines;
6065
- }
6066
- // ── Initialization ──────────────────────────────────────────────────
6067
5877
  /**
6068
- * Initialize or replace the entire document from a text blob.
6069
- * Clears all existing state and assigns new persistent line IDs.
6070
- *
6071
- * @throws `DOCUMENT_TOO_LARGE` for a document past {@link maxLines}, before
6072
- * any of it is stored. Recoverable: nothing has been replaced yet, so the
6073
- * model still holds whatever it held.
6074
- */
6075
- setDocument(text) {
6076
- const lineCount = countLines(text, this.maxLines);
6077
- if (lineCount > this.maxLines) {
6078
- throw ErrorFactory.execution(
6079
- "DOCUMENT_TOO_LARGE",
6080
- `This document has more than ${this.maxLines.toLocaleString("en-US")} lines, which is the most the engine will hold at once`,
6081
- { maxLines: this.maxLines }
5878
+ * Restore a snapshot produced by {@link ExpressionEngine.toJSON} onto a
5879
+ * fresh engine.
5880
+ *
5881
+ * The version gate runs first: a snapshot whose {@link EngineSnapshot.format}
5882
+ * or {@link EngineSnapshot.version} does not match this engine's reader is
5883
+ * refused with {@link SnapshotErrorCodes.SNAPSHOT_VERSION_MISMATCH} rather
5884
+ * than restored wrongly. The engine is then built with the snapshot's locale
5885
+ * (unless overridden) and the given `packages`, and its variable table,
5886
+ * user-function registry, line cache, dependency graph, and bytecode cache
5887
+ * are rehydrated from the snapshot.
5888
+ *
5889
+ * @param snapshot - A snapshot object, typically straight from `JSON.parse`.
5890
+ * @param options - Packages (must match those the snapshot was taken with),
5891
+ * plus optional config, diagnostic mode, and locale override. See
5892
+ * {@link EngineRestoreOptions}.
5893
+ * @returns A ready engine that behaves as though it had evaluated the
5894
+ * original document.
5895
+ * @throws {@link SnapshotErrorCodes.SNAPSHOT_VERSION_MISMATCH} for a missing
5896
+ * or mismatched envelope, and
5897
+ * {@link SnapshotErrorCodes.SNAPSHOT_MALFORMED} for internally
5898
+ * inconsistent contents.
5899
+ */
5900
+ static fromJSON(snapshot, options = {}) {
5901
+ assertRestorable(snapshot);
5902
+ const locale = options.locale ?? snapshot.locale ?? "en";
5903
+ const engine = new _ExpressionEngine(locale, options.diagnosticMode ?? false, options.config, void 0, options.packages);
5904
+ engine.restoreSnapshot(snapshot);
5905
+ return engine;
5906
+ }
5907
+ /**
5908
+ * Rehydrate this engine's state from a snapshot whose envelope has already
5909
+ * been validated by {@link assertRestorable}. Instance-private, the public
5910
+ * entry point is the static {@link ExpressionEngine.fromJSON}.
5911
+ */
5912
+ restoreSnapshot(snapshot) {
5913
+ for (const [name, sv] of Object.entries(snapshot.variables)) {
5914
+ this.vm.setVar(name, deserializeValue(sv));
5915
+ }
5916
+ for (const fn of snapshot.userFunctions) {
5917
+ const def = deserializeUserFunction(fn);
5918
+ this.vm.defineUserFunction(def.name, def.params, def.program);
5919
+ }
5920
+ for (const e of snapshot.lineCache) {
5921
+ const entry = new LineCacheEntry(
5922
+ deserializeValue(e.result),
5923
+ deserializeBytecode(e.bytecode),
5924
+ e.reads.slice(),
5925
+ e.writeVar
6082
5926
  );
5927
+ this.lineCache.set(e.line, entry, e.expression);
5928
+ this.dag.registerLine(e.line, e.reads, e.writeVar ? [e.writeVar] : []);
6083
5929
  }
6084
- this.lines.clear();
6085
- this.orderTree.clear();
6086
- this._positionCache = null;
6087
- this.dirtyLineIds.clear();
6088
- this.nextLineId = 1;
6089
- const rawLines = text.split("\n");
6090
- const lineIds = new Array(rawLines.length);
6091
- for (let i = 0; i < rawLines.length; i++) {
6092
- const lineId = this.nextLineId++;
6093
- lineIds[i] = lineId;
6094
- this.lines.set(lineId, {
6095
- lineId,
6096
- textHash: djb2Hash(rawLines[i]),
6097
- text: rawLines[i],
6098
- expressions: [],
6099
- bytecodes: [],
6100
- reads: [],
6101
- writes: [],
6102
- results: [],
6103
- result: null,
6104
- dirty: true,
6105
- isVariableDef: false,
6106
- isEmpty: rawLines[i].trim().length === 0,
6107
- inlineSolveCount: 0
6108
- });
5930
+ for (const { expression, program } of snapshot.bytecodeCache) {
5931
+ this.bytecodeCache.set(expression, deserializeBytecode(program));
6109
5932
  }
6110
- this.orderTree.replaceAll(lineIds);
6111
- for (const id of lineIds) this.dirtyLineIds.add(id);
6112
5933
  }
6113
- // ── Structural edits ────────────────────────────────────────────────
5934
+ //#endregion
5935
+ //#region Public API, Incremental evaluation
6114
5936
  /**
6115
- * Apply one or more line-level changes to the document.
6116
- *
6117
- * **Precondition:** Changes must be **non-overlapping** in their line ranges.
6118
- * If two changes target the same or adjacent lines, the reverse-order
6119
- * processing may produce incorrect results because the first-applied
6120
- * change shifts the line numbers that the second change references.
6121
- *
6122
- * Changes are applied in **reverse order** (highest startLine first) so
6123
- * that earlier changes in the document don't shift the indices of later
6124
- * changes during processing.
6125
- *
6126
- * Returns both the newly inserted line IDs and the removed line IDs.
6127
- * Callers should use `removed` to clean up the dependency graph and
6128
- * other data structures keyed by lineId.
5937
+ * Incrementally re-evaluate lines affected by a variable change.
5938
+ * Walks the DAG from the changed variable to find exactly which lines
5939
+ * need re-execution, no dirty-set indirection, no sorting guesswork.
5940
+ * Uses Kahn's algorithm for topological ordering: producers always
5941
+ * execute before consumers, regardless of document line order.
6129
5942
  */
6130
- applyChanges(changes) {
6131
- const inserted = [];
6132
- const removed = [];
6133
- const sorted = [...changes].sort((a, b) => b.startLine - a.startLine);
6134
- for (const change of sorted) {
6135
- const startIdx = change.startLine - 1;
6136
- const newIds = [];
6137
- for (const text of change.insertLines) {
6138
- const lineId = this.nextLineId++;
6139
- newIds.push(lineId);
6140
- inserted.push(lineId);
6141
- this.lines.set(lineId, {
6142
- lineId,
6143
- textHash: djb2Hash(text),
6144
- text,
6145
- expressions: [],
6146
- bytecodes: [],
6147
- reads: [],
6148
- writes: [],
6149
- results: [],
6150
- result: null,
6151
- dirty: true,
6152
- isVariableDef: false,
6153
- isEmpty: text.trim().length === 0,
6154
- inlineSolveCount: 0
6155
- });
5943
+ evaluateIncremental(variable, newValue) {
5944
+ const affectedLines = this.dag.getAffectedLinesInOrder(variable);
5945
+ this.vm.setVar(variable, numberValue(newValue));
5946
+ const updated = /* @__PURE__ */ new Map();
5947
+ for (const lineNumber of affectedLines) {
5948
+ const entry = this.lineCache.getEntryForLine(lineNumber);
5949
+ if (!entry || entry.bytecode.opcodes.length === 0) continue;
5950
+ const evalResult = this.executeRaw(entry.bytecode, lineNumber);
5951
+ if (evalResult.type === "pending") {
5952
+ void this.resolveAsync(evalResult);
5953
+ continue;
6156
5954
  }
6157
- for (const id of newIds) this.dirtyLineIds.add(id);
6158
- const removedIds = this.orderTree.spliceAt(
6159
- startIdx,
6160
- change.deleteCount,
6161
- newIds
6162
- );
6163
- for (const id of removedIds) {
6164
- removed.push(id);
6165
- this.lines.delete(id);
6166
- this.dirtyLineIds.delete(id);
5955
+ if (evalResult.type === "error") {
5956
+ const value = errorValue(evalResult.error.code, evalResult.error.message);
5957
+ updated.set(lineNumber, value);
5958
+ entry.result = value;
5959
+ continue;
6167
5960
  }
5961
+ const result = evalResult.value;
5962
+ updated.set(lineNumber, result);
5963
+ entry.result = result;
6168
5964
  }
6169
- this._positionCache = null;
6170
- return { inserted, removed };
5965
+ return updated;
6171
5966
  }
6172
- /**
6173
- * Insert new lines at the given 1-based position.
6174
- * Convenience wrapper around applyChanges.
6175
- */
6176
- insertLines(atLine, texts) {
6177
- const change = {
6178
- startLine: atLine,
6179
- deleteCount: 0,
6180
- insertLines: texts
6181
- };
6182
- const result = this.applyChanges([change]);
6183
- return result.inserted;
5967
+ //#endregion
5968
+ };
5969
+ /** The most nesting {@link makeLineContext}'s `evaluateLineWithBinding` allows before refusing, so the bisection re-runs can never compound. */
5970
+ _ExpressionEngine.GOAL_SEEK_MAX_NESTING_DEPTH = 1;
5971
+ var ExpressionEngine = _ExpressionEngine;
5972
+
5973
+ // src/workers/engine.worker.ts
5974
+ var engine_worker_default = (() => {
5975
+ throw new Error("engine.worker.ts was not processed by esbuild-plugin-inline-worker, so worker offload is unavailable");
5976
+ });
5977
+ var compileEngine = null;
5978
+ function getCompileEngine() {
5979
+ if (!compileEngine) {
5980
+ compileEngine = new ExpressionEngine("en", false);
6184
5981
  }
6185
- /**
6186
- * Delete lines in the given 1-based range [startLine, endLine] inclusive.
6187
- * Convenience wrapper around applyChanges.
6188
- */
6189
- deleteLines(startLine, endLine) {
6190
- const change = {
6191
- startLine,
6192
- deleteCount: endLine - startLine + 1,
6193
- insertLines: []
6194
- };
6195
- const result = this.applyChanges([change]);
6196
- return result.removed;
5982
+ return compileEngine;
5983
+ }
5984
+ var executeVm = null;
5985
+ function getExecuteVm() {
5986
+ if (!executeVm) {
5987
+ executeVm = createVM(sharedOpRegistry, 200, 5e4);
6197
5988
  }
6198
- /**
6199
- * Update the text of a single line in place.
6200
- * If the text hash differs, marks the line dirty and clears its
6201
- * bytecode/result so it gets re-evaluated.
6202
- *
6203
- * Returns true if the text actually changed (hash mismatch).
6204
- */
6205
- editLine(lineNumber, newText) {
6206
- const state = this.getLineAt(lineNumber);
6207
- if (!state) return false;
6208
- const newHash = djb2Hash(newText);
6209
- if (newHash === state.textHash) return false;
6210
- state.text = newText;
6211
- state.textHash = newHash;
6212
- state.expressions = [];
6213
- state.bytecodes = [];
6214
- state.results = [];
6215
- state.result = null;
6216
- state.inlineSolveCount = 0;
6217
- state.dirty = true;
6218
- this.dirtyLineIds.add(state.lineId);
6219
- state.isEmpty = newText.trim().length === 0;
6220
- return true;
5989
+ return executeVm;
5990
+ }
5991
+ function compileOne(item) {
5992
+ const base = {
5993
+ lineId: item.lineId,
5994
+ expression: item.expression,
5995
+ compiledAgainstHash: item.textHash,
5996
+ strings: [],
5997
+ reads: [],
5998
+ writes: [],
5999
+ isVariableDef: false,
6000
+ error: null
6001
+ };
6002
+ try {
6003
+ const eng = getCompileEngine();
6004
+ const { program, reads, writes } = eng.compileExpression(item.expression);
6005
+ const isVariableDef = writes.length > 0;
6006
+ const opcodes = program.opcodes;
6007
+ const numbers = program.numbers;
6008
+ const opcodesBuffer = opcodes.buffer.slice(
6009
+ opcodes.byteOffset,
6010
+ opcodes.byteOffset + opcodes.byteLength
6011
+ );
6012
+ const numbersBuffer = numbers.buffer.slice(
6013
+ numbers.byteOffset,
6014
+ numbers.byteOffset + numbers.byteLength
6015
+ );
6016
+ return {
6017
+ ...base,
6018
+ opcodesBuffer,
6019
+ numbersBuffer,
6020
+ opcodesLength: opcodes.length,
6021
+ numbersLength: numbers.length,
6022
+ strings: [...program.strings],
6023
+ reads,
6024
+ writes,
6025
+ isVariableDef
6026
+ };
6027
+ } catch (e) {
6028
+ return {
6029
+ ...base,
6030
+ opcodesBuffer: new ArrayBuffer(0),
6031
+ numbersBuffer: new ArrayBuffer(0),
6032
+ opcodesLength: 0,
6033
+ numbersLength: 0,
6034
+ error: e instanceof Error ? e.message : String(e)
6035
+ };
6221
6036
  }
6222
- // ── Queries ─────────────────────────────────────────────────────────
6223
- /**
6224
- * Get the LineState at the given 1-based line position. O(1).
6225
- */
6226
- getLineAt(position) {
6227
- const idx = position - 1;
6228
- const lineId = this.orderTree.getAt(idx);
6229
- if (lineId === void 0) return void 0;
6230
- return this.lines.get(lineId);
6037
+ }
6038
+ function handleCompileBatch(msg) {
6039
+ const results = [];
6040
+ const transferList = [];
6041
+ for (const item of msg.items) {
6042
+ const result = compileOne(item);
6043
+ results.push(result);
6044
+ if (result.opcodesBuffer.byteLength > 0) transferList.push(result.opcodesBuffer);
6045
+ if (result.numbersBuffer.byteLength > 0) transferList.push(result.numbersBuffer);
6231
6046
  }
6232
- /**
6233
- * Get the 1-based position of a line by its persistent ID.
6234
- * Returns -1 if the line ID is not in the document.
6235
- *
6236
- * Uses a lazy position cache: O(N) on first call after structural edit,
6237
- * O(1) on subsequent calls. The cache is invalidated by any structural edit.
6238
- */
6239
- getLinePosition(lineId) {
6240
- if (this._positionCache) {
6241
- return this._positionCache.get(lineId) ?? -1;
6047
+ self.postMessage(
6048
+ { id: msg.id, type: "COMPILE_RESULT", results },
6049
+ transferList
6050
+ );
6051
+ }
6052
+ function executeOne(item) {
6053
+ const vmm = getExecuteVm();
6054
+ vmm.reset();
6055
+ const opcodes = new Uint8Array(item.opcodesBuffer, 0, item.opcodesLength);
6056
+ const numbers = new Float64Array(item.numbersBuffer, 0, item.numbersLength);
6057
+ const bytecode = { opcodes, numbers, strings: item.strings };
6058
+ try {
6059
+ const result = executeBytecode(bytecode, vmm);
6060
+ if (result.type === "pending") {
6061
+ return {
6062
+ lineNumber: item.lineNumber,
6063
+ valueType: 12 /* Pending */,
6064
+ value: 0,
6065
+ isPending: true,
6066
+ queryKey: result.queryKey
6067
+ };
6242
6068
  }
6243
- this._positionCache = /* @__PURE__ */ new Map();
6244
- let pos = 1;
6245
- for (const id of this.orderTree) {
6246
- this._positionCache.set(id, pos++);
6069
+ if (result.type === "error") {
6070
+ return {
6071
+ lineNumber: item.lineNumber,
6072
+ valueType: 13 /* Error */,
6073
+ value: 0,
6074
+ isPending: false,
6075
+ unit: result.error.message
6076
+ };
6247
6077
  }
6248
- return this._positionCache.get(lineId) ?? -1;
6078
+ const val = result.value;
6079
+ return {
6080
+ lineNumber: item.lineNumber,
6081
+ valueType: val.type,
6082
+ value: typeof val.value === "number" ? val.value : typeof val.value === "bigint" ? Number(val.value) : 0,
6083
+ unit: val.unit,
6084
+ isPending: false
6085
+ };
6086
+ } catch {
6087
+ return {
6088
+ lineNumber: item.lineNumber,
6089
+ valueType: 13 /* Error */,
6090
+ value: 0,
6091
+ isPending: false,
6092
+ unit: "Worker execution failed"
6093
+ };
6249
6094
  }
6250
- /**
6251
- * Get all LineState entries within the given viewport range (1-based, inclusive).
6252
- * Uses SegmentTree.getRange() for O(viewport + log N) collection instead of
6253
- * O(viewport × log N) per-line lookups.
6254
- */
6255
- getVisibleLines(startLine, endLine) {
6256
- const lineIds = this.orderTree.getRange(startLine - 1, endLine - 1);
6257
- const result = [];
6258
- for (const lineId of lineIds) {
6259
- const state = this.lines.get(lineId);
6260
- if (state) result.push(state);
6261
- }
6262
- return result;
6095
+ }
6096
+ function handleExecuteBatch(msg) {
6097
+ const results = [];
6098
+ for (const item of msg.items) {
6099
+ results.push(executeOne(item));
6263
6100
  }
6264
- /**
6265
- * Get all LineState entries in order. Useful for batch processing.
6266
- */
6267
- getAllLines() {
6268
- return this.getVisibleLines(1, this.lineCount);
6101
+ self.postMessage({
6102
+ id: msg.id,
6103
+ type: "EXECUTE_RESULT",
6104
+ results
6105
+ });
6106
+ }
6107
+ var inWorkerScope = typeof self !== "undefined" && typeof window === "undefined";
6108
+ var handleMessage = (event) => {
6109
+ if (event.origin && self.location && event.origin !== self.location.origin) return;
6110
+ const msg = event.data;
6111
+ switch (msg.type) {
6112
+ case "COMPILE_BATCH":
6113
+ handleCompileBatch(msg);
6114
+ break;
6115
+ case "EXECUTE_BATCH":
6116
+ handleExecuteBatch(msg);
6117
+ break;
6118
+ case "TERMINATE": {
6119
+ if (compileEngine) {
6120
+ compileEngine.clear();
6121
+ compileEngine = null;
6122
+ }
6123
+ if (executeVm) {
6124
+ executeVm.reset();
6125
+ executeVm = null;
6126
+ }
6127
+ self.postMessage({
6128
+ id: msg.id,
6129
+ type: "COMPILE_RESULT",
6130
+ results: []
6131
+ });
6132
+ break;
6133
+ }
6134
+ default: {
6135
+ self.postMessage({
6136
+ id: -1,
6137
+ type: "COMPILE_RESULT",
6138
+ results: [],
6139
+ error: `Unknown message type: ${msg.type}`
6140
+ });
6141
+ }
6269
6142
  }
6270
- /**
6271
- * Get a LineState by its persistent line ID. O(1).
6272
- */
6273
- getLineById(lineId) {
6274
- return this.lines.get(lineId);
6143
+ };
6144
+ if (inWorkerScope) {
6145
+ self.onmessage = handleMessage;
6146
+ }
6147
+
6148
+ // src/engine/ExecutionPool.ts
6149
+ var WORKER_BATCH_TIMEOUT_MS = 3e4;
6150
+ var WORKER_OFFLOAD_THRESHOLD = 50;
6151
+ var ExecutionPool = class {
6152
+ constructor(poolSize) {
6153
+ this.workers = [];
6154
+ this.nextWorker = 0;
6155
+ this.nextId = 1;
6156
+ this.pendingBatches = /* @__PURE__ */ new Map();
6157
+ this.terminated = false;
6158
+ // ── Public API ────────────────────────────────────────────────────────
6159
+ /** Cached availability flag. Checked once; createExecutionWorker never called again. */
6160
+ this._available = null;
6161
+ this.poolSize = Math.min(poolSize ?? this.defaultPoolSize(), 4);
6275
6162
  }
6276
- /**
6277
- * Get all lines that are marked dirty.
6278
- */
6279
- getDirtyLines() {
6280
- const result = [];
6281
- for (const state of this.lines.values()) {
6282
- if (state.dirty) result.push(state);
6163
+ defaultPoolSize() {
6164
+ if (typeof navigator !== "undefined" && navigator.hardwareConcurrency) {
6165
+ return Math.min(navigator.hardwareConcurrency, 4);
6283
6166
  }
6284
- return result;
6167
+ return 2;
6285
6168
  }
6286
6169
  /**
6287
- * Whether any line before `position` (1-based, exclusive) is dirty.
6288
- *
6289
- * Used by ThreeTierEvaluator.setViewport() to decide whether cached
6290
- * checkpoint state might be stale and a full evaluate() (from line 1) is
6291
- * needed instead of the cheap viewport-only path.
6292
- *
6293
- * O(d log N) where d = current dirty line count via {@link dirtyLineIds},
6294
- * not O(N log N), a document that's mostly clean (the steady state after
6295
- * initial load) answers this in the cost of resolving a handful of
6296
- * lineIds to positions, not walking every line up to `position`.
6170
+ * Whether worker-based execution is available.
6171
+ * Returns false in Node.js / jsdom test environments where Worker
6172
+ * may be polyfilled but execution workers aren't functional.
6297
6173
  */
6298
- hasAnyDirtyLineBefore(position) {
6299
- for (const lineId of this.dirtyLineIds) {
6300
- const pos = this.getLinePosition(lineId);
6301
- if (pos >= 1 && pos < position) return true;
6174
+ isAvailable() {
6175
+ if (this.terminated) return false;
6176
+ if (this._available !== null) return this._available;
6177
+ if (typeof Worker === "undefined") {
6178
+ this._available = false;
6179
+ return false;
6302
6180
  }
6303
- return false;
6304
- }
6305
- /**
6306
- * Whether any **variable-definition** line before `position` (1-based,
6307
- * exclusive) is dirty.
6308
- *
6309
- * Narrower than {@link hasAnyDirtyLineBefore}: `VMCheckpointer.snapshot()`
6310
- * only ever records state for lines with `writes.length > 0` (see
6311
- * VMCheckpoints.ts), so a dirty plain-expression line before the viewport
6312
- * cannot have invalidated any checkpoint, there's no checkpoint entry
6313
- * for it to invalidate. Only a dirty variable-def line can mean the VM
6314
- * state a checkpoint would restore is stale.
6315
- *
6316
- * This distinction matters because `PageManager.evictPageBytecode()`
6317
- * marks evicted non-variable-def lines dirty (so they get Tier 1 if
6318
- * scrolled back into view), and Tier 3's compile-only path never clears
6319
- * `dirty` for non-variable-def lines by design. Using the broader
6320
- * `hasAnyDirtyLineBefore` here meant scrolling far into a large,
6321
- * variable-def-free document would trip `setViewport()`'s fallback to
6322
- * `evaluate()` on every single call, evaluate() reprocesses the evicted
6323
- * lines via Tier 3, which recompiles their bytecode without clearing
6324
- * dirty, so the very next `maintainAfterEval()` re-evicts and re-dirties
6325
- * the same lines, forever re-triggering the fallback on an otherwise
6326
- * unchanged viewport.
6327
- */
6328
- hasAnyDirtyVariableDefLineBefore(position) {
6329
- for (const lineId of this.dirtyLineIds) {
6330
- const state = this.lines.get(lineId);
6331
- if (!state || !state.isVariableDef) continue;
6332
- const pos = this.getLinePosition(lineId);
6333
- if (pos >= 1 && pos < position) return true;
6181
+ try {
6182
+ const w = engine_worker_default();
6183
+ w.terminate();
6184
+ this._available = true;
6185
+ return true;
6186
+ } catch {
6187
+ this._available = false;
6188
+ return false;
6334
6189
  }
6335
- return false;
6336
6190
  }
6337
- /** Number of lines currently marked dirty. For diagnostics/tests. */
6338
- get dirtyCount() {
6339
- return this.dirtyLineIds.size;
6340
- }
6341
- // ── Thread-safety validation ────────────────────────────────────────
6342
6191
  /**
6343
- * Verify that bytecode compiled by a worker is still valid for this line.
6192
+ * Execute a batch of line entries via the worker pool.
6344
6193
  *
6345
- * When Phase 5.2h sends compilation to a worker, the worker posts back
6346
- * `{lineId, bytecode, reads, writes, compiledAgainstHash}`. Between dispatch
6347
- * and response, the user may have edited the line. This method lets the
6348
- * main thread check whether the bytecode is still applicable.
6194
+ * Takes ordered line numbers and their LineCache entries. Clones bytecode
6195
+ * ArrayBuffers for transfer, dispatches to workers round-robin, and
6196
+ * returns the serialized results (with 30s timeout fallback).
6349
6197
  *
6350
- * @returns true if the line still exists and its text hash matches.
6351
- */
6352
- isBytecodeValid(lineId, compiledAgainstHash) {
6353
- const state = this.lines.get(lineId);
6354
- return state !== void 0 && state.textHash === compiledAgainstHash;
6355
- }
6356
- // ── State mutations ─────────────────────────────────────────────────
6357
- /**
6358
- * Mark a line as clean (re-evaluated successfully).
6198
+ * Falls back to undefined when workers are unavailable, caller should
6199
+ * use the main-thread path.
6200
+ *
6201
+ * @returns ExecuteResult[] on success, undefined if workers unavailable.
6359
6202
  */
6360
- markClean(lineId) {
6361
- const state = this.lines.get(lineId);
6362
- if (state) {
6363
- state.dirty = false;
6364
- this.dirtyLineIds.delete(lineId);
6203
+ executeBatch(orderedLineNumbers, entries) {
6204
+ if (!this.isAvailable()) return void 0;
6205
+ if (orderedLineNumbers.length === 0) return Promise.resolve([]);
6206
+ this.ensureWorkers();
6207
+ const items = [];
6208
+ for (const lineNumber of orderedLineNumbers) {
6209
+ const entry = entries.get(lineNumber);
6210
+ if (!entry || entry.bytecode.opcodes.length === 0) continue;
6211
+ const opcodes = entry.bytecode.opcodes;
6212
+ const numbers = entry.bytecode.numbers;
6213
+ const opcodesClone = opcodes.buffer.slice(
6214
+ opcodes.byteOffset,
6215
+ opcodes.byteOffset + opcodes.byteLength
6216
+ );
6217
+ const numbersClone = numbers.buffer.slice(
6218
+ numbers.byteOffset,
6219
+ numbers.byteOffset + numbers.byteLength
6220
+ );
6221
+ items.push({
6222
+ lineNumber,
6223
+ opcodesBuffer: opcodesClone,
6224
+ numbersBuffer: numbersClone,
6225
+ opcodesLength: opcodes.length,
6226
+ numbersLength: numbers.length,
6227
+ strings: [...entry.bytecode.strings]
6228
+ });
6365
6229
  }
6366
- }
6367
- /**
6368
- * Mark a line as dirty (needs re-evaluation) by its 1-based position.
6369
- * Convenience for callers that have line numbers instead of line IDs.
6370
- */
6371
- markDirtyByLineNumber(lineNumber) {
6372
- const state = this.getLineAt(lineNumber);
6373
- if (state) {
6374
- state.dirty = true;
6375
- this.dirtyLineIds.add(state.lineId);
6230
+ if (items.length === 0) return Promise.resolve([]);
6231
+ const transferList = [];
6232
+ for (const item of items) {
6233
+ if (item.opcodesBuffer.byteLength > 0) transferList.push(item.opcodesBuffer);
6234
+ if (item.numbersBuffer.byteLength > 0) transferList.push(item.numbersBuffer);
6376
6235
  }
6236
+ const batchId = this.nextId++;
6237
+ const worker = this.getNextWorker();
6238
+ return new Promise((resolve) => {
6239
+ const timeoutId = setTimeout(() => {
6240
+ this.pendingBatches.delete(batchId);
6241
+ resolve([]);
6242
+ }, WORKER_BATCH_TIMEOUT_MS);
6243
+ this.pendingBatches.set(batchId, {
6244
+ id: batchId,
6245
+ lineNumbers: orderedLineNumbers,
6246
+ resolve: (results) => {
6247
+ clearTimeout(timeoutId);
6248
+ resolve(results);
6249
+ }
6250
+ });
6251
+ worker.postMessage(
6252
+ { type: "EXECUTE_BATCH", id: batchId, items },
6253
+ transferList
6254
+ );
6255
+ });
6377
6256
  }
6378
6257
  /**
6379
- * Mark a line as dirty (needs re-evaluation).
6258
+ * Clean up all workers and pending batches. Called on engine clear.
6259
+ * Workers are recreated lazily on next dispatch.
6380
6260
  */
6381
- markDirty(lineId) {
6382
- const state = this.lines.get(lineId);
6383
- if (state) {
6384
- state.dirty = true;
6385
- this.dirtyLineIds.add(lineId);
6261
+ clear() {
6262
+ for (const [, batch] of this.pendingBatches) {
6263
+ batch.resolve([]);
6386
6264
  }
6387
- }
6388
- /**
6389
- * Mark all lines as dirty (e.g., after plugin register/unregister).
6390
- */
6391
- invalidateAll() {
6392
- for (const state of this.lines.values()) {
6393
- state.dirty = true;
6265
+ this.pendingBatches.clear();
6266
+ for (const w of this.workers) {
6267
+ w.terminate();
6394
6268
  }
6395
- this.dirtyLineIds = new Set(this.lines.keys());
6396
- }
6397
- /**
6398
- * Update a line's evaluation state after successful execution (Tier 1 / Tier 2).
6399
- *
6400
- * Sets results, bytecodes, reads, writes, and marks the line clean.
6401
- * Supports multi-expression lines (inline solves) via parallel arrays.
6402
- *
6403
- * @param lineId - Persistent line identifier.
6404
- * @param results - Evaluation result groups for each expression (in order). Each element is a Value[].
6405
- * @param bytecodes - Compiled bytecode for each expression (in order).
6406
- * @param expressions - Extracted expression strings (in order).
6407
- * @param reads - Aggregated read variables across all expressions.
6408
- * @param writes - Aggregated write variables across all expressions.
6409
- * @param isVariableDef - True if any expression defines a variable.
6410
- * @param inlineSolveCount - Number of inline solves (0 for full-line).
6411
- */
6412
- updateLineResult(lineId, results, bytecodes, expressions, reads, writes, isVariableDef, inlineSolveCount = 0) {
6413
- const state = this.lines.get(lineId);
6414
- if (!state) return;
6415
- state.results = results;
6416
- state.result = results[0]?.[0] ?? null;
6417
- state.bytecodes = bytecodes;
6418
- state.expressions = expressions;
6419
- state.reads = reads;
6420
- state.writes = writes;
6421
- state.isVariableDef = isVariableDef;
6422
- state.inlineSolveCount = inlineSolveCount;
6423
- state.dirty = false;
6424
- this.dirtyLineIds.delete(lineId);
6269
+ this.workers = [];
6270
+ this.nextWorker = 0;
6425
6271
  }
6426
6272
  /**
6427
- * Update a line's compile-only state (Tier 3: background compilation).
6428
- *
6429
- * Stores expressions, bytecodes, reads, and writes. Does NOT set results
6430
- * and does NOT mark the line clean, it still needs execution (Tier 1 or
6431
- * Tier 2) to produce results. This distinction allows the three-tier
6432
- * evaluation strategy: compile invisible lines in the background without
6433
- * executing them, then execute from cached bytecode when scrolled into view.
6434
- *
6435
- * @param lineId - Persistent line identifier.
6436
- * @param expressions - Extracted expression strings (in order).
6437
- * @param bytecodes - Compiled bytecode for each expression (in order).
6438
- * @param reads - Aggregated read variables across all expressions.
6439
- * @param writes - Aggregated write variables across all expressions.
6440
- * @param isVariableDef - True if any expression defines a variable.
6441
- * @param inlineSolveCount - Number of inline solves (0 for full-line).
6273
+ * Full teardown. After destroy(), the pool is permanently unusable.
6442
6274
  */
6443
- updateLineCompiled(lineId, expressions, bytecodes, reads, writes, isVariableDef, inlineSolveCount = 0) {
6444
- const state = this.lines.get(lineId);
6445
- if (!state) return;
6446
- state.expressions = expressions;
6447
- state.bytecodes = bytecodes;
6448
- state.reads = reads;
6449
- state.writes = writes;
6450
- state.isVariableDef = isVariableDef;
6451
- state.inlineSolveCount = inlineSolveCount;
6452
- }
6453
- // ── Properties ──────────────────────────────────────────────────────
6454
- get lineCount() {
6455
- return this.orderTree.length;
6456
- }
6457
- get isEmpty() {
6458
- return this.orderTree.isEmpty;
6275
+ destroy() {
6276
+ this.terminated = true;
6277
+ this.clear();
6459
6278
  }
6460
- /**
6461
- * Iterator over LineState in document order.
6462
- */
6463
- *[Symbol.iterator]() {
6464
- for (const lineId of this.orderTree) {
6465
- const state = this.lines.get(lineId);
6466
- if (state) yield state;
6279
+ // ── Private ────────────────────────────────────────────────────────────
6280
+ ensureWorkers() {
6281
+ if (this.workers.length > 0) return;
6282
+ for (let i = 0; i < this.poolSize; i++) {
6283
+ const worker = engine_worker_default();
6284
+ worker.onmessage = (event) => {
6285
+ this.handleWorkerMessage(event.data);
6286
+ };
6287
+ worker.onerror = (err) => {
6288
+ console.error(`[ExecutionPool] Worker ${i} error:`, err.message);
6289
+ for (const [id, batch] of this.pendingBatches) {
6290
+ batch.resolve([]);
6291
+ this.pendingBatches.delete(id);
6292
+ }
6293
+ };
6294
+ this.workers.push(worker);
6467
6295
  }
6468
6296
  }
6469
- // ── Lifecycle ───────────────────────────────────────────────────────
6470
- clear() {
6471
- this.lines.clear();
6472
- this.orderTree.clear();
6473
- this._positionCache = null;
6474
- this.nextLineId = 1;
6297
+ getNextWorker() {
6298
+ const w = this.workers[this.nextWorker];
6299
+ this.nextWorker = (this.nextWorker + 1) % this.workers.length;
6300
+ return w;
6475
6301
  }
6476
- /**
6477
- * Serialize the document model to a plain object for debugging.
6478
- */
6479
- toJSON() {
6480
- return {
6481
- lineCount: this.lineCount,
6482
- lines: this.getAllLines().map((s) => ({
6483
- lineId: s.lineId,
6484
- text: s.text.substring(0, 80),
6485
- // truncate for readability
6486
- textHash: s.textHash,
6487
- dirty: s.dirty,
6488
- isVariableDef: s.isVariableDef,
6489
- isEmpty: s.isEmpty,
6490
- hasBytecode: s.bytecodes.length > 0,
6491
- hasResult: s.results.length > 0,
6492
- inlineSolveCount: s.inlineSolveCount,
6493
- reads: s.reads,
6494
- writes: s.writes
6495
- }))
6496
- };
6302
+ handleWorkerMessage(data) {
6303
+ if (data.type !== "EXECUTE_RESULT") return;
6304
+ const batch = this.pendingBatches.get(data.id);
6305
+ if (!batch) return;
6306
+ this.pendingBatches.delete(data.id);
6307
+ batch.resolve(data.results);
6497
6308
  }
6498
6309
  };
6499
-
6500
- // src/engine/CompilationWorkerManager.ts
6501
- var CompilationWorkerManager = class {
6502
- constructor() {
6503
- this.worker = null;
6504
- this.nextId = 1;
6505
- this.pending = /* @__PURE__ */ new Map();
6506
- }
6507
- /**
6508
- * Ensure the worker is started (lazy initialization).
6509
- * Uses esbuild-plugin-inline-worker to inline the worker as a blob URL.
6510
- */
6511
- ensureWorker() {
6512
- if (this.worker) return this.worker;
6513
- this.worker = engine_worker_default();
6514
- this.worker.onmessage = (event) => {
6515
- const data = event.data;
6516
- if (!data || data.type !== "COMPILE_RESULT") return;
6517
- const pending = this.pending.get(data.id);
6518
- if (!pending) return;
6519
- this.pending.delete(data.id);
6520
- const results = [];
6521
- for (const raw of data.results) {
6522
- results.push(this.reconstructResult(raw));
6523
- }
6524
- pending.resolve(results);
6525
- };
6526
- this.worker.onerror = (err) => {
6527
- for (const [id, pending] of this.pending) {
6528
- pending.reject(
6529
- new Error(`Compilation worker error: ${err.message}`)
6530
- );
6531
- this.pending.delete(id);
6532
- }
6533
- };
6534
- return this.worker;
6310
+ function reconstructValue(result) {
6311
+ switch (result.valueType) {
6312
+ case 0 /* Number */:
6313
+ return numberValue(result.value);
6314
+ case 1 /* Hex */:
6315
+ return hexValue(result.value);
6316
+ case 2 /* BigInt */:
6317
+ return bigIntValue(BigInt(result.value));
6318
+ case 3 /* String */:
6319
+ return stringValue(String(result.value));
6320
+ case 4 /* Datetime */:
6321
+ return datetimeValue(result.value);
6322
+ case 5 /* Percentage */:
6323
+ return percentageValue(result.value);
6324
+ case 6 /* Uom */:
6325
+ return uomValue(result.value, result.unit ?? "");
6326
+ case 7 /* Matrix */:
6327
+ return numberValue(result.value);
6328
+ case 10 /* Boolean */:
6329
+ return boolValue(result.value !== 0);
6330
+ case 12 /* Pending */:
6331
+ return pendingValue(result.queryKey ?? "");
6332
+ case 13 /* Error */:
6333
+ return errorValue("WORKER_EXECUTION_ERROR", result.unit ?? "Unknown worker error");
6334
+ default:
6335
+ return numberValue(result.value);
6535
6336
  }
6536
- /**
6537
- * Batch-compile expressions in the worker.
6538
- *
6539
- * Sends a batch of { lineId, expression, textHash } to the worker,
6540
- * which compiles each and transfers bytecode ArrayBuffers back.
6541
- * Bytecode is reconstructed into BytecodeProgram objects.
6542
- *
6543
- * Results are returned in the same order as the input items.
6544
- *
6545
- * @param items Expressions to compile.
6546
- * @returns Compiled bytecode for each item (or error).
6547
- */
6548
- async compileBatch(items) {
6549
- if (items.length === 0) return [];
6550
- const worker = this.ensureWorker();
6551
- const id = this.nextId++;
6552
- return new Promise((resolve, reject) => {
6553
- this.pending.set(id, { resolve, reject });
6554
- worker.postMessage({
6555
- type: "COMPILE_BATCH",
6556
- id,
6557
- items
6558
- });
6559
- });
6337
+ }
6338
+
6339
+ // src/engine/AsyncResolutionBatcher.ts
6340
+ var _AsyncResolutionBatcher = class _AsyncResolutionBatcher {
6341
+ constructor(dag, lineCache, vm, highWaterMark = _AsyncResolutionBatcher.DEFAULT_HIGH_WATER_MARK) {
6342
+ this.pending = [];
6343
+ this.scheduled = false;
6344
+ /** Set to true by clearPending(), flush() checks this to abort stale work. */
6345
+ this.cleared = false;
6346
+ /**
6347
+ * Worker pool for offloading VM re-execution when the affected line
6348
+ * count exceeds WORKER_OFFLOAD_THRESHOLD (50). Lazily created on
6349
+ * first dispatch; cleared on clearAll().
6350
+ */
6351
+ this.executionPool = null;
6352
+ /**
6353
+ * Number of flushes actually dispatched to the worker pool (as opposed
6354
+ * to falling back to the main thread because Worker is unavailable).
6355
+ * Exposed via {@link workerOffloadCount} for the Workers diagnostic tab.
6356
+ */
6357
+ this.workerOffloadDispatchCount = 0;
6358
+ /**
6359
+ * Controller for the internal event stream. Set during stream
6360
+ * initialization; cleared on stream cancellation or clearAll().
6361
+ */
6362
+ this._streamController = null;
6363
+ /**
6364
+ * Test-only synchronous capture array. When enabled (non-null), every
6365
+ * event is synchronously pushed here in addition to the stream.
6366
+ * Tests read from this array to avoid async stream reader timing issues.
6367
+ */
6368
+ this._testCaptures = null;
6369
+ /**
6370
+ * Called for each line whose result is patched after an async resolution,
6371
+ * on both the main-thread and worker-pool paths.
6372
+ *
6373
+ * **A host that displays async results must set this.** It is the only
6374
+ * mechanism that moves a resolved value out of the LineCache and into the
6375
+ * host's own document state. The engine cannot do it itself: it does not own
6376
+ * a document, the host does, and the batcher has no reference to one.
6377
+ *
6378
+ * Nullable rather than a constructor parameter because it is cleared by
6379
+ * `clearAll()` and re-wired on re-subscribe, so it cannot be readonly. That
6380
+ * makes it easy to miss, which is why {@link warnIfUnwired} exists: leaving
6381
+ * it unset means async values resolve into the cache and are never shown,
6382
+ * with nothing to indicate why. A host that genuinely does not want async
6383
+ * results should not register async resolvers at all.
6384
+ */
6385
+ this.onLineResult = null;
6386
+ /**
6387
+ * Whether {@link warnIfUnwired} has already fired.
6388
+ *
6389
+ * Once per instance, not once per resolution. A document with fifty live
6390
+ * data lines would otherwise produce fifty identical warnings on the first
6391
+ * fetch, which is how a useful warning becomes noise people filter out.
6392
+ */
6393
+ this.warnedAboutMissingHook = false;
6394
+ this.dag = dag;
6395
+ this.lineCache = lineCache;
6396
+ this.vm = vm;
6397
+ this.highWaterMark = highWaterMark;
6398
+ this._eventStream = this.createEventStream();
6560
6399
  }
6561
6400
  /**
6562
- * Store worker-compiled bytecode into the DocumentModel, with safety
6563
- * validation. Only stores bytecode for lines whose text has not changed
6564
- * since the compilation request was dispatched.
6565
- *
6566
- * Results for the same lineId are batched, all bytecodes from
6567
- * successful compilations are passed to updateLineCompiled in a single
6568
- * call. This supports multi-expression lines (inline solves).
6401
+ * Warn once if an async result resolved with no {@link onLineResult} wired.
6569
6402
  *
6570
- * @param results Compiled results from the worker.
6571
- * @param doc The target document model.
6572
- * @returns Number of results successfully stored (passed safety check).
6573
- */
6574
- storeResults(results, doc) {
6575
- const byLineId = /* @__PURE__ */ new Map();
6576
- for (const result of results) {
6577
- if (result.error) continue;
6578
- const state = doc.getLineById(result.lineId);
6579
- if (!state) continue;
6580
- if (state.textHash !== result.compiledAgainstHash) continue;
6581
- let batch = byLineId.get(result.lineId);
6582
- if (!batch) {
6583
- batch = {
6584
- bytecodes: [],
6585
- reads: /* @__PURE__ */ new Set(),
6586
- writes: /* @__PURE__ */ new Set(),
6587
- isVariableDef: false,
6588
- expressions: state.expressions.length > 0 ? [...state.expressions] : [],
6589
- textHash: result.compiledAgainstHash
6590
- };
6591
- byLineId.set(result.lineId, batch);
6592
- }
6593
- batch.bytecodes.push(result.program);
6594
- for (const r of result.reads) batch.reads.add(r);
6595
- for (const w of result.writes) batch.writes.add(w);
6596
- if (result.isVariableDef) batch.isVariableDef = true;
6597
- }
6598
- let stored = 0;
6599
- for (const [lineId, batch] of byLineId) {
6600
- if (batch.bytecodes.length === 0) continue;
6601
- const state = doc.getLineById(lineId);
6602
- const inlineSolveCount = state?.inlineSolveCount ?? 0;
6603
- doc.updateLineCompiled(
6604
- lineId,
6605
- batch.expressions,
6606
- batch.bytecodes,
6607
- [...batch.reads],
6608
- [...batch.writes],
6609
- batch.isVariableDef,
6610
- inlineSolveCount
6611
- );
6612
- stored += batch.bytecodes.length;
6613
- }
6614
- return stored;
6615
- }
6616
- /**
6617
- * Terminate the worker and clean up.
6403
+ * The failure this catches is silent by nature: the value arrives, the cache
6404
+ * updates, and the line keeps showing pending forever. Without this a host
6405
+ * author has no thread to pull on.
6618
6406
  */
6619
- terminate() {
6620
- if (this.worker) {
6621
- this.worker.terminate();
6622
- this.worker = null;
6623
- this.pending.clear();
6624
- }
6407
+ warnIfUnwired() {
6408
+ if (this.onLineResult || this.warnedAboutMissingHook) return;
6409
+ this.warnedAboutMissingHook = true;
6410
+ console.warn(
6411
+ "[solve-engine] An async result resolved but AsyncResolutionBatcher.onLineResult is not set, so the value cannot reach your document and the line will keep showing as pending. Set it to mirror resolved values into your own state. This warning appears once per batcher."
6412
+ );
6625
6413
  }
6626
6414
  /**
6627
- * Whether the worker is currently active.
6415
+ * Create a fresh internal event stream and wire its controller.
6416
+ * Called from the constructor and again from clearAll() so the batcher
6417
+ * keeps emitting events after an engine clear, the engine instance
6418
+ * (and this batcher) live on across clear() calls.
6628
6419
  */
6629
- get isActive() {
6630
- return this.worker !== null;
6420
+ createEventStream() {
6421
+ return new ReadableStream({
6422
+ start: (controller) => {
6423
+ this._streamController = controller;
6424
+ },
6425
+ cancel: () => {
6426
+ this._streamController = null;
6427
+ }
6428
+ }, new CountQueuingStrategy({ highWaterMark: this.highWaterMark }));
6631
6429
  }
6632
- // ── Private helpers ────────────────────────────────────────────────
6430
+ // ── Public API ────────────────────────────────────────────────────
6633
6431
  /**
6634
- * Reconstruct a BytecodeProgram from transferred ArrayBuffers.
6432
+ * Add a resolved query key to the pending batch.
6635
6433
  *
6636
- * The transferred ArrayBuffers are zero-copy, we create TypedArray
6637
- * views directly over them. No data is copied or serialized.
6434
+ * Called by ExpressionEngine.resolveAsync() after a promise resolves or errors.
6435
+ * If this is the first entry in the current tick, schedules a microtask flush.
6638
6436
  */
6639
- reconstructResult(raw) {
6640
- if (raw.error) {
6641
- return {
6642
- lineId: raw.lineId,
6643
- compiledAgainstHash: raw.compiledAgainstHash,
6644
- program: { opcodes: new Uint8Array(0), numbers: new Float64Array(0), strings: [], hasAsync: false },
6645
- reads: [],
6646
- writes: [],
6647
- isVariableDef: false,
6648
- error: raw.error
6649
- };
6437
+ add(entry) {
6438
+ this.cleared = false;
6439
+ for (const existing of this.pending) {
6440
+ if (existing.packageId === entry.packageId && existing.queryKey === entry.queryKey) {
6441
+ return;
6442
+ }
6650
6443
  }
6651
- const opcodes = raw.opcodesLength > 0 ? new Uint8Array(raw.opcodesBuffer, 0, raw.opcodesLength) : new Uint8Array(0);
6652
- const numbers = raw.numbersLength > 0 ? new Float64Array(raw.numbersBuffer, 0, raw.numbersLength) : new Float64Array(0);
6653
- const program = {
6654
- opcodes,
6655
- numbers,
6656
- strings: raw.strings,
6657
- hasAsync: false
6658
- };
6659
- return {
6660
- lineId: raw.lineId,
6661
- compiledAgainstHash: raw.compiledAgainstHash,
6662
- program,
6663
- reads: raw.reads,
6664
- writes: raw.writes,
6665
- isVariableDef: raw.isVariableDef,
6666
- error: null
6667
- };
6668
- }
6669
- };
6670
-
6671
- // src/engine/PageManager.ts
6672
- var PAGE_SIZE = 128;
6673
- var HOT_PAGE_RADIUS = 3;
6674
- var WARM_PAGE_RADIUS = 6;
6675
- var PRELOAD_PAGE_COUNT = 2;
6676
- var COLD_EVICT_BUFFER = HOT_PAGE_RADIUS;
6677
- var PageManager = class _PageManager {
6678
- constructor() {
6679
- /** Previous viewport for scroll direction detection. null = no previous viewport. */
6680
- this.lastViewportStart = null;
6681
- /** Saved scroll direction from the most recent maintainAfterEval call. */
6682
- this.savedDirection = null;
6683
- /** Per-page access counter for LRU tracking. */
6684
- this.pageAccess = /* @__PURE__ */ new Map();
6685
- /** Monotonically increasing access sequence number. */
6686
- this.accessSeq = 0;
6687
- }
6688
- // ── Static helpers ─────────────────────────────────────────────────
6689
- /** Get the page number for a 1-based line number (0-based page index). */
6690
- static pageForLine(lineNumber) {
6691
- return Math.floor((lineNumber - 1) / PAGE_SIZE);
6692
- }
6693
- /** Get the 1-based inclusive line range for a page. */
6694
- static pageRange(pageNum, docLineCount) {
6695
- return {
6696
- startLine: pageNum * PAGE_SIZE + 1,
6697
- endLine: Math.min((pageNum + 1) * PAGE_SIZE, docLineCount)
6698
- };
6699
- }
6700
- // ── Public API ─────────────────────────────────────────────────────
6701
- /**
6702
- * Detect scroll direction from viewport movement.
6703
- * Returns null on first call (no previous viewport) or no movement.
6704
- */
6705
- detectDirection(newViewport) {
6706
- if (this.lastViewportStart === null) return null;
6707
- if (newViewport.startLine > this.lastViewportStart) return "down";
6708
- if (newViewport.startLine < this.lastViewportStart) return "up";
6709
- return null;
6710
- }
6711
- /**
6712
- * Maintain page tiers after evaluation.
6713
- *
6714
- * 1. Records the new viewport position
6715
- * 2. Touches hot pages (viewport ± HOT_PAGE_RADIUS) for LRU
6716
- * 3. Evicts cold/warm pages based on distance from viewport
6717
- *
6718
- * Call this after every `evaluate()`, `setViewport()`, or `evaluateAll()`.
6719
- */
6720
- maintainAfterEval(viewport, doc) {
6721
- this.savedDirection = this.detectDirection(viewport);
6722
- this.lastViewportStart = viewport.startLine;
6723
- const docLineCount = doc.lineCount;
6724
- const viewportStartPage = _PageManager.pageForLine(viewport.startLine);
6725
- const viewportEndPage = _PageManager.pageForLine(viewport.endLine);
6726
- const lastPage = _PageManager.pageForLine(docLineCount);
6727
- const hotStart = Math.max(0, viewportStartPage - HOT_PAGE_RADIUS);
6728
- const hotEnd = Math.min(lastPage, viewportEndPage + HOT_PAGE_RADIUS);
6729
- const warmStart = Math.max(0, viewportStartPage - WARM_PAGE_RADIUS);
6730
- const warmEnd = Math.min(lastPage, viewportEndPage + WARM_PAGE_RADIUS);
6731
- for (let p = hotStart; p <= hotEnd; p++) {
6732
- this.touchPage(p);
6733
- }
6734
- for (let p = warmStart; p <= warmEnd; p++) {
6735
- if (p >= hotStart && p <= hotEnd) continue;
6736
- this.evictPageResults(p, doc, docLineCount);
6737
- }
6738
- const coldLeftEnd = warmStart - 1;
6739
- const coldLeftStart = Math.max(0, coldLeftEnd - COLD_EVICT_BUFFER + 1);
6740
- for (let p = coldLeftStart; p <= coldLeftEnd; p++) {
6741
- this.evictPageBytecode(p, doc, docLineCount);
6742
- }
6743
- const coldRightStart = warmEnd + 1;
6744
- const coldRightEnd = Math.min(lastPage, coldRightStart + COLD_EVICT_BUFFER - 1);
6745
- for (let p = coldRightStart; p <= coldRightEnd; p++) {
6746
- this.evictPageBytecode(p, doc, docLineCount);
6444
+ this.pending.push(entry);
6445
+ if (!this.scheduled) {
6446
+ this.scheduled = true;
6447
+ queueMicrotask(() => {
6448
+ try {
6449
+ this.flush();
6450
+ } catch (e) {
6451
+ const engineError = normalizeUnknownError(e);
6452
+ console.error(`[AsyncResolutionBatcher] flush() failed unexpectedly \u2014 this should never happen; please report: ${engineError.format()}`);
6453
+ }
6454
+ });
6747
6455
  }
6748
6456
  }
6749
6457
  /**
6750
- * Get lines ahead of the viewport that should be pre-compiled.
6458
+ * Get the native event stream for stream-based consumers.
6751
6459
  *
6752
- * Looks PRELOAD_PAGE_COUNT pages ahead of the current scroll direction.
6753
- * Only returns lines that are dirty, don't already have bytecode,
6754
- * and are not empty/markdown-only.
6460
+ * Use this for backpressure, cancellation, or the ability
6461
+ * to `pipeTo()` / `pipeThrough()` the event flow.
6755
6462
  *
6756
- * @param viewport The current viewport.
6757
- * @param doc The document model.
6758
- * @returns Items ready to send to the compilation worker.
6759
- */
6760
- getPreloadTargets(viewport, doc) {
6761
- const direction = this.savedDirection;
6762
- if (!direction) return [];
6763
- const items = [];
6764
- const viewportEndPage = _PageManager.pageForLine(viewport.endLine);
6765
- const viewportStartPage = _PageManager.pageForLine(viewport.startLine);
6766
- const lastPage = _PageManager.pageForLine(doc.lineCount);
6767
- const pages = [];
6768
- if (direction === "down") {
6769
- const startPage = viewportEndPage + HOT_PAGE_RADIUS + 1;
6770
- const endPage = Math.min(startPage + PRELOAD_PAGE_COUNT - 1, lastPage);
6771
- for (let p = startPage; p <= endPage && p <= lastPage; p++) {
6772
- pages.push(p);
6773
- }
6774
- } else {
6775
- const firstPreloadPage = viewportStartPage - HOT_PAGE_RADIUS - 1;
6776
- for (let p = firstPreloadPage; p > firstPreloadPage - PRELOAD_PAGE_COUNT && p >= 0; p--) {
6777
- pages.push(p);
6778
- }
6779
- }
6780
- for (const pageNum of pages) {
6781
- const range = _PageManager.pageRange(pageNum, doc.lineCount);
6782
- for (let pos = range.startLine; pos <= range.endLine; pos++) {
6783
- const state = doc.getLineAt(pos);
6784
- if (!state) continue;
6785
- if (!state.dirty) continue;
6786
- if (state.bytecodes.length > 0 && !state.isVariableDef) continue;
6787
- if (state.isEmpty) continue;
6788
- if (state.expressions.length > 0) {
6789
- for (const expression of state.expressions) {
6790
- if (!expression.trim()) continue;
6791
- items.push({
6792
- lineId: state.lineId,
6793
- expression,
6794
- textHash: state.textHash
6795
- });
6796
- }
6797
- } else {
6798
- const inlineSpans = sharedLexer.findInlineSolves(state.text);
6799
- if (inlineSpans.length > 0) {
6800
- for (const span of inlineSpans) {
6801
- if (!span.expression.trim()) continue;
6802
- items.push({
6803
- lineId: state.lineId,
6804
- expression: span.expression,
6805
- textHash: state.textHash
6806
- });
6807
- }
6808
- } else {
6809
- const expression = state.text.trim();
6810
- if (expression) {
6811
- items.push({
6812
- lineId: state.lineId,
6813
- expression,
6814
- textHash: state.textHash
6815
- });
6816
- }
6817
- }
6818
- }
6819
- }
6820
- }
6821
- return items;
6822
- }
6823
- /**
6824
- * Reset internal state (e.g., after document switch).
6463
+ * @returns A {@link ReadableStream} that emits {@link AsyncResolutionEvent}
6464
+ * items as the batcher processes async resolutions.
6825
6465
  */
6826
- clear() {
6827
- this.pageAccess.clear();
6828
- this.accessSeq = 0;
6829
- this.lastViewportStart = null;
6830
- this.savedDirection = null;
6466
+ getEventStream() {
6467
+ return this._eventStream;
6831
6468
  }
6832
- // ── Private helpers ─────────────────────────────────────────────────
6833
- /** Record a page access with a monotonic sequence number. */
6834
- touchPage(pageNum) {
6835
- this.pageAccess.set(pageNum, ++this.accessSeq);
6469
+ /** Number of resolutions currently queued for the next flush. */
6470
+ get pendingCount() {
6471
+ return this.pending.length;
6836
6472
  }
6837
- /**
6838
- * Evict results from all non-variable-def lines in a page.
6839
- * Bytecode is preserved so Tier 2 execution works on scroll-back.
6840
- */
6841
- evictPageResults(pageNum, doc, docLineCount) {
6842
- const range = _PageManager.pageRange(pageNum, docLineCount);
6843
- for (let pos = range.startLine; pos <= range.endLine; pos++) {
6844
- const state = doc.getLineAt(pos);
6845
- if (state && !state.isVariableDef && state.results.length > 0) {
6846
- state.results = [];
6847
- state.result = null;
6848
- }
6473
+ /** Number of pending entries collapsed by (packageId, queryKey) deduplication. */
6474
+ get dedupCount() {
6475
+ const dedup = /* @__PURE__ */ new Set();
6476
+ for (const entry of this.pending) {
6477
+ dedup.add(`${entry.packageId}:${entry.queryKey}`);
6849
6478
  }
6479
+ return Math.max(0, this.pending.length - dedup.size);
6850
6480
  }
6851
6481
  /**
6852
- * Evict bytecode + results from all non-variable-def lines in a page.
6853
- * Variable definition bytecode is **never** evicted, it forms the
6854
- * backbone of the DAG and VM checkpoints.
6855
- *
6856
- * Evicted lines are marked dirty so they get Tier 1 re-evaluation
6857
- * when scrolled back into view.
6482
+ * Whether the internal event stream currently has an active reader.
6483
+ * `1` if a consumer has called `getEventStream().getReader()` (or
6484
+ * otherwise locked the stream) and not released it, `0` otherwise.
6858
6485
  */
6859
- evictPageBytecode(pageNum, doc, docLineCount) {
6860
- const range = _PageManager.pageRange(pageNum, docLineCount);
6861
- for (let pos = range.startLine; pos <= range.endLine; pos++) {
6862
- const state = doc.getLineAt(pos);
6863
- if (!state || state.isVariableDef) continue;
6864
- if (state.bytecodes.length > 0 || state.results.length > 0) {
6865
- state.bytecodes = [];
6866
- state.results = [];
6867
- state.result = null;
6868
- doc.markDirty(state.lineId);
6869
- }
6870
- }
6486
+ get listenerCount() {
6487
+ return this._eventStream.locked ? 1 : 0;
6871
6488
  }
6872
- };
6873
-
6874
- // src/engine/ThreeTierEvaluator.ts
6875
- var EvalTier = /* @__PURE__ */ ((EvalTier2) => {
6876
- EvalTier2[EvalTier2["Tier1"] = 1] = "Tier1";
6877
- EvalTier2[EvalTier2["Tier2"] = 2] = "Tier2";
6878
- EvalTier2[EvalTier2["Tier3"] = 3] = "Tier3";
6879
- EvalTier2[EvalTier2["Skipped"] = 0] = "Skipped";
6880
- return EvalTier2;
6881
- })(EvalTier || {});
6882
- var ThreeTierEvaluator = class {
6883
- /**
6884
- * @param doc The persistent document model.
6885
- * @param engine The expression engine (shared VM is accessed via engine.getVM()).
6886
- * @param checkpointer Optional VM state checkpointer. If provided, the evaluator
6887
- * will create checkpoints after variable-definition lines and support fast VM
6888
- * restoration via `restoreTo()`. If omitted, checkpointing is disabled.
6889
- */
6890
- constructor(doc, engine, checkpointer) {
6891
- this.compilationWorker = null;
6892
- /**
6893
- * Unsubscribe from sharedGlobalVariableStore, set in the constructor
6894
- * called from terminateWorker(). See the subscription itself below for
6895
- * why this only marks lines dirty and never re-evaluates synchronously.
6896
- */
6897
- this.globalUnsubscribe = null;
6898
- this.doc = doc;
6899
- this.engine = engine;
6900
- this.dag = engine.getDag();
6901
- this.checkpointer = checkpointer ?? null;
6902
- this.pageManager = new PageManager();
6903
- this.engine.setDocumentModel(this.doc);
6904
- this.globalUnsubscribe = sharedGlobalVariableStore.subscribe((name) => {
6905
- for (const lineNumber of this.dag.getAffectedLines(globalDagKey(name))) {
6906
- this.doc.markDirtyByLineNumber(lineNumber);
6907
- }
6908
- });
6489
+ /** Number of flushes that were actually dispatched to the worker pool. */
6490
+ get workerOffloadCount() {
6491
+ return this.workerOffloadDispatchCount;
6909
6492
  }
6910
- /**
6911
- * Evaluate all lines needed to render the given viewport.
6912
- *
6913
- * Processes lines from 1 to `viewport.endLine` in document order.
6914
- * Dirty lines in the viewport get Tier-1 full pipeline; clean cached
6915
- * lines get Tier-2 bytecode execution. Lines after the viewport
6916
- * get Tier-3 compile-only (with variable-def execution).
6917
- *
6918
- * @returns Results for all processed lines, including tier metadata.
6919
- */
6920
- evaluate(viewport, signal) {
6921
- this.engine.setKeystrokeSignal(signal ?? null);
6922
- enableValueArena();
6493
+ /** Remove all listeners and cancel pending batch. Called on engine clear. */
6494
+ clearAll() {
6495
+ this.pending = [];
6496
+ this.scheduled = false;
6497
+ this.cleared = true;
6498
+ this._testCaptures = null;
6499
+ this.onLineResult = null;
6923
6500
  try {
6924
- const lines = [];
6925
- const resultMap = /* @__PURE__ */ new Map();
6926
- const tierCounts = { tier1: 0, tier2: 0, tier3: 0, skipped: 0 };
6927
- const docEnd = this.doc.lineCount;
6928
- const evalEnd = Math.min(viewport.endLine, docEnd);
6929
- for (let pos = 1; pos <= evalEnd; pos++) {
6930
- const state = this.doc.getLineAt(pos);
6931
- if (!state) {
6932
- tierCounts.skipped++;
6933
- continue;
6934
- }
6935
- const inViewport = pos >= viewport.startLine && pos <= viewport.endLine;
6936
- const lineResult = this.evaluateSingleLine(state, pos, inViewport);
6937
- lines.push(lineResult);
6938
- if (lineResult.tier === 1 /* Tier1 */) tierCounts.tier1++;
6939
- else if (lineResult.tier === 2 /* Tier2 */) tierCounts.tier2++;
6940
- else if (lineResult.tier === 3 /* Tier3 */) tierCounts.tier3++;
6941
- else tierCounts.skipped++;
6942
- if (lineResult.results && inViewport) {
6943
- resultMap.set(pos, lineResult.results.flat());
6944
- }
6945
- }
6946
- this.pageManager.maintainAfterEval(viewport, this.doc);
6947
- return { lines, resultMap, tierCounts };
6948
- } finally {
6949
- this.engine.setKeystrokeSignal(null);
6950
- disableValueArena();
6501
+ this._streamController?.close();
6502
+ } catch {
6951
6503
  }
6504
+ this._streamController = null;
6505
+ this._eventStream = this.createEventStream();
6506
+ if (this.executionPool) {
6507
+ this.executionPool.clear();
6508
+ this.executionPool = null;
6509
+ }
6510
+ this.workerOffloadDispatchCount = 0;
6952
6511
  }
6512
+ // ── Private: flush ────────────────────────────────────────────────
6953
6513
  /**
6954
- * Background-compile invisible dirty lines beyond the viewport (Tier 3 only).
6955
- *
6956
- * Compiles expressions to discover reads/writes for the dependency graph
6957
- * without executing display-only expressions. Variable definitions are
6958
- * executed to maintain VM state for future Tier-2 executions.
6959
- *
6960
- * This is intended to be called after evaluate() so visible lines are
6961
- * rendered first, then background work fills in the dependency graph.
6962
- *
6963
- * **Phase 5.2h:** This synchronous method is retained for environments
6964
- * without Worker support. Prefer `dispatchBackgroundCompiles()` which
6965
- * offloads compilation to a Web Worker with Transferable bytecode.
6966
- */
6967
- backgroundCompile(viewport) {
6968
- const results = [];
6969
- const docEnd = this.doc.lineCount;
6970
- const startPos = viewport.endLine + 1;
6971
- for (let pos = startPos; pos <= docEnd; pos++) {
6972
- const state = this.doc.getLineAt(pos);
6973
- if (!state) continue;
6974
- if (!state.dirty) continue;
6975
- if (state.bytecodes.length > 0 && !state.isVariableDef) continue;
6976
- const lineResult = this.evaluateSingleLine(state, pos, false);
6977
- results.push(lineResult);
6978
- }
6979
- return results;
6980
- }
6981
- /**
6982
- * Dispatch background compilation to a Web Worker (Phase 5.2h).
6983
- *
6984
- * Collects invisible dirty lines beyond the viewport that need compilation,
6985
- * sends them to the compilation worker, and asynchronously stores the
6986
- * transferred bytecode in the DocumentModel when the worker responds.
6987
- *
6988
- * This is the non-blocking alternative to `backgroundCompile()`. The worker
6989
- * compiles expressions with Transferable ArrayBuffers (zero-copy postMessage),
6990
- * so bytecode appears on the main thread without serialization overhead.
6991
- *
6992
- * Lines that already have cached bytecode (from a previous worker pass or
6993
- * synchronous compile) are skipped, only truly uncompiled dirty lines are
6994
- * sent to the worker.
6995
- *
6996
- * **Usage:** Call after `evaluate()` so visible lines render first, then
6997
- * this fills the bytecode cache for future Tier-2 scrolls.
6514
+ * Flush all pending resolutions in a single batched pass.
6998
6515
  *
6999
- * @param viewport The current visible range. Lines beyond viewport.endLine
7000
- * that are dirty and don't have bytecode are dispatched.
7001
- */
7002
- dispatchBackgroundCompiles(viewport) {
7003
- const items = this.collectInvisibleCompileTargets(viewport);
7004
- if (items.length === 0) return;
7005
- if (!this.compilationWorker) {
7006
- this.compilationWorker = new CompilationWorkerManager();
7007
- }
7008
- this.compilationWorker.compileBatch(items).then((results) => {
7009
- this.compilationWorker.storeResults(results, this.doc);
7010
- }).catch((_err) => {
7011
- });
7012
- }
7013
- /**
7014
- * Terminate the compilation worker if active, and unsubscribe from
7015
- * sharedGlobalVariableStore. Call this when the evaluator is no longer
7016
- * needed to clean up resources, every call site that retires a
7017
- * ThreeTierEvaluator (document switch, pane destroy()) already calls
7018
- * this unconditionally, so folding the global-store unsubscribe in here
7019
- * needs no new call sites anywhere.
6516
+ * Called automatically via queueMicrotask. Never called directly.
7020
6517
  */
7021
- terminateWorker() {
7022
- if (this.compilationWorker) {
7023
- this.compilationWorker.terminate();
7024
- this.compilationWorker = null;
6518
+ flush() {
6519
+ this.scheduled = false;
6520
+ if (this.cleared) return;
6521
+ if (this.pending.length === 0) return;
6522
+ const batch = this.pending;
6523
+ this.pending = [];
6524
+ const deduped = /* @__PURE__ */ new Map();
6525
+ for (const entry of batch) {
6526
+ const compositeKey = `${entry.packageId}:${entry.queryKey}`;
6527
+ deduped.set(compositeKey, entry);
6528
+ }
6529
+ const errorEntries = [];
6530
+ const okEntries = [];
6531
+ for (const entry of deduped.values()) {
6532
+ if (entry.isError) {
6533
+ errorEntries.push(entry);
6534
+ } else {
6535
+ okEntries.push(entry);
6536
+ }
7025
6537
  }
7026
- if (this.globalUnsubscribe) {
7027
- this.globalUnsubscribe();
7028
- this.globalUnsubscribe = null;
6538
+ for (const entry of errorEntries) {
6539
+ if (entry.signal.aborted) continue;
6540
+ this.notifyListeners({
6541
+ type: "error",
6542
+ queryKey: entry.queryKey,
6543
+ packageId: entry.packageId,
6544
+ error: entry.error ?? new Error("Unknown async resolution error")
6545
+ });
7029
6546
  }
7030
- }
7031
- /**
7032
- * Get the DocumentModel (read-only access for decoration building).
7033
- */
7034
- getDoc() {
7035
- return this.doc;
7036
- }
7037
- /**
7038
- * Evaluate all dirty lines in the document, regardless of viewport.
7039
- * Used for full re-evaluation after plugin register/unregister.
7040
- */
7041
- evaluateAll(signal) {
7042
- const viewport = { startLine: 1, endLine: this.doc.lineCount };
7043
- const result = this.evaluate(viewport, signal);
7044
- return result;
7045
- }
7046
- /**
7047
- * Zero-allocation viewport evaluation, the Phase 5.2e "holy grail."
7048
- *
7049
- * **Key insight:** When the user scrolls (viewport-only change, no edits),
7050
- * we don't need to re-evaluate from line 1. Instead:
7051
- *
7052
- * 1. Restore the VM to just before the viewport via the nearest checkpoint.
7053
- * 2. Evaluate ONLY the visible lines (Tier 2 for clean cached, Tier 1 for dirty).
7054
- * 3. Lines before the viewport are completely skipped, their state lives in
7055
- * the VM checkpointer's prototypal chain.
7056
- *
7057
- * **Correctness guard:** If any variable-definition line before the viewport
7058
- * is dirty (e.g., the user edited a variable def that hasn't been
7059
- * re-evaluated yet), we clear stale checkpoints and fall back to `evaluate()`
7060
- * which processes from line 1 and rebuilds fresh checkpoints. This
7061
- * guarantees that stale checkpoints are never used as restoration targets.
7062
- * Only variable-def lines matter here, `VMCheckpointer.snapshot()` only
7063
- * records state for lines that write a variable, so a dirty plain-expression
7064
- * line before the viewport has no checkpoint to invalidate (see
7065
- * `DocumentModel.hasAnyDirtyVariableDefLineBefore()`).
7066
- *
7067
- * **Performance:** O(visible lines) instead of O(document length). Target:
7068
- * < 1ms for a typical ~30-line viewport, independent of document size.
7069
- *
7070
- * @param viewport The visible line range.
7071
- * @returns Results for visible lines only. Lines before the viewport are
7072
- * not included in `lines[]` or `resultMap`.
7073
- */
7074
- setViewport(viewport, signal) {
7075
- this.engine.setKeystrokeSignal(signal ?? null);
7076
- if (viewport.startLine > 1 && this.hasDirtyLinesBefore(viewport.startLine)) {
7077
- this.checkpointer?.clear();
7078
- return this.evaluate(viewport, signal);
7079
- }
7080
- this.pageManager.maintainAfterEval(viewport, this.doc);
7081
- this.preloadNextPages(viewport);
7082
- this.restoreTo(viewport.startLine - 1);
7083
- enableValueArena();
7084
- try {
7085
- const result = this.collectEvalResults(viewport.startLine, viewport.endLine);
7086
- return result;
7087
- } finally {
7088
- this.engine.setKeystrokeSignal(null);
7089
- disableValueArena();
6547
+ const allAffected = /* @__PURE__ */ new Set();
6548
+ const allQueryKeys = [];
6549
+ const allEntries = [...okEntries, ...errorEntries];
6550
+ for (const entry of allEntries) {
6551
+ if (entry.signal.aborted) continue;
6552
+ allQueryKeys.push(entry.queryKey);
6553
+ const affected = this.dag.getAffectedLinesByDataSource(
6554
+ entry.packageId,
6555
+ [entry.queryKey]
6556
+ );
6557
+ for (const line of affected) {
6558
+ allAffected.add(line);
6559
+ }
6560
+ }
6561
+ if (allAffected.size === 0) {
6562
+ this.notifyListeners({
6563
+ type: "lines-updated",
6564
+ lineNumbers: [],
6565
+ affectedQueryKeys: allQueryKeys
6566
+ });
6567
+ return;
6568
+ }
6569
+ const ordered = this.topologicalSort(Array.from(allAffected));
6570
+ if (ordered.length > WORKER_OFFLOAD_THRESHOLD) {
6571
+ const entryMap = /* @__PURE__ */ new Map();
6572
+ for (const lineNumber of ordered) {
6573
+ entryMap.set(lineNumber, this.lineCache.getEntryForLine(lineNumber));
6574
+ }
6575
+ void this.reExecuteViaWorkerPool(ordered, entryMap, allQueryKeys).catch((e) => {
6576
+ const engineError = normalizeUnknownError(e);
6577
+ console.error(`[AsyncResolutionBatcher] reExecuteViaWorkerPool() failed unexpectedly: ${engineError.format()}`);
6578
+ });
6579
+ return;
7090
6580
  }
6581
+ this.reExecuteMainThread(ordered, allQueryKeys);
7091
6582
  }
6583
+ // ── Private: topological sort ─────────────────────────────────────
7092
6584
  /**
7093
- * Apply incremental line-level changes to the document model.
7094
- *
7095
- * **Phase 5.2f:** Replaces the O(N) `setDocument()` + full re-evaluation
7096
- * with O(changed) incremental updates. Key benefits:
7097
- *
7098
- * 1. Unchanged lines retain their persistent lineIds → bytecode survives
7099
- * 2. Only changed + DAG-downstream lines are marked dirty → Tier 1 re-evaluation
7100
- * 3. Clean lines in viewport use Tier 2 (cached bytecode execution)
7101
- * 4. Clean lines outside viewport are skipped entirely
7102
- *
7103
- * The DAG is fully cleared after propagation: shifted lines would have
7104
- * stale entries keyed by old line numbers, so the DAG is rebuilt from
7105
- * scratch during the subsequent `evaluate()` call.
7106
- *
7107
- * **Caller should follow up with `evaluate(viewport)`** to re-evaluate
7108
- * dirty lines from line 1 and rebuild the DAG + checkpoints.
6585
+ * Topologically sort affected lines using Kahn's algorithm.
7109
6586
  *
7110
- * @param changes Line-level changes to apply. Must be non-overlapping.
7111
- * @returns Metadata about the applied changes.
6587
+ * Lines that produce variables come before lines that consume them.
6588
+ * This ensures correct evaluation order when multiple interdependent
6589
+ * lines are affected by async resolution.
7112
6590
  */
7113
- applyTransaction(changes) {
7114
- const allWrites = /* @__PURE__ */ new Set();
7115
- for (const change of changes) {
7116
- for (let i = 0; i < change.deleteCount; i++) {
7117
- const lineNum = change.startLine + i;
7118
- const writes = this.dag.getWrites(lineNum);
7119
- for (const w of writes) {
7120
- allWrites.add(w);
7121
- }
7122
- this.dag.removeLine(lineNum);
7123
- this.engine.getLineCache().removeAllForLine(lineNum);
6591
+ topologicalSort(lines) {
6592
+ if (lines.length <= 1) return lines;
6593
+ const inDegree = /* @__PURE__ */ new Map();
6594
+ const adjacency = /* @__PURE__ */ new Map();
6595
+ const producerOf = /* @__PURE__ */ new Map();
6596
+ for (const line of lines) {
6597
+ inDegree.set(line, 0);
6598
+ adjacency.set(line, []);
6599
+ }
6600
+ for (const line of lines) {
6601
+ const writes = this.dag.getWrites(line);
6602
+ for (const w of writes) {
6603
+ producerOf.set(w, line);
7124
6604
  }
7125
6605
  }
7126
- const downstreamLineIds = /* @__PURE__ */ new Set();
7127
- for (const writeVar of allWrites) {
7128
- const affected = this.dag.getAffectedLines(writeVar);
7129
- for (const lineNum of affected) {
7130
- const state = this.doc.getLineAt(lineNum);
7131
- if (state) {
7132
- downstreamLineIds.add(state.lineId);
6606
+ for (const line of lines) {
6607
+ const reads = this.dag.getDependencies(line);
6608
+ for (const readVar of reads) {
6609
+ const producer = producerOf.get(readVar);
6610
+ if (producer !== void 0 && producer !== line) {
6611
+ adjacency.get(producer).push(line);
6612
+ inDegree.set(line, (inDegree.get(line) ?? 0) + 1);
7133
6613
  }
7134
6614
  }
7135
6615
  }
7136
- const result = this.doc.applyChanges(changes);
7137
- this.checkpointer?.clear();
7138
- for (const lineId of downstreamLineIds) {
7139
- this.doc.markDirty(lineId);
6616
+ const queue = [];
6617
+ for (const [line, degree] of inDegree) {
6618
+ if (degree === 0) queue.push(line);
6619
+ }
6620
+ if (queue.length === 0 && lines.length > 0) {
6621
+ return [...lines].sort((a, b) => a - b);
7140
6622
  }
7141
- this.dag.clear();
7142
- return {
7143
- inserted: result.inserted,
7144
- removed: result.removed
7145
- };
7146
- }
7147
- // ── Private helpers ─────────────────────────────────────────────────
7148
- /**
7149
- * Collect evaluation results for a contiguous range of lines.
7150
- *
7151
- * Used by both `evaluate()` (startLine=1) and `setViewport()` (any start).
7152
- * All lines in the range are treated as in-viewport (visible), callers that
7153
- * need the invisible/dirty → Tier 3 handling should use `evaluate()` instead.
7154
- *
7155
- * @param startLine First line to evaluate (1-based, inclusive).
7156
- * @param endLine Last line to evaluate (1-based, inclusive). Clamped to docEnd.
7157
- */
7158
- collectEvalResults(startLine, endLine) {
7159
- const lines = [];
7160
- const resultMap = /* @__PURE__ */ new Map();
7161
- const tierCounts = { tier1: 0, tier2: 0, tier3: 0, skipped: 0 };
7162
- const docEnd = this.doc.lineCount;
7163
- const evalEnd = Math.min(endLine, docEnd);
7164
- for (let pos = startLine; pos <= evalEnd; pos++) {
7165
- const state = this.doc.getLineAt(pos);
7166
- if (!state) continue;
7167
- const lineResult = this.evaluateSingleLine(state, pos, true);
7168
- lines.push(lineResult);
7169
- if (lineResult.tier === 1 /* Tier1 */) tierCounts.tier1++;
7170
- else if (lineResult.tier === 2 /* Tier2 */) tierCounts.tier2++;
7171
- else if (lineResult.tier === 3 /* Tier3 */) tierCounts.tier3++;
7172
- else tierCounts.skipped++;
7173
- if (lineResult.results) {
7174
- resultMap.set(pos, lineResult.results.flat());
7175
- }
7176
- }
7177
- return { lines, resultMap, tierCounts };
7178
- }
7179
- /**
7180
- * Check whether any **variable-definition** line before `position`
7181
- * (1-based, exclusive) is dirty.
7182
- *
7183
- * Used by `setViewport()` to decide whether to fall back to `evaluate()`:
7184
- * if a variable-def before the viewport is dirty, the checkpoint state
7185
- * `restoreTo()` would use may be stale and we need to reprocess from
7186
- * line 1 to rebuild checkpoints correctly.
7187
- *
7188
- * Deliberately narrower than `DocumentModel.hasAnyDirtyLineBefore()`:
7189
- * checkpoints only snapshot variable-def lines (see VMCheckpointer), so a
7190
- * dirty plain-expression line before the viewport can't have invalidated
7191
- * one, there's nothing checkpointed for it to invalidate. Using the
7192
- * broader check here previously caused a real perf bug: `PageManager`'s
7193
- * cold-page eviction marks evicted non-variable-def lines dirty, so
7194
- * scrolling far into a large, variable-def-free document would trip this
7195
- * guard, fall back to `evaluate()`, which recompiles those lines via
7196
- * Tier 3 (never clearing their dirty flag by design), causing the very
7197
- * next `maintainAfterEval()` to re-evict and re-dirty them, a
7198
- * self-sustaining loop that pinned every subsequent `setViewport()` call
7199
- * to the cost of a full re-evaluation instead of O(visible lines).
7200
- *
7201
- * Delegates to DocumentModel.hasAnyDirtyVariableDefLineBefore(), which
7202
- * tracks dirty lineIds incrementally instead of scanning every line up to
7203
- * `position` on every call. This used to be a real per-scroll cost
7204
- * (benchmarked at ~10ms scrolled near the bottom of a 20k-line document)
7205
- * since it fired on every viewport change, not just edits.
7206
- */
7207
- hasDirtyLinesBefore(position) {
7208
- return this.doc.hasAnyDirtyVariableDefLineBefore(position);
7209
- }
7210
- /**
7211
- * Evaluate a single line using the appropriate tier.
7212
- *
7213
- * Tier assignment logic:
7214
- * - Empty/markdown-only lines → skipped
7215
- * - Dirty + in-viewport → Tier 1 (full pipeline)
7216
- * - Dirty + not in viewport → Tier 3 (compile-only, execute variable defs)
7217
- * - Clean + has bytecode + in viewport → Tier 2 (execute from cache)
7218
- * - Clean + no bytecode → skipped (non-evaluable)
7219
- */
7220
- evaluateSingleLine(state, lineNumber, inViewport) {
7221
- const baseResult = {
7222
- lineId: state.lineId,
7223
- lineNumber
7224
- };
7225
- if (state.isEmpty || isEmptyLine(state.text)) {
7226
- state.isEmpty = true;
7227
- this.doc.markClean(state.lineId);
7228
- return { ...baseResult, tier: 0 /* Skipped */, result: null, error: null };
7229
- }
7230
- const { expressions, inlineSolveCount } = this.extractExpressions(state);
7231
- if (expressions.length === 0) {
7232
- state.isEmpty = true;
7233
- this.doc.markClean(state.lineId);
7234
- return { ...baseResult, tier: 0 /* Skipped */, result: null, error: null };
7235
- }
7236
- if (state.dirty) {
7237
- if (inViewport) {
7238
- return this.evaluateTier1(state, lineNumber, expressions, inlineSolveCount, baseResult);
7239
- } else {
7240
- if (state.bytecodes.length > 0 && state.bytecodes.length === expressions.length && !state.isVariableDef) {
7241
- return { ...baseResult, tier: 0 /* Skipped */, result: null, error: null };
7242
- }
7243
- return this.evaluateTier3(state, lineNumber, expressions, inlineSolveCount, baseResult);
6623
+ const ordered = [];
6624
+ while (queue.length > 0) {
6625
+ const current = queue.shift();
6626
+ ordered.push(current);
6627
+ for (const downstream of adjacency.get(current) ?? []) {
6628
+ const newDegree = (inDegree.get(downstream) ?? 1) - 1;
6629
+ inDegree.set(downstream, newDegree);
6630
+ if (newDegree === 0) queue.push(downstream);
7244
6631
  }
7245
6632
  }
7246
- if (inViewport && state.bytecodes.length > 0) {
7247
- return this.evaluateTier2(state, lineNumber, baseResult);
6633
+ if (ordered.length < lines.length) {
6634
+ const remaining = lines.filter((l) => !ordered.includes(l)).sort((a, b) => a - b);
6635
+ ordered.push(...remaining);
7248
6636
  }
7249
- return { ...baseResult, tier: 0 /* Skipped */, result: null, error: null };
6637
+ return ordered;
7250
6638
  }
6639
+ // ── Private: worker-pool re-execution ─────────────────────────────
7251
6640
  /**
7252
- * Tier 1: Full pipeline, lex, parse, compile, execute.
7253
- * Uses the engine's existing evaluateLine() which handles all pipeline
7254
- * stages including DAG updates and LineCache population.
6641
+ * Offload VM re-execution to the worker pool for large batches.
6642
+ *
6643
+ * Called when ordered.length > WORKER_OFFLOAD_THRESHOLD (50).
6644
+ * Clones bytecode ArrayBuffers, dispatches to workers, and asynchronously
6645
+ * patches results back into LineCache before notifying listeners.
6646
+ *
6647
+ * Handles pending results: lines that return { type: 'pending' } from the
6648
+ * worker are NOT marked as updated, the engine's resolveAsync will handle
6649
+ * them when the async resolver completes.
7255
6650
  *
7256
- * Supports multiple expressions per line (inline solves). Evaluates each
7257
- * expression left-to-right through the engine so variable definitions in
7258
- * earlier solves update the VM state before later solves are evaluated.
7259
- * Reads/writes are aggregated across all expressions for the DAG.
6651
+ * Safety: checks this.cleared before applying results, if the engine was
6652
+ * cleared while the worker batch was in-flight, results are discarded.
7260
6653
  */
7261
- evaluateTier1(state, lineNumber, expressions, inlineSolveCount, baseResult) {
7262
- const allResults = [];
7263
- const allBytecodes = [];
7264
- const allReads = /* @__PURE__ */ new Set();
7265
- const allWrites = /* @__PURE__ */ new Set();
7266
- let hasVariableDef = false;
7267
- let lastValue = null;
7268
- let firstError = null;
7269
- let anyFailed = false;
7270
- let anyPending = false;
7271
- for (const expression of expressions) {
7272
- if (!expression.trim()) continue;
7273
- let value = null;
7274
- let entry;
7275
- try {
7276
- const evaluation = this.engine.evaluateLineDetailed(lineNumber, expression);
7277
- value = evaluation.values;
7278
- lastValue = value[0];
7279
- entry = this.engine.getLineCache().get(lineNumber, expression);
7280
- } catch (e) {
7281
- const errorMessage = e instanceof Error ? e.message : String(e);
7282
- if (!firstError) firstError = errorMessage;
7283
- anyFailed = true;
7284
- value = null;
7285
- }
7286
- if (value && value.some((v) => v.type === 12 /* Pending */)) {
7287
- anyPending = true;
7288
- }
7289
- if (value) {
7290
- allResults.push(value);
7291
- } else {
7292
- allResults.push([errorValue("eval_failed", firstError ?? "unknown error")]);
7293
- }
7294
- if (entry) {
7295
- allBytecodes.push(entry.bytecode);
7296
- for (const r of entry.readVariables) allReads.add(r);
7297
- if (entry.writeVariable) {
7298
- allWrites.add(entry.writeVariable);
7299
- hasVariableDef = true;
7300
- }
7301
- } else {
7302
- try {
7303
- const { program, reads: reads2, writes: writes2 } = this.engine.compileExpression(expression);
7304
- allBytecodes.push(program);
7305
- for (const r of reads2) allReads.add(r);
7306
- for (const w of writes2) allWrites.add(w);
7307
- if (writes2.length > 0) hasVariableDef = true;
7308
- } catch (compileErr) {
7309
- allBytecodes.push({ opcodes: new Uint8Array(0), numbers: new Float64Array(0), strings: [], hasAsync: false });
7310
- if (compileErr instanceof EngineError && compileErr.context) {
7311
- const errReads = compileErr.context.reads;
7312
- const errWrites = compileErr.context.writes;
7313
- if (Array.isArray(errReads)) for (const r of errReads) allReads.add(r);
7314
- if (Array.isArray(errWrites)) for (const w of errWrites) allWrites.add(w);
7315
- }
7316
- }
7317
- }
7318
- }
7319
- const reads = [...allReads];
7320
- const writes = [...allWrites];
7321
- if (anyFailed || anyPending) {
7322
- this.doc.updateLineCompiled(
7323
- state.lineId,
7324
- expressions,
7325
- allBytecodes,
7326
- reads,
7327
- writes,
7328
- hasVariableDef,
7329
- inlineSolveCount
7330
- );
7331
- state.results = allResults;
7332
- state.result = allResults[0]?.[0] ?? null;
7333
- state.inlineSolveCount = inlineSolveCount;
7334
- state.expressions = expressions;
7335
- } else {
7336
- this.doc.updateLineResult(
7337
- state.lineId,
7338
- allResults,
7339
- allBytecodes,
7340
- expressions,
7341
- reads,
7342
- writes,
7343
- hasVariableDef,
7344
- inlineSolveCount
7345
- );
6654
+ async reExecuteViaWorkerPool(ordered, entryMap, allQueryKeys) {
6655
+ if (!this.executionPool) {
6656
+ this.executionPool = new ExecutionPool();
7346
6657
  }
7347
- this.dag.registerLine(lineNumber, reads, writes);
7348
- if (this.checkpointer && writes.length > 0 && !anyFailed && !anyPending) {
7349
- this.checkpointer.snapshot(lineNumber, state.lineId, writes);
6658
+ const results = this.executionPool.executeBatch(ordered, entryMap);
6659
+ if (!results) {
6660
+ this.reExecuteMainThread(ordered, allQueryKeys, entryMap);
6661
+ return;
7350
6662
  }
7351
- return {
7352
- ...baseResult,
7353
- tier: 1 /* Tier1 */,
7354
- result: lastValue,
7355
- results: allResults,
7356
- error: firstError
7357
- };
7358
- }
7359
- /**
7360
- * Tier 2: Execute from cached bytecode only.
7361
- * Skips lexing, parsing, and compiling, runs the pre-compiled bytecode
7362
- * against the engine's shared VM. Supports multiple bytecodes per line
7363
- * (inline solves), each is executed left-to-right so variable definitions
7364
- * in earlier bytecodes update the VM before later ones run.
7365
- * Assumes the VM already has correct variable state from preceding
7366
- * Tier-1 evaluations.
7367
- */
7368
- evaluateTier2(state, lineNumber, baseResult) {
7369
- if (state.bytecodes.length === 0) {
7370
- return { ...baseResult, tier: 0 /* Skipped */, result: null, error: null };
7371
- }
7372
- const results = [];
7373
- let lastValue = null;
7374
- let firstError = null;
7375
- let anyFailed = false;
7376
- for (const bytecode of state.bytecodes) {
7377
- if (bytecode.opcodes.length === 0) continue;
7378
- try {
7379
- const value = this.engine.executeCached(bytecode, lineNumber);
7380
- lastValue = value;
7381
- results.push([value]);
7382
- } catch (e) {
7383
- const errorMessage = e instanceof Error ? e.message : String(e);
7384
- if (!firstError) firstError = errorMessage;
7385
- anyFailed = true;
7386
- results.push([errorValue("exec_failed", errorMessage)]);
6663
+ this.workerOffloadDispatchCount++;
6664
+ const workerResults = await results;
6665
+ if (this.cleared) return;
6666
+ const updatedLineNumbers = [];
6667
+ for (const wr of workerResults) {
6668
+ const entry = entryMap.get(wr.lineNumber);
6669
+ if (!entry) continue;
6670
+ if (wr.isPending) {
6671
+ continue;
7387
6672
  }
6673
+ const value = reconstructValue(wr);
6674
+ entry.result = value;
6675
+ this.warnIfUnwired();
6676
+ this.onLineResult?.(wr.lineNumber, value);
6677
+ updatedLineNumbers.push(wr.lineNumber);
7388
6678
  }
7389
- this.dag.registerLine(lineNumber, state.reads, state.writes);
7390
- state.results = results;
7391
- state.result = results[0]?.[0] ?? null;
7392
- if (anyFailed) {
7393
- this.doc.markDirty(state.lineId);
6679
+ if (this.cleared) return;
6680
+ if (updatedLineNumbers.length > 0 || allQueryKeys.length > 0) {
6681
+ this.notifyListeners({
6682
+ type: "lines-updated",
6683
+ lineNumbers: updatedLineNumbers,
6684
+ affectedQueryKeys: allQueryKeys
6685
+ });
7394
6686
  }
7395
- return { ...baseResult, tier: 2 /* Tier2 */, result: lastValue, results, error: firstError };
7396
6687
  }
6688
+ // ── Private: main-thread re-execution ────────────────────────────
7397
6689
  /**
7398
- * Tier 3: Compile-only for invisible lines.
7399
- * Lex Parse Compile to discover reads/writes for the dependency graph.
7400
- * Executes the bytecode ONLY if the line defines a variable (isVariableDef
7401
- * or writes.length > 0), because variable assignments affect VM state
7402
- * that other lines depend on. Pure expression lines are compiled but NOT
7403
- * executed, saving CPU for large documents.
6690
+ * Execute ordered lines on the main thread, update LineCache, and notify
6691
+ * listeners. Returns the list of line numbers that actually changed.
7404
6692
  *
7405
- * Supports multiple expressions per line (inline solves). Each is compiled
7406
- * separately; variable-def expressions are also executed.
6693
+ * Used by both flush() (≤50 lines) and reExecuteViaWorkerPool() (fallback
6694
+ * when workers are unavailable). Extracted to avoid code duplication.
6695
+ *
6696
+ * **Per-line containment (fatal-bug fix)**: `executeBytecode()` used to
6697
+ * run here with NO try/catch anywhere in this method's call chain, and
6698
+ * this whole batch runs inside a bare `queueMicrotask` (see `add()`) with
6699
+ * no surrounding try/catch at any caller either, so if any ONE line's
6700
+ * cached bytecode threw (a stack/instruction-limit error, an undefined
6701
+ * variable, a corrupted-bytecode `TypeError`), the `for` loop aborted
6702
+ * immediately: every line scheduled AFTER the failure in this batch was
6703
+ * silently never re-executed or notified even though nothing was wrong
6704
+ * with them, every line BEFORE it had already had its `entry.result`
6705
+ * mutated in-place but `notifyListeners()` was never reached (a silent
6706
+ * `LineCache`/host desync), and, because a bare `queueMicrotask`
6707
+ * callback has no caller to catch it, the exception was uncatchable:
6708
+ * an `uncaughtException` that could crash the host process outright.
6709
+ * (`__tests__/async/AsyncResolutionBatcher.spec.ts`'s topological-sort
6710
+ * describe block used to have a test skipped specifically because of
6711
+ * this. See that file, now un-skipped and rewritten.) Each line's
6712
+ * execution is now its own try/catch: a failure is recorded as an
6713
+ * `Error` `Value` for THAT line (still counted as "updated" so the host
6714
+ * learns about it and stops showing a stale Pending state) and the loop
6715
+ * continues, one line's failure can no longer take out its neighbors.
7407
6716
  */
7408
- evaluateTier3(state, lineNumber, expressions, inlineSolveCount, baseResult) {
7409
- const allBytecodes = [];
7410
- const allReads = /* @__PURE__ */ new Set();
7411
- const allWrites = /* @__PURE__ */ new Set();
7412
- let hasVariableDef = false;
7413
- let lastResult = null;
7414
- let firstError = null;
7415
- let anyFailed = false;
7416
- let anyPending = false;
7417
- for (const expression of expressions) {
7418
- if (!expression.trim()) continue;
6717
+ reExecuteMainThread(ordered, allQueryKeys, entryMap) {
6718
+ const updatedLineNumbers = [];
6719
+ for (const lineNumber of ordered) {
6720
+ const entry = entryMap ? entryMap.get(lineNumber) : this.lineCache.getEntryForLine(lineNumber);
6721
+ if (!entry || entry.bytecode.opcodes.length === 0) continue;
6722
+ const stackBefore = this.vm.getStack().length;
7419
6723
  try {
7420
- const { program, reads: reads2, writes: writes2 } = this.engine.compileExpression(expression);
7421
- allBytecodes.push(program);
7422
- for (const r of reads2) allReads.add(r);
7423
- for (const w of writes2) allWrites.add(w);
7424
- if (writes2.length > 0) hasVariableDef = true;
7425
- if (writes2.length > 0 && program.opcodes.length > 0) {
7426
- lastResult = this.engine.executeCached(program, lineNumber);
7427
- if (lastResult && lastResult.type === 12 /* Pending */) {
7428
- anyPending = true;
7429
- }
6724
+ const result = executeBytecode(entry.bytecode, this.vm);
6725
+ while (this.vm.getStack().length > stackBefore) {
6726
+ this.vm.pop();
6727
+ }
6728
+ if (result.type === "value") {
6729
+ entry.result = result.value;
6730
+ this.warnIfUnwired();
6731
+ this.onLineResult?.(lineNumber, result.value);
6732
+ updatedLineNumbers.push(lineNumber);
6733
+ } else if (result.type === "error") {
6734
+ const value = errorValue(result.error.code, result.error.message);
6735
+ entry.result = value;
6736
+ this.warnIfUnwired();
6737
+ this.warnIfUnwired();
6738
+ this.onLineResult?.(lineNumber, value);
6739
+ updatedLineNumbers.push(lineNumber);
7430
6740
  }
7431
6741
  } catch (e) {
7432
- const errorMessage = e instanceof Error ? e.message : String(e);
7433
- if (!firstError) firstError = errorMessage;
7434
- anyFailed = true;
7435
- allBytecodes.push({ opcodes: new Uint8Array(0), numbers: new Float64Array(0), strings: [], hasAsync: false });
7436
- }
7437
- }
7438
- const reads = [...allReads];
7439
- const writes = [...allWrites];
7440
- this.doc.updateLineCompiled(
7441
- state.lineId,
7442
- expressions,
7443
- allBytecodes,
7444
- reads,
7445
- writes,
7446
- hasVariableDef,
7447
- inlineSolveCount
7448
- );
7449
- this.dag.registerLine(lineNumber, reads, writes);
7450
- if (hasVariableDef && lastResult && !anyFailed && !anyPending) {
7451
- state.results = [[lastResult]];
7452
- state.result = lastResult;
7453
- this.doc.markClean(state.lineId);
7454
- if (this.checkpointer) {
7455
- this.checkpointer.snapshot(lineNumber, state.lineId, writes);
6742
+ while (this.vm.getStack().length > stackBefore) {
6743
+ this.vm.pop();
6744
+ }
6745
+ const engineError = normalizeUnknownError(e);
6746
+ const value = errorValue(engineError.code, engineError.message);
6747
+ entry.result = value;
6748
+ this.warnIfUnwired();
6749
+ this.onLineResult?.(lineNumber, value);
6750
+ updatedLineNumbers.push(lineNumber);
7456
6751
  }
7457
6752
  }
7458
- return { ...baseResult, tier: 3 /* Tier3 */, result: lastResult, results: hasVariableDef && lastResult && !anyFailed && !anyPending ? [[lastResult]] : void 0, error: firstError };
7459
- }
7460
- // ── Public checkpoint API (used by Phase 5.2e setViewport) ──────
7461
- /**
7462
- * Restore the VM to the state at or just after the given line number.
7463
- *
7464
- * Finds the nearest checkpoint at or before `lineNumber` and replays
7465
- * all variable definitions from the checkpoint chain into the VM.
7466
- * After calling this, the VM is ready to evaluate lines starting at
7467
- * `lineNumber + 1` without re-evaluating all preceding lines.
7468
- *
7469
- * **Usage:** Phase 5.2e's `setViewport()` calls `restoreTo(viewport.startLine - 1)`
7470
- * before evaluating only the newly visible lines. This is the key to
7471
- * O(visible lines) scrolling.
7472
- *
7473
- * @param lineNumber The line number to restore to. Variables defined
7474
- * at lines ≤ this number will be available in the VM.
7475
- */
7476
- restoreTo(lineNumber) {
7477
- if (this.checkpointer) {
7478
- this.checkpointer.restoreTo(lineNumber);
6753
+ if (updatedLineNumbers.length > 0 || allQueryKeys.length > 0) {
6754
+ this.notifyListeners({
6755
+ type: "lines-updated",
6756
+ lineNumbers: updatedLineNumbers,
6757
+ affectedQueryKeys: allQueryKeys
6758
+ });
7479
6759
  }
6760
+ return updatedLineNumbers;
7480
6761
  }
7481
6762
  /**
7482
- * Get the VM checkpointer, or null if checkpointing is disabled.
7483
- */
7484
- getCheckpointer() {
7485
- return this.checkpointer;
7486
- }
7487
- /**
7488
- * Get the PageManager (Phase 5.2g).
7489
- * Exposed for testing.
7490
- */
7491
- getPageManager() {
7492
- return this.pageManager;
7493
- }
7494
- // ── Phase 5.2g: Directional preloading ──────────────────────────
7495
- /**
7496
- * Preload the next 1–2 pages in the current scroll direction.
7497
- *
7498
- * Called during `setViewport()` (scroll-only path). Collects dirty
7499
- * uncompiled lines in pages just beyond the viewport and dispatches
7500
- * them to the background compilation worker so bytecode is ready
7501
- * before the user scrolls those lines into view.
7502
- */
7503
- preloadNextPages(viewport) {
7504
- const targets = this.pageManager.getPreloadTargets(viewport, this.doc);
7505
- if (targets.length === 0) return;
7506
- if (!this.compilationWorker) {
7507
- this.compilationWorker = new CompilationWorkerManager();
7508
- }
7509
- this.compilationWorker.compileBatch(targets).then((results) => {
7510
- this.compilationWorker.storeResults(results, this.doc);
7511
- }).catch((_err) => {
7512
- });
7513
- }
7514
- /**
7515
- * Collect invisible dirty lines that need background compilation.
7516
- *
7517
- * Iterates lines beyond `viewport.endLine`, filtering for:
7518
- * - Dirty lines (need re-compilation)
7519
- * - Non-empty, non-markdown lines
7520
- * - No existing bytecode (skip already-compiled Tier 3 lines)
6763
+ * Notify all consumers of an async resolution event.
7521
6764
  *
7522
- * Returns CompileRequestItem[] suitable for CompilationWorkerManager.
6765
+ * Enqueues the event into the internal {@link ReadableStream}.
6766
+ * If the stream has been closed or errored (consumer cancelled),
6767
+ * the enqueue silently fails (caught by try/catch).
7523
6768
  */
7524
- collectInvisibleCompileTargets(viewport) {
7525
- const items = [];
7526
- const docEnd = this.doc.lineCount;
7527
- const startPos = viewport.endLine + 1;
7528
- for (let pos = startPos; pos <= docEnd; pos++) {
7529
- const state = this.doc.getLineAt(pos);
7530
- if (!state) continue;
7531
- if (!state.dirty) continue;
7532
- if (state.bytecodes.length > 0 && !state.isVariableDef) continue;
7533
- if (state.isEmpty || isEmptyLine(state.text)) continue;
7534
- const { expressions } = this.extractExpressions(state);
7535
- if (expressions.length === 0) continue;
7536
- for (const expression of expressions) {
7537
- if (!expression.trim()) continue;
7538
- items.push({
7539
- lineId: state.lineId,
7540
- expression,
7541
- textHash: state.textHash
7542
- });
7543
- }
6769
+ notifyListeners(event) {
6770
+ if (this._testCaptures) {
6771
+ this._testCaptures.push(event);
7544
6772
  }
7545
- return items;
7546
- }
7547
- /**
7548
- * Extract all evaluable expressions from a LineState.
7549
- *
7550
- * For full-line expressions: returns `{ expressions: [trimmedText], inlineSolveCount: 0 }`.
7551
- * For inline solve lines: returns `{ expressions: [...allSolves], inlineSolveCount: N }`.
7552
- * For pre-extracted (cached) expressions: returns the cached array.
7553
- *
7554
- * Inline solves are extracted left-to-right via the sharedLexer, so variable
7555
- * definitions in earlier solves (e.g., `s\`x = 5\` more text s\`x + 1\``)
7556
- * correctly update the VM state before later solves are evaluated.
7557
- */
7558
- extractExpressions(state) {
7559
- if (state.expressions.length > 0) {
7560
- return { expressions: state.expressions, inlineSolveCount: state.inlineSolveCount };
7561
- }
7562
- const trimmed = state.text.trim();
7563
- if (trimmed.length === 0) return { expressions: [], inlineSolveCount: 0 };
7564
- const inlineSpans = sharedLexer.findInlineSolves(state.text);
7565
- if (inlineSpans.length > 0) {
7566
- return {
7567
- expressions: inlineSpans.map((s) => s.expression),
7568
- inlineSolveCount: inlineSpans.length
7569
- };
6773
+ if (this._streamController) {
6774
+ try {
6775
+ this._streamController.enqueue(event);
6776
+ } catch {
6777
+ }
7570
6778
  }
7571
- return { expressions: [trimmed], inlineSolveCount: 0 };
7572
6779
  }
7573
6780
  };
6781
+ // ── Web Streams API integration ──────────────────────────────────
6782
+ /**
6783
+ * Default high-water mark for the internal event stream.
6784
+ * Limits the internal buffer size before the stream signals to
6785
+ * consumers that they need to catch up.
6786
+ */
6787
+ _AsyncResolutionBatcher.DEFAULT_HIGH_WATER_MARK = 64;
6788
+ var AsyncResolutionBatcher = _AsyncResolutionBatcher;
7574
6789
 
7575
- export { AsyncResolutionBatcher, DocumentModel, EvalTier, ExpressionEngine, ThreeTierEvaluator, assertEngineVersionCompatible, checkEngineVersionCompatibility, checkExpressionComplexity, checkExpressionLength, checkPackageCompatibility, extractReadsAndWrites, findInlineSolvesInLine, isEmptyLine };
7576
- //# sourceMappingURL=chunk-V5PYO44Y.js.map
7577
- //# sourceMappingURL=chunk-V5PYO44Y.js.map
6790
+ export { AsyncResolutionBatcher, ExpressionEngine, SNAPSHOT_FORMAT, SNAPSHOT_VERSION, SnapshotErrorCodes, assertEngineVersionCompatible, checkEngineVersionCompatibility, checkExpressionComplexity, checkExpressionLength, checkPackageCompatibility, engine_worker_default, extractReadsAndWrites, findInlineSolvesInLine, isEmptyLine };
6791
+ //# sourceMappingURL=chunk-JIPATHVY.js.map
6792
+ //# sourceMappingURL=chunk-JIPATHVY.js.map