lazycodex-ai 5.1.45 → 5.1.46

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 (236) hide show
  1. package/README.de.md +0 -5
  2. package/README.es.md +0 -5
  3. package/README.fr.md +0 -5
  4. package/README.it.md +0 -5
  5. package/README.ja.md +0 -5
  6. package/README.ko.md +0 -5
  7. package/README.md +4 -5
  8. package/README.pt-br.md +0 -5
  9. package/README.ru.md +0 -6
  10. package/README.zh-cn.md +0 -5
  11. package/dist/cli/index.js +76 -156
  12. package/dist/cli-node/index.js +76 -156
  13. package/package.json +1 -1
  14. package/packages/lsp-daemon/dist/cli.js +3 -3
  15. package/packages/lsp-daemon/dist/client.js +3 -3
  16. package/packages/lsp-daemon/dist/index.js +3 -3
  17. package/packages/lsp-tools-mcp/dist/cli.js +3 -3
  18. package/packages/lsp-tools-mcp/dist/mcp.js +3 -3
  19. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  20. package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +22 -12
  21. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  22. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  23. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  24. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  25. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  26. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  27. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  28. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  29. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  30. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  31. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  32. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  33. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  34. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  35. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  36. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  37. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  38. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  39. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  40. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  41. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  42. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  43. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  44. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  45. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  46. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  47. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  48. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  49. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  50. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  51. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  52. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  53. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  54. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  55. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  56. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  57. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  58. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  59. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  60. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  61. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  62. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  63. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  64. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  65. package/packages/omo-codex/plugin/package-lock.json +12 -12
  66. package/packages/omo-codex/plugin/package.json +1 -1
  67. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +8 -3
  68. package/packages/omo-codex/plugin/shared/test/config-loader.test.ts +0 -2
  69. package/packages/omo-codex/plugin/skills/ast-grep/AGENTS.md +1 -1
  70. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
  71. package/packages/omo-codex/plugin/skills/frontend/ATTRIBUTION.md +2 -2
  72. package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/AGENTS.md +1 -1
  73. package/packages/omo-codex/plugin/skills/ulw-research/AGENTS.md +14 -2
  74. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +36 -11
  75. package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +73 -13
  76. package/packages/omo-codex/plugin/skills/ulw-research/references/fact-ledger-grammar.md +19 -1
  77. package/packages/omo-codex/plugin/skills/ulw-research/references/fact-ledger.md +95 -0
  78. package/packages/omo-codex/plugin/skills/ulw-research/references/fidelity-claim-kinds.md +19 -0
  79. package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +21 -4
  80. package/packages/omo-codex/plugin/skills/ulw-research/references/report-visualization.md +2 -0
  81. package/packages/omo-codex/plugin/skills/ulw-research/references/unit-rules.md +157 -0
  82. package/packages/omo-codex/plugin/skills/ulw-research/scripts/claim-kinds.mjs +116 -0
  83. package/packages/omo-codex/plugin/skills/ulw-research/scripts/cli-support.mjs +19 -5
  84. package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +27 -6
  85. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-meaning.mjs +191 -0
  86. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-verify.mjs +164 -0
  87. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands.mjs +239 -0
  88. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-derive.mjs +66 -0
  89. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-expr.mjs +108 -0
  90. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-fill.mjs +194 -0
  91. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-ledger-kinds.mjs +127 -13
  92. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-ledger-schema.mjs +33 -16
  93. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-ledger.mjs +29 -9
  94. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-locate.mjs +231 -0
  95. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-pairs.mjs +187 -0
  96. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-recompute.mjs +89 -0
  97. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-binding.mjs +130 -0
  98. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-brief.mjs +5 -0
  99. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-claims.mjs +89 -0
  100. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-lexicon.mjs +145 -0
  101. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-records.mjs +163 -0
  102. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-state.mjs +2 -1
  103. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-trace.mjs +85 -0
  104. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-values.mjs +229 -0
  105. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-fidelity.mjs +113 -0
  106. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-static.mjs +28 -8
  107. package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-lite.mjs +2 -2
  108. package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-markup.mjs +182 -0
  109. package/packages/omo-codex/plugin/skills/ulw-research/scripts/md-lite.mjs +193 -0
  110. package/packages/omo-codex/plugin/skills/ulw-research/scripts/meaning-check.mjs +196 -0
  111. package/packages/omo-codex/plugin/skills/ulw-research/scripts/outcome.mjs +6 -5
  112. package/packages/omo-codex/plugin/skills/ulw-research/scripts/placeholder.mjs +184 -0
  113. package/packages/omo-codex/plugin/skills/ulw-research/scripts/polish-apply.mjs +115 -0
  114. package/packages/omo-codex/plugin/skills/ulw-research/scripts/polish-diff.mjs +31 -0
  115. package/packages/omo-codex/plugin/skills/ulw-research/scripts/polish-project.mjs +125 -0
  116. package/packages/omo-codex/plugin/skills/ulw-research/scripts/polish-rules.mjs +170 -0
  117. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools-commands.mjs +19 -8
  118. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools.mjs +15 -0
  119. package/packages/omo-codex/plugin/skills/ulw-research/scripts/source-commands.mjs +140 -0
  120. package/packages/omo-codex/plugin/skills/ulw-research/scripts/source-snapshot.mjs +176 -0
  121. package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-algebra.mjs +101 -0
  122. package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-errors.mjs +23 -0
  123. package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-metadata.mjs +31 -0
  124. package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-pairs.mjs +72 -0
  125. package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-precision.mjs +44 -0
  126. package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-rules-shapes.mjs +64 -0
  127. package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-rules.mjs +173 -0
  128. package/packages/omo-codex/plugin/skills/ulw-research/scripts/units.mjs +4 -4
  129. package/packages/omo-codex/plugin/test/sync-skills-codex-compatibility.test.mjs +19 -0
  130. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +7 -3
  131. package/packages/omo-codex/scripts/install-dist/install-local.mjs +26 -16
  132. package/packages/shared-skills/skills/ast-grep/AGENTS.md +1 -1
  133. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +1 -1
  134. package/packages/shared-skills/skills/frontend/ATTRIBUTION.md +2 -2
  135. package/packages/shared-skills/skills/ultimate-browsing/engine/AGENTS.md +1 -1
  136. package/packages/shared-skills/skills/ulw-research/AGENTS.md +14 -2
  137. package/packages/shared-skills/skills/ulw-research/SKILL.md +37 -12
  138. package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +73 -13
  139. package/packages/shared-skills/skills/ulw-research/references/fact-ledger-grammar.md +19 -1
  140. package/packages/shared-skills/skills/ulw-research/references/fact-ledger.md +95 -0
  141. package/packages/shared-skills/skills/ulw-research/references/fidelity-claim-kinds.md +19 -0
  142. package/packages/shared-skills/skills/ulw-research/references/report-gates.md +21 -4
  143. package/packages/shared-skills/skills/ulw-research/references/report-visualization.md +2 -0
  144. package/packages/shared-skills/skills/ulw-research/references/unit-rules.md +157 -0
  145. package/packages/shared-skills/skills/ulw-research/scripts/claim-kinds.mjs +116 -0
  146. package/packages/shared-skills/skills/ulw-research/scripts/claim-kinds.test.ts +193 -0
  147. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.mjs +19 -5
  148. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.test.ts +13 -2
  149. package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +27 -6
  150. package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +3 -0
  151. package/packages/shared-skills/skills/ulw-research/scripts/copy-returns.test.ts +183 -0
  152. package/packages/shared-skills/skills/ulw-research/scripts/delivery-barrier.test.ts +245 -0
  153. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-meaning.mjs +191 -0
  154. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-verify.mjs +164 -0
  155. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands.mjs +239 -0
  156. package/packages/shared-skills/skills/ulw-research/scripts/fact-derive.mjs +66 -0
  157. package/packages/shared-skills/skills/ulw-research/scripts/fact-derive.test.ts +375 -0
  158. package/packages/shared-skills/skills/ulw-research/scripts/fact-expr.mjs +108 -0
  159. package/packages/shared-skills/skills/ulw-research/scripts/fact-fill.mjs +194 -0
  160. package/packages/shared-skills/skills/ulw-research/scripts/fact-fill.test.ts +221 -0
  161. package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-grammar.test.ts +9 -2
  162. package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-kinds.mjs +127 -13
  163. package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-kinds.test.ts +18 -3
  164. package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-legacy.test.ts +160 -0
  165. package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-schema.mjs +33 -16
  166. package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger.mjs +29 -9
  167. package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger.test.ts +2 -0
  168. package/packages/shared-skills/skills/ulw-research/scripts/fact-locate.mjs +231 -0
  169. package/packages/shared-skills/skills/ulw-research/scripts/fact-locate.test.ts +118 -0
  170. package/packages/shared-skills/skills/ulw-research/scripts/fact-pairs.mjs +187 -0
  171. package/packages/shared-skills/skills/ulw-research/scripts/fact-pairs.test.ts +167 -0
  172. package/packages/shared-skills/skills/ulw-research/scripts/fact-recompute.mjs +89 -0
  173. package/packages/shared-skills/skills/ulw-research/scripts/fact-unit-dimensions.test.ts +409 -0
  174. package/packages/shared-skills/skills/ulw-research/scripts/fact-unit-rules.test.ts +388 -0
  175. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-binding.mjs +130 -0
  176. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-binding.test.ts +81 -0
  177. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-brief.mjs +5 -0
  178. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-claims.mjs +89 -0
  179. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-lexicon.mjs +145 -0
  180. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-lexicon.test.ts +117 -0
  181. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-records.mjs +163 -0
  182. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-state.mjs +2 -1
  183. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-trace.mjs +85 -0
  184. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-trace.test.ts +58 -0
  185. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-values.mjs +229 -0
  186. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-values.test.ts +270 -0
  187. package/packages/shared-skills/skills/ulw-research/scripts/gates-fidelity.mjs +113 -0
  188. package/packages/shared-skills/skills/ulw-research/scripts/gates-fidelity.test.ts +73 -0
  189. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.mjs +28 -8
  190. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.mjs +2 -2
  191. package/packages/shared-skills/skills/ulw-research/scripts/html-markup.mjs +182 -0
  192. package/packages/shared-skills/skills/ulw-research/scripts/md-lite.mjs +193 -0
  193. package/packages/shared-skills/skills/ulw-research/scripts/md-lite.test.ts +271 -0
  194. package/packages/shared-skills/skills/ulw-research/scripts/meaning-check.mjs +196 -0
  195. package/packages/shared-skills/skills/ulw-research/scripts/meaning-check.test.ts +228 -0
  196. package/packages/shared-skills/skills/ulw-research/scripts/outcome.mjs +6 -5
  197. package/packages/shared-skills/skills/ulw-research/scripts/placeholder.mjs +184 -0
  198. package/packages/shared-skills/skills/ulw-research/scripts/placeholder.test.ts +77 -0
  199. package/packages/shared-skills/skills/ulw-research/scripts/polish-apply.mjs +115 -0
  200. package/packages/shared-skills/skills/ulw-research/scripts/polish-apply.test.ts +363 -0
  201. package/packages/shared-skills/skills/ulw-research/scripts/polish-diff.mjs +31 -0
  202. package/packages/shared-skills/skills/ulw-research/scripts/polish-diff.test.ts +168 -0
  203. package/packages/shared-skills/skills/ulw-research/scripts/polish-markup.test.ts +187 -0
  204. package/packages/shared-skills/skills/ulw-research/scripts/polish-project.mjs +125 -0
  205. package/packages/shared-skills/skills/ulw-research/scripts/polish-rules.mjs +170 -0
  206. package/packages/shared-skills/skills/ulw-research/scripts/report-tools-commands.mjs +19 -8
  207. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.mjs +15 -0
  208. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +458 -2
  209. package/packages/shared-skills/skills/ulw-research/scripts/source-commands.mjs +140 -0
  210. package/packages/shared-skills/skills/ulw-research/scripts/source-snapshot.mjs +176 -0
  211. package/packages/shared-skills/skills/ulw-research/scripts/source-snapshot.test.ts +113 -0
  212. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/assets/share.csv +3 -0
  213. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/gen-legacy-dev.mjs +89 -0
  214. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/ledger-basic.json +223 -0
  215. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/ledger-legacy-dev.json +859 -0
  216. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/ledger-verify.json +420 -0
  217. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/report-bad.html +19 -0
  218. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/report-good.html +39 -0
  219. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4/acronyms.json +3 -0
  220. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4/meta.json +9 -0
  221. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4/sentences.jsonl +7 -0
  222. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4/text.txt +7 -0
  223. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4.html +19 -0
  224. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S5/acronyms.json +1 -0
  225. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S5/meta.json +9 -0
  226. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S5/sentences.jsonl +1 -0
  227. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S5/text.txt +1 -0
  228. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S9.txt +1 -0
  229. package/packages/shared-skills/skills/ulw-research/scripts/unit-algebra.mjs +101 -0
  230. package/packages/shared-skills/skills/ulw-research/scripts/unit-errors.mjs +23 -0
  231. package/packages/shared-skills/skills/ulw-research/scripts/unit-metadata.mjs +31 -0
  232. package/packages/shared-skills/skills/ulw-research/scripts/unit-pairs.mjs +72 -0
  233. package/packages/shared-skills/skills/ulw-research/scripts/unit-precision.mjs +44 -0
  234. package/packages/shared-skills/skills/ulw-research/scripts/unit-rules-shapes.mjs +64 -0
  235. package/packages/shared-skills/skills/ulw-research/scripts/unit-rules.mjs +173 -0
  236. package/packages/shared-skills/skills/ulw-research/scripts/units.mjs +4 -4
@@ -0,0 +1,66 @@
1
+ // Public append boundary: dispatch through the operation table, verify transitive trust, append only on success.
2
+ import { leaves, parseExpr } from "./fact-expr.mjs"
3
+ import { readRow } from "./fact-ledger-kinds.mjs"
4
+ import { addRow, nextId, validateLedger } from "./fact-ledger.mjs"
5
+ import { recompute, verifyGraph } from "./fact-recompute.mjs"
6
+ import { canonicalUnit, inputScale, refuse } from "./unit-algebra.mjs"
7
+ import { refusal } from "./unit-errors.mjs"
8
+ import { object, sameDeclared } from "./unit-metadata.mjs"
9
+ import { display, renderValue, resultPrecision, toText } from "./unit-precision.mjs"
10
+ import { expectedUnit, renderPrecision, ruleOf, unitMismatch } from "./unit-rules.mjs"
11
+ export { recompute, display }
12
+
13
+ /** Arithmetic and generated fields are chosen exclusively by the registry. */
14
+ export function compute(op, rows, unit, ast, context, formula = null) {
15
+ const rule = ruleOf(op)
16
+ const out = rule.calculate(rows, context, ast, formula)
17
+ const mode = renderPrecision(op, unit, ast)
18
+ const precision = resultPrecision(rows, ast ? leaves(ast).literals : [], out.exact, mode)
19
+ const value = renderValue(out.exact, precision, mode)
20
+ const render = rule.render(display(value, unit), rows)
21
+ return { exact: toText(out.exact), value, render, raw: render, formula: out.formula, factor: out.factor, precision, compatibility: rule.compatibility, ...rule.metadataResult(rows) }
22
+ }
23
+
24
+ function declaredUnit(spec, rule) {
25
+ const own = Object.hasOwn(spec, "unit")
26
+ if ((!own && !rule.defaultUnit) || (own && spec.unit === undefined)) refuse("declare the result unit: pass unit (null for a dimensionless result)", undefined, "missing_unit", "an explicit unit or null")
27
+ const declared = own ? spec.unit : rule.defaultUnit(spec)
28
+ if (inputScale(declared)) refuse(`${declared} needs rescaling: declare %p and scale the value`)
29
+ return canonicalUnit(declared)
30
+ }
31
+
32
+ export function derive(ledger, spec) {
33
+ try {
34
+ if (!object(spec)) refuse("spec must be an object")
35
+ const errors = validateLedger(ledger)
36
+ if (errors.length) refuse(errors.join("; "), undefined, "ledger_schema", "a ledger conforming to KIND_RULES and canonical storage")
37
+ const rule = ruleOf(spec.op)
38
+ const allowed = ["op", "unit", "rationale", "label", "now", "context", ...rule.inputFields]
39
+ if (Object.keys(spec).some((key) => !allowed.includes(key))) refuse("spec carries fields outside its operation", undefined, "spec_fields", allowed)
40
+ const ast = rule.expression ? parseExpr(spec.formula) : null
41
+ const ids = rule.operandIds(spec, ast)
42
+ if (!Array.isArray(ids) || !ids.every((id) => typeof id === "string")) refuse(`${spec.op} needs its operand fact ids`, undefined, "operands", "an array of existing fact ids")
43
+ const verified = verifyGraph(ledger, ids, true)
44
+ if (!verified.ok) return { error: "incompatible_operands", ...verified }
45
+ const byId = new Map(ledger.rows.map((r) => [r.fact_id, r]))
46
+ const rows = ids.map((id) => readRow(byId.get(id)))
47
+ const context = rule.context(spec)
48
+ if (Object.hasOwn(spec, "context") && !sameDeclared(spec.context, context)) refuse("supplied context differs from the operation's input fields", undefined, "context", context ?? "explicit null")
49
+ const shape = rule.shape({ operation: spec.op, operands: ids, formula: spec.formula, context })
50
+ if (shape) refuse(shape, undefined, "context_or_formula", "the operation's declared context and matching formula operand set")
51
+ const expected = expectedUnit(spec.op, rows, { ast, context: { ...context, rationale: spec.rationale ?? null } })
52
+ const declared = declaredUnit(spec, rule)
53
+ if (declared !== expected) refuse(unitMismatch(spec.op, rows, declared, expected), undefined, "result_unit", { unit: expected })
54
+ const result = compute(spec.op, rows, expected, ast, context, spec.formula)
55
+ const row = addRow(ledger, {
56
+ kind: rule.kind, unit: expected, quote: "", extracted_by: "fact-derive",
57
+ observed_at: spec.now ?? new Date().toISOString(),
58
+ label: spec.label ?? `${spec.op}-${nextId(ledger, rule.kind).toLowerCase()}`,
59
+ ...result, operation: spec.op, operands: ids, derived_from: ids,
60
+ rationale: spec.rationale ?? null, context,
61
+ })
62
+ return { row, compatibility: rule.compatibility }
63
+ } catch (error) {
64
+ return { error: "incompatible_operands", ...refusal(error) }
65
+ }
66
+ }
@@ -0,0 +1,375 @@
1
+ import { describe, expect, onTestFinished, test } from "bun:test"
2
+ import { mkdtempSync, rmSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+
6
+ import { derive, recompute } from "./fact-derive.mjs"
7
+ import { addRow, emptyLedger, readLedger, writeLedger } from "./fact-ledger.mjs"
8
+
9
+ const HASH = "a".repeat(64)
10
+ const EOK_WON = "\uC5B5\uC6D0"
11
+
12
+ type Row = Record<string, unknown>
13
+ type Ledger = { schemaVersion: number; rows: Row[]; events: unknown[] }
14
+
15
+ function measured(factId: string, value: string, unit: string, extra: Row = {}): Row {
16
+ return {
17
+ fact_id: factId,
18
+ kind: "measured",
19
+ value,
20
+ unit,
21
+ raw: `${value} ${unit}`,
22
+ quote: `The figure is ${value} ${unit}.`,
23
+ source_id: "S1",
24
+ locator: { artifact: "sources/S1/text.txt", char_start: 0, char_end: 20 },
25
+ snapshot_sha256: HASH,
26
+ extracted_by: "fact add --find",
27
+ observed_at: "2026-10-23T08:00:00Z",
28
+ label: factId.toLowerCase(),
29
+ ...extra,
30
+ }
31
+ }
32
+
33
+ function ledgerOf(rows: Row[]): Ledger {
34
+ const ledger = emptyLedger() as Ledger
35
+ for (const row of rows) addRow(ledger, row)
36
+ return ledger
37
+ }
38
+
39
+ function fixture(): Ledger {
40
+ return ledgerOf([
41
+ measured("F5", "100", EOK_WON, { population: "all" }),
42
+ measured("F6", "200", EOK_WON, { population: "all" }),
43
+ measured("F7", "200", EOK_WON, { population: "subset" }),
44
+ measured("F8", "15", "%"),
45
+ measured("F9", "20", "%"),
46
+ measured("F12", "320", "m"),
47
+ measured("F19", "100", "case", { valid_at: "2025-Q3", entity: "acme" }),
48
+ measured("F20", "112", "case", { valid_at: "2026-Q3", entity: "acme" }),
49
+ measured("F30", "3.2", "m"),
50
+ ])
51
+ }
52
+
53
+ function rowOf(result: { row?: Row; error?: string; detail?: string }): Row {
54
+ if (!result.row) throw new Error(`expected a row, got ${result.error}: ${result.detail}`)
55
+ return result.row
56
+ }
57
+
58
+ describe("derive: checked operations", () => {
59
+ test("sum of compatible rows stores the exact sum and appends a derived row", () => {
60
+ const ledger = fixture()
61
+ const row = rowOf(derive(ledger, { op: "sum", operands: ["F5", "F6"], unit: EOK_WON }))
62
+ // unit-rules.md: derived rows store the canonical unit text, so the input alias is stored as its table token.
63
+ expect(row).toMatchObject({ fact_id: "F31", kind: "derived", operation: "sum", value: "300", unit: "KRW-e8", exact: { num: "300", den: "1" } })
64
+ expect(row.operands).toEqual(["F5", "F6"])
65
+ expect(row.derived_from).toEqual(["F5", "F6"])
66
+ expect(ledger.rows.at(-1)).toBe(row)
67
+ })
68
+
69
+ test("sum across declared populations is incompatible and writes nothing", () => {
70
+ const ledger = fixture()
71
+ const result = derive(ledger, { op: "sum", operands: ["F5", "F7"], unit: EOK_WON })
72
+ expect(result.error).toBe("incompatible_operands")
73
+ expect(result.detail).toContain("population")
74
+ expect(ledger.rows).toHaveLength(9)
75
+ })
76
+
77
+ test("percent operands: plain sum refused, pp diff and disjoint sum allowed", () => {
78
+ const ledger = fixture()
79
+ expect(derive(ledger, { op: "sum", operands: ["F8", "F9"], unit: "%" }).error).toBe("incompatible_operands")
80
+ expect(derive(ledger, { op: "diff", operands: ["F9", "F8"], unit: "%" }).error).toBe("incompatible_operands")
81
+ // unit-rules.md: `pp` is an input alias; the stored unit is canonical `%p`.
82
+ expect(rowOf(derive(ledger, { op: "diff", operands: ["F9", "F8"], unit: "pp" }))).toMatchObject({ value: "5", unit: "%p" })
83
+ expect(derive(ledger, { op: "sum", operands: ["F8", "F9"], unit: "%", disjoint: true }).error).toBe("incompatible_operands")
84
+ // unit-rules.md: a % sum also needs one shared reference (denominator), persisted in context.
85
+ expect(derive(ledger, { op: "sum", operands: ["F8", "F9"], unit: "%", disjoint: true, rationale: "x" }).error).toBe("incompatible_operands")
86
+ const disjoint = rowOf(derive(ledger, { op: "sum", operands: ["F8", "F9"], unit: "%", disjoint: true, reference: "all", rationale: "x" }))
87
+ expect(disjoint).toMatchObject({ value: "35", unit: "%", rationale: "x", context: { disjoint: true, reference: "all" } })
88
+ })
89
+
90
+ test("growth needs two periods of the same entity", () => {
91
+ const ledger = fixture()
92
+ const row = rowOf(derive(ledger, { op: "growth", from: "F19", to: "F20", unit: "%" }))
93
+ expect(row).toMatchObject({ value: "12", unit: "%", exact: { num: "12", den: "1" }, operands: ["F19", "F20"] })
94
+ addRow(ledger, measured("F21", "105", "case", { valid_at: "2025-Q3", entity: "acme" }))
95
+ const same = derive(ledger, { op: "growth", from: "F19", to: "F21", unit: "%" })
96
+ expect(same.error).toBe("incompatible_operands")
97
+ expect(same.detail).toContain("valid_at")
98
+ })
99
+
100
+ test("an unknown or missing operand is refused", () => {
101
+ const ledger = fixture()
102
+ expect(derive(ledger, { op: "sum", operands: ["F5", "F99"], unit: EOK_WON }).error).toBeDefined()
103
+ expect(ledger.rows).toHaveLength(9)
104
+ })
105
+ })
106
+
107
+ describe("derive: rendered precision", () => {
108
+ const precision = () =>
109
+ ledgerOf([measured("F1", "1000", "m"), measured("F2", "3", "m"), measured("F3", "9", "m"), measured("F4", "1.5", "m"), measured("F5", "20.25", "%"), measured("F6", "15.5", "%"), measured("F7", "19.0", "m")])
110
+
111
+ test("a difference keeps the fewest decimal places: 1000 m - 3 m is 997 m", () => {
112
+ const row = rowOf(derive(precision(), { op: "diff", operands: ["F1", "F2"], unit: "m" }))
113
+ expect(row).toMatchObject({ value: "997", render: "997 m", exact: { num: "997", den: "1" } })
114
+ })
115
+
116
+ test("a difference keeps the fewest decimal places: 9 m - 1.5 m is 8 m, 19.0 m - 1.5 m is 17.5 m", () => {
117
+ const ledger = precision()
118
+ expect(rowOf(derive(ledger, { op: "diff", operands: ["F3", "F4"], unit: "m" }))).toMatchObject({ value: "8", render: "8 m", exact: { num: "15", den: "2" } })
119
+ expect(rowOf(derive(ledger, { op: "diff", operands: ["F7", "F4"], unit: "m" }))).toMatchObject({ value: "17.5", render: "17.5 m", exact: { num: "35", den: "2" } })
120
+ })
121
+
122
+ test("pp with mixed decimal places rounds to the fewest: 20.25 % - 15.5 % is 4.8 pp", () => {
123
+ const ledger = precision()
124
+ const row = rowOf(derive(ledger, { op: "diff", operands: ["F5", "F6"], unit: "pp" }))
125
+ // unit-rules.md: the canonical delta unit is `%p`; precision is unchanged.
126
+ expect(row).toMatchObject({ value: "4.8", unit: "%p", render: "4.8 %p", exact: { num: "19", den: "4" } })
127
+ expect(recompute(ledger, row).ok).toBe(true)
128
+ })
129
+ })
130
+
131
+ describe("derive: convert", () => {
132
+ test("320 m to mm keeps the original beside the converted value", () => {
133
+ const ledger = fixture()
134
+ const row = rowOf(derive(ledger, { op: "convert", operands: ["F12"], to: "mm" }))
135
+ expect(row).toMatchObject({ kind: "converted", operation: "convert", value: "320000", unit: "mm", factor: "1000", render: "320,000 mm (320 m)" })
136
+ expect(row.exact).toEqual({ num: "320000", den: "1" })
137
+ expect(row.derived_from).toEqual(["F12"])
138
+ })
139
+
140
+ test("3.2 m to cm renders 320 cm at the original precision", () => {
141
+ const ledger = fixture()
142
+ const row = rowOf(derive(ledger, { op: "convert", operands: ["F30"], to: "cm" }))
143
+ expect(row.value).toBe("320")
144
+ expect(String(row.render).startsWith("320 cm")).toBe(true)
145
+ expect(String(row.render)).not.toContain("320.0")
146
+ })
147
+
148
+ test("currency conversion is refused with a hint to expr", () => {
149
+ const ledger = fixture()
150
+ const result = derive(ledger, { op: "convert", operands: ["F5"], to: "USD" })
151
+ expect(result.error).toBe("incompatible_operands")
152
+ expect(result.hint).toContain("expr")
153
+ expect(ledger.rows).toHaveLength(9)
154
+ })
155
+ })
156
+
157
+ describe("derive: expr and exact chaining", () => {
158
+ // unit-rules.md: expr units are inferred with no rationale escape, and an omitted unit is an error, not null.
159
+ test("expr needs a declared unit that follows from its operands", () => {
160
+ const ledger = fixture()
161
+ expect(derive(ledger, { op: "expr", formula: "F12 / F30", rationale: "span over sample length" }).error).toBeDefined()
162
+ expect(derive(ledger, { op: "expr", formula: "F12 / F30", unit: "m" }).error).toBeDefined()
163
+ const result = derive(ledger, { op: "expr", formula: "F12 / F30", unit: null })
164
+ expect(result.compatibility).toBe("unchecked")
165
+ expect(rowOf(result)).toMatchObject({ value: "100", formula: "F12 / F30", operands: ["F12", "F30"] })
166
+ })
167
+
168
+ test("expr refuses unsupported tokens and absent ids", () => {
169
+ const ledger = fixture()
170
+ for (const formula of ["F12 ** 2", "Math.max(F12)", "F12 / F404", "F12 / 0", "(F12"]) {
171
+ expect(derive(ledger, { op: "expr", formula, unit: null }).error).toBeDefined()
172
+ }
173
+ expect(ledger.rows).toHaveLength(9)
174
+ })
175
+
176
+ test("ratio of 1.00 / 3.00 stores 1/3 and chains exactly through expr", () => {
177
+ const ledger = ledgerOf([measured("F5", "1.00", EOK_WON, { population: "all" }), measured("F6", "3.00", EOK_WON, { population: "all" })])
178
+ // unit-rules.md: the result unit is always declared; a same-unit ratio is dimensionless (null).
179
+ const ratio = rowOf(derive(ledger, { op: "ratio", num: "F5", den: "F6", unit: null }))
180
+ expect(ratio.exact).toEqual({ num: "1", den: "3" })
181
+ expect(ratio.value).toBe("0.333")
182
+ const chained = rowOf(derive(ledger, { op: "expr", formula: `${ratio.fact_id} * 3`, unit: null }))
183
+ expect(chained.exact).toEqual({ num: "1", den: "1" })
184
+ expect(chained.value).toBe("1")
185
+
186
+ const dir = mkdtempSync(join(tmpdir(), "ulw-derive-"))
187
+ onTestFinished(() => rmSync(dir, { recursive: true, force: true }))
188
+ writeLedger(dir, ledger)
189
+ const back = readLedger(dir) as Ledger
190
+ expect(back.rows.find((row) => row.fact_id === ratio.fact_id)?.exact).toEqual({ num: "1", den: "3" })
191
+ expect(recompute(back, back.rows.find((row) => row.fact_id === chained.fact_id) as Row).ok).toBe(true)
192
+ })
193
+ })
194
+
195
+ describe("recompute", () => {
196
+ test("an untouched row recomputes; an edited value or exact does not", () => {
197
+ const ledger = fixture()
198
+ const row = rowOf(derive(ledger, { op: "convert", operands: ["F12"], to: "mm" }))
199
+ expect(recompute(ledger, row)).toMatchObject({ ok: true, expected: { value: "320000", exact: { num: "320000", den: "1" } } })
200
+ expect(recompute(ledger, { ...row, value: "319999" }).ok).toBe(false)
201
+ expect(recompute(ledger, { ...row, exact: { num: "319999", den: "1" } }).ok).toBe(false)
202
+ const sum = rowOf(derive(ledger, { op: "sum", operands: ["F5", "F6"], unit: EOK_WON }))
203
+ expect(recompute(ledger, sum).ok).toBe(true)
204
+ expect(recompute(ledger, { ...sum, exact: { num: "301", den: "1" } }).ok).toBe(false)
205
+ })
206
+
207
+ const meters = () => ledgerOf([measured("F1", "1", "m"), measured("F2", "3", "m"), measured("F3", "5", "m")])
208
+
209
+ test("a unit relabel with a matching render is refused: 1 m + 3 m relabeled 4 km", () => {
210
+ const ledger = meters()
211
+ const sum = rowOf(derive(ledger, { op: "sum", operands: ["F1", "F2"], unit: "m" }))
212
+ expect(recompute(ledger, sum).ok).toBe(true)
213
+ const relabeled = recompute(ledger, { ...sum, unit: "km", raw: "4 km", render: "4 km" })
214
+ expect(relabeled.ok).toBe(false)
215
+ expect(relabeled.detail).toContain("unit")
216
+ const expr = rowOf(derive(ledger, { op: "expr", formula: "F1 + F2", unit: "m" }))
217
+ expect(recompute(ledger, expr).ok).toBe(true)
218
+ expect(recompute(ledger, { ...expr, unit: "km", raw: "4 km", render: "4 km" }).ok).toBe(false)
219
+ })
220
+
221
+ test("a row with null operands is not ok and does not throw", () => {
222
+ const ledger = meters()
223
+ const sum = rowOf(derive(ledger, { op: "sum", operands: ["F1", "F2"], unit: "m" }))
224
+ const result = recompute(ledger, { ...sum, operands: null })
225
+ expect(result.ok).toBe(false)
226
+ expect(result.detail).toContain("operands")
227
+ })
228
+
229
+ test("an expr row whose operands no longer match its formula ids is not ok and does not throw", () => {
230
+ const ledger = meters()
231
+ const expr = rowOf(derive(ledger, { op: "expr", formula: "F1 + F2", unit: "m" }))
232
+ const result = recompute(ledger, { ...expr, operands: ["F1", "F3"] })
233
+ expect(result.ok).toBe(false)
234
+ expect(result.detail).toContain("F2")
235
+ })
236
+ })
237
+
238
+ describe("unit rules: the design review's counterexamples", () => {
239
+ const book = () =>
240
+ ledgerOf([
241
+ measured("F1", "20", "%"),
242
+ measured("F2", "10", "%"),
243
+ measured("F3", "5", "pp"),
244
+ measured("F4", "25", "C", { valid_at: "2025", entity: "x" }),
245
+ measured("F5", "30", "C", { valid_at: "2026", entity: "x" }),
246
+ measured("F6", "200", "person"),
247
+ measured("F7", "100", "person"),
248
+ measured("F8", "100", "USD"),
249
+ measured("F9", "20", "USD/person"),
250
+ measured("F10", "0", "m"),
251
+ ])
252
+
253
+ test("a sum of % operands without one shared reference is refused", () => {
254
+ const ledger = book()
255
+ const base = { op: "sum", operands: ["F1", "F2"], unit: "%", disjoint: true, rationale: "disjoint shares" }
256
+ expect(derive(ledger, base).error).toBe("incompatible_operands")
257
+ expect(derive(ledger, { ...base, reference: { F1: "voters", F2: "residents" } }).error).toBe("incompatible_operands")
258
+ expect(rowOf(derive(ledger, { ...base, reference: { F1: "voters", F2: "voters" } }))).toMatchObject({ value: "30", unit: "%" })
259
+ })
260
+
261
+ test("20% - 5pp is 15%", () => {
262
+ const row = rowOf(derive(book(), { op: "diff", operands: ["F1", "F3"], unit: "%" }))
263
+ expect(row).toMatchObject({ value: "15", unit: "%", render: "15%" })
264
+ })
265
+
266
+ test("growth on a Celsius scale is refused", () => {
267
+ const result = derive(book(), { op: "growth", from: "F4", to: "F5", unit: "%" })
268
+ expect(result.error).toBe("incompatible_operands")
269
+ expect(result.detail).toContain("affine")
270
+ })
271
+
272
+ test("temperatures are not summed or differenced as absolute values; ratio refuses them; convert C to F is allowed", () => {
273
+ const ledger = book()
274
+ const refused = [
275
+ { op: "sum", operands: ["F4", "F5"], unit: "C" },
276
+ { op: "diff", operands: ["F5", "F4"], unit: "C" },
277
+ { op: "expr", formula: "F4 + F5", unit: "C" },
278
+ { op: "expr", formula: "F5 - F4", unit: "C" },
279
+ { op: "ratio", num: "F5", den: "F4", unit: null },
280
+ ]
281
+ for (const spec of refused) {
282
+ const result = derive(ledger, spec)
283
+ expect({ spec, error: result.error, detail: result.detail }).toEqual({ spec, error: "incompatible_operands", detail: expect.stringContaining("affine") })
284
+ }
285
+ const converted = rowOf(derive(ledger, { op: "convert", operands: ["F4"], to: "F" }))
286
+ expect(converted).toMatchObject({ value: "77", unit: "F" })
287
+ expect(recompute(ledger, converted).ok).toBe(true)
288
+ })
289
+
290
+ test("a convert row relabeled to an alias in both unit and context.target is detected", () => {
291
+ const ledger = book()
292
+ const row = rowOf(derive(ledger, { op: "convert", operands: ["F10"], to: "km" }))
293
+ const relabeled = recompute(ledger, { ...row, unit: "kilometre", raw: "0 kilometre (0 m)", render: "0 kilometre (0 m)", context: { target: "kilometre" } })
294
+ expect(relabeled.ok).toBe(false)
295
+ expect(relabeled.detail).toContain('context.target "kilometre" is not canonical')
296
+ })
297
+
298
+ test("a share of 200 out of 100 is refused", () => {
299
+ expect(derive(book(), { op: "share", part: "F6", whole: "F7", unit: "%" }).error).toBe("incompatible_operands")
300
+ })
301
+
302
+ test("100 USD / (20 USD/person) is 5 person", () => {
303
+ const ledger = book()
304
+ const row = rowOf(derive(ledger, { op: "ratio", num: "F8", den: "F9", unit: "person" }))
305
+ expect(row).toMatchObject({ value: "5", unit: "person", exact: { num: "5", den: "1" } })
306
+ expect(recompute(ledger, row).ok).toBe(true)
307
+ })
308
+
309
+ test("convert 0 m to km relabeled cm is detected though the value is unchanged", () => {
310
+ const ledger = book()
311
+ const row = rowOf(derive(ledger, { op: "convert", operands: ["F10"], to: "km" }))
312
+ expect(recompute(ledger, row).ok).toBe(true)
313
+ const relabeled = recompute(ledger, { ...row, unit: "cm", raw: "0 cm (0 m)", render: "0 cm (0 m)" })
314
+ expect(relabeled.ok).toBe(false)
315
+ expect(relabeled.detail).toContain("km")
316
+ })
317
+
318
+ test("expr % + %p is %, in either order; a ratio over unequal % and %p is refused", () => {
319
+ const ledger = book()
320
+ for (const formula of ["F1 + F3", "F3 + F1"]) {
321
+ const row = rowOf(derive(ledger, { op: "expr", formula, unit: "%" }))
322
+ expect(row).toMatchObject({ value: "25", unit: "%" })
323
+ expect(recompute(ledger, row).ok).toBe(true)
324
+ }
325
+ const ratio = derive(ledger, { op: "ratio", num: "F1", den: "F3", unit: null })
326
+ expect(ratio.error).toBe("incompatible_operands")
327
+ expect(ratio.detail).toContain("needs equal units")
328
+ })
329
+
330
+ test("a stored context on an operation that has none is malformed", () => {
331
+ const ledger = book()
332
+ const diff = rowOf(derive(ledger, { op: "diff", operands: ["F1", "F2"], unit: "%p" }))
333
+ const expr = rowOf(derive(ledger, { op: "expr", formula: "F1 - F2", unit: "%p" }))
334
+ for (const [row, context] of [[diff, "x"], [expr, { target: "km" }]] as const) {
335
+ const result = recompute(ledger, { ...row, context })
336
+ expect(result.ok).toBe(false)
337
+ expect(result.detail).toContain("rows carry no context")
338
+ }
339
+ })
340
+
341
+ test("expr 20% - 10% is %p, never %", () => {
342
+ const ledger = book()
343
+ expect(derive(ledger, { op: "expr", formula: "F1 - F2", unit: "%" }).error).toBe("incompatible_operands")
344
+ const row = rowOf(derive(ledger, { op: "expr", formula: "F1 - F2", unit: "%p" }))
345
+ expect(row).toMatchObject({ value: "10", unit: "%p" })
346
+ expect(recompute(ledger, row).ok).toBe(true)
347
+ })
348
+ })
349
+
350
+ describe("render precision of expr results (invariant 5)", () => {
351
+ const book = () => ledgerOf([measured("F1", "20.25", "%"), measured("F2", "15.5", "%"), measured("F3", "1000", "m"), measured("F4", "3", "m"), measured("F5", "12.34", "m")])
352
+
353
+ test("an expr difference giving %p renders by decimal places, as diff does", () => {
354
+ const ledger = book()
355
+ const viaDiff = rowOf(derive(ledger, { op: "diff", operands: ["F1", "F2"], unit: "%p" }))
356
+ const viaExpr = rowOf(derive(ledger, { op: "expr", formula: "F1 - F2", unit: "%p" }))
357
+ expect(viaDiff).toMatchObject({ value: "4.8", render: "4.8 %p" })
358
+ expect(viaExpr).toMatchObject({ value: "4.8", render: "4.8 %p" })
359
+ expect(recompute(ledger, viaExpr).ok).toBe(true)
360
+ })
361
+
362
+ test("an expr with a - root renders 1000 m - 3 m as 997 m", () => {
363
+ const ledger = book()
364
+ const row = rowOf(derive(ledger, { op: "expr", formula: "F3 - F4", unit: "m" }))
365
+ expect(row).toMatchObject({ value: "997", render: "997 m", exact: { num: "997", den: "1" } })
366
+ expect(recompute(ledger, row).ok).toBe(true)
367
+ })
368
+
369
+ test("an expr with a * root keeps significant figures", () => {
370
+ const ledger = book()
371
+ const row = rowOf(derive(ledger, { op: "expr", formula: "F5 * 1.5", unit: "m" }))
372
+ expect(row).toMatchObject({ value: "19", render: "19 m" })
373
+ expect(recompute(ledger, row).ok).toBe(true)
374
+ })
375
+ })
@@ -0,0 +1,108 @@
1
+ // fact-expr.mjs - the expression language of `fact derive --op expr` (references/unit-rules.md, expr row).
2
+ // Leaves are fact ids and decimal literals; nodes are + - * / and pct(x), the explicit dimensionless-to-% node.
3
+ // evaluate() is exact rational arithmetic. Under * and / a child whose inferred unit is % or %p enters as its
4
+ // fraction (20% as 0.2): that unit comes from the registry's inference over the operand rows, never from the row's
5
+ // own declared unit.
6
+
7
+ import { add, div, fromDecimal, mul, sub } from "./rational.mjs"
8
+ import { isPercentish, refuse } from "./unit-algebra.mjs"
9
+
10
+ const TOKEN = /\s*(?:(F\d+)|(\d+(?:\.\d+)?)|(pct)\b|([-+*/()]))/y
11
+ const HUNDRED = fromDecimal("100")
12
+
13
+ function tokenize(formula) {
14
+ const text = String(formula ?? "")
15
+ const tokens = []
16
+ TOKEN.lastIndex = 0
17
+ while (/\S/.test(text.slice(TOKEN.lastIndex))) {
18
+ const at = TOKEN.lastIndex
19
+ const match = TOKEN.exec(text)
20
+ if (!match) refuse(`unsupported expression text at ${at}: "${text.slice(at).trim().slice(0, 16)}"`)
21
+ tokens.push(match[1] ? { id: match[1] } : match[2] ? { literal: match[2] } : match[3] ? { fn: "pct" } : { op: match[4] })
22
+ }
23
+ if (tokens.length === 0) refuse("expr needs an expression")
24
+ return tokens
25
+ }
26
+
27
+ /** Shunting-yard parser: parentheses and AST depth do not consume the JS call stack. */
28
+ export function parseExpr(formula) {
29
+ if (typeof formula !== "string") refuse("expr needs an expression")
30
+ const tokens = tokenize(formula)
31
+ const values = []
32
+ const ops = []
33
+ const precedence = (op) => op === "*" || op === "/" ? 2 : 1
34
+ const reduce = () => {
35
+ const op = ops.pop()
36
+ if (op === "pct") values.push({ fn: op, arg: values.pop() })
37
+ else { const right = values.pop(); values.push({ op, left: values.pop(), right }) }
38
+ }
39
+ let need = true
40
+ for (let i = 0; i < tokens.length; i++) {
41
+ const token = tokens[i]
42
+ if (need) {
43
+ if (token.id || token.literal) { values.push(token); need = false }
44
+ else if (token.fn) {
45
+ if (tokens[i + 1]?.op !== "(") refuse("pct needs a parenthesized argument")
46
+ ops.push("pct")
47
+ } else if (token.op === "(") ops.push("(")
48
+ else refuse(`unexpected "${token.op}" in the expression`)
49
+ } else if (token.op === ")") {
50
+ while (ops.length && ops.at(-1) !== "(") reduce()
51
+ if (ops.pop() !== "(") refuse("unexpected closing parenthesis")
52
+ if (ops.at(-1) === "pct") reduce()
53
+ } else if (["+", "-", "*", "/"].includes(token.op)) {
54
+ while (ops.length && ops.at(-1) !== "(" && precedence(ops.at(-1)) >= precedence(token.op)) reduce()
55
+ ops.push(token.op)
56
+ need = true
57
+ } else refuse("unexpected text after the expression")
58
+ }
59
+ if (need) refuse("the expression ends early")
60
+ if (ops.includes("(")) refuse("the expression is missing a closing parenthesis")
61
+ while (ops.length) reduce()
62
+ return values[0]
63
+ }
64
+
65
+ const childrenOf = (node) => (node.op ? [node.left, node.right] : node.fn ? [node.arg] : [])
66
+
67
+ export function leaves(node, found = { ids: [], literals: [] }) {
68
+ const ids = new Set(found.ids)
69
+ const stack = [node]
70
+ while (stack.length) {
71
+ const current = stack.pop()
72
+ if (current.id) ids.add(current.id)
73
+ if (current.literal) found.literals.push(current.literal)
74
+ stack.push(...childrenOf(current).reverse())
75
+ }
76
+ found.ids = [...ids]
77
+ return found
78
+ }
79
+
80
+ export function postorder(node) {
81
+ const stack = [node]
82
+ const order = []
83
+ while (stack.length) { const current = stack.pop(); order.push(current); stack.push(...childrenOf(current)) }
84
+ return order.reverse()
85
+ }
86
+
87
+ /** Exact value of the tree; `units` maps each node to its inferred canonical unit (inferExpr in unit-rules.mjs). */
88
+ export function evaluate(node, values, units) {
89
+ const results = new Map()
90
+ for (const current of postorder(node)) {
91
+ let value
92
+ if (current.id) value = values.get(current.id)
93
+ else if (current.literal) value = fromDecimal(current.literal)
94
+ else if (current.fn === "pct") value = mul(results.get(current.arg), HUNDRED)
95
+ else {
96
+ const operand = (child) => {
97
+ const v = results.get(child)
98
+ return (current.op === "*" || current.op === "/") && isPercentish(units.get(child)) ? div(v, HUNDRED) : v
99
+ }
100
+ const left = operand(current.left)
101
+ const right = operand(current.right)
102
+ if (current.op === "/" && right.num === 0n) refuse("the expression divides by zero", undefined, "divides_by_zero", "a nonzero denominator")
103
+ value = { "+": add, "-": sub, "*": mul, "/": div }[current.op](left, right)
104
+ }
105
+ results.set(current, value)
106
+ }
107
+ return results.get(node)
108
+ }