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,388 @@
1
+ import { describe, expect, test } from "bun:test"
2
+
3
+ import { derive, display, recompute } from "./fact-derive.mjs"
4
+ import { parseExpr } from "./fact-expr.mjs"
5
+ import { addRow, emptyLedger, OPERATIONS } from "./fact-ledger.mjs"
6
+ import { canonicalUnit, expectedUnit, isAffine, OPERATION_RULES, renderPrecision } from "./unit-rules.mjs"
7
+
8
+ // Property checks of references/unit-rules.md: every case table below is keyed by the registry's own operation list,
9
+ // so a new operation without valid, relabel, and malformed cases fails here.
10
+
11
+ type Row = Record<string, unknown>
12
+ type Ledger = { schemaVersion: number; rows: Row[]; events: unknown[] }
13
+ type Spec = Record<string, unknown>
14
+
15
+ const HASH = "a".repeat(64)
16
+
17
+ function measured(factId: string, value: string, unit: string | null, extra: Row = {}): Row {
18
+ return {
19
+ fact_id: factId,
20
+ kind: "measured",
21
+ value,
22
+ unit,
23
+ raw: `${value} ${unit ?? ""}`.trim(),
24
+ quote: `The figure is ${value}.`,
25
+ source_id: "S1",
26
+ locator: { artifact: "sources/S1/text.txt", char_start: 0, char_end: 20 },
27
+ snapshot_sha256: HASH,
28
+ extracted_by: "fact add --find",
29
+ observed_at: "2026-10-23T08:00:00Z",
30
+ label: factId.toLowerCase(),
31
+ ...extra,
32
+ }
33
+ }
34
+
35
+ function ledger(): Ledger {
36
+ const built = emptyLedger() as Ledger
37
+ const rows = [
38
+ measured("F1", "1", "m"),
39
+ measured("F2", "3", "m"),
40
+ measured("F3", "20", "%"),
41
+ measured("F4", "15", "%"),
42
+ measured("F5", "5", "%p"),
43
+ measured("F6", "100", "case", { valid_at: "2025", entity: "acme" }),
44
+ measured("F7", "112", "case", { valid_at: "2026", entity: "acme" }),
45
+ measured("F8", "40", "person"),
46
+ measured("F9", "200", "person"),
47
+ measured("F10", "100", "USD"),
48
+ measured("F11", "20", "USD/person"),
49
+ measured("F12", "0", "m"),
50
+ measured("F13", "25", "C", { valid_at: "2025", entity: "x" }),
51
+ measured("F14", "30", "C", { valid_at: "2026", entity: "x" }),
52
+ measured("F15", "0", "person"),
53
+ measured("F16", "2.5", "kilometre"),
54
+ measured("F17", "120", "case", { valid_at: "2026", entity: "other" }),
55
+ measured("F18", "0", "case", { valid_at: "2025", entity: "acme" }),
56
+ measured("F19", "-5", "person"),
57
+ measured("F20", "2", "%p"),
58
+ measured("F21", "105", "case", { valid_at: "2025", entity: "acme" }),
59
+ measured("F22", "200", "person"),
60
+ ]
61
+ for (const row of rows) addRow(built, row)
62
+ return built
63
+ }
64
+
65
+ const PCT_SUM = { op: "sum", operands: ["F3", "F4"], unit: "%", disjoint: true, reference: "all respondents", rationale: "disjoint answer shares" }
66
+
67
+ const VALID: Record<string, Array<{ spec: Spec; unit: string | null; precision?: "places" | "sigfigs" }>> = {
68
+ sum: [
69
+ { spec: { op: "sum", operands: ["F1", "F2"], unit: "m" }, unit: "m" },
70
+ { spec: { op: "sum", operands: ["F1", "F2"], unit: "metre" }, unit: "m" },
71
+ { spec: PCT_SUM, unit: "%" },
72
+ ],
73
+ diff: [
74
+ { spec: { op: "diff", operands: ["F2", "F1"], unit: "m" }, unit: "m" },
75
+ { spec: { op: "diff", operands: ["F3", "F4"], unit: "pp" }, unit: "%p" },
76
+ { spec: { op: "diff", operands: ["F3", "F5"], unit: "%" }, unit: "%" },
77
+ { spec: { op: "diff", operands: ["F5", "F20"], unit: "%p" }, unit: "%p" },
78
+ ],
79
+ growth: [{ spec: { op: "growth", from: "F6", to: "F7", unit: "%" }, unit: "%" }],
80
+ share: [
81
+ { spec: { op: "share", part: "F8", whole: "F9", unit: "%" }, unit: "%" },
82
+ { spec: { op: "share", part: "F22", whole: "F9", unit: "%" }, unit: "%" },
83
+ ],
84
+ ratio: [
85
+ { spec: { op: "ratio", num: "F10", den: "F11", unit: "person" }, unit: "person" },
86
+ { spec: { op: "ratio", num: "F1", den: "F2", unit: null }, unit: null },
87
+ { spec: { op: "ratio", num: "F10", den: "F9", unit: "USD/person" }, unit: "USD/person" },
88
+ ],
89
+ convert: [
90
+ { spec: { op: "convert", operands: ["F12"], to: "km" }, unit: "km" },
91
+ { spec: { op: "convert", operands: ["F16"], to: "m" }, unit: "m" },
92
+ { spec: { op: "convert", operands: ["F2"], to: "kilometre" }, unit: "km" },
93
+ ],
94
+ expr: [
95
+ { spec: { op: "expr", formula: "F3 - F4", unit: "%p" }, unit: "%p", precision: "places" },
96
+ { spec: { op: "expr", formula: "F2 - F1", unit: "m" }, unit: "m", precision: "places" },
97
+ { spec: { op: "expr", formula: "F5", unit: "%p" }, unit: "%p", precision: "places" },
98
+ { spec: { op: "expr", formula: "F1 * F1 / F2", unit: "m" }, unit: "m" },
99
+ { spec: { op: "expr", formula: "F10 / F11", unit: "person" }, unit: "person" },
100
+ { spec: { op: "expr", formula: "pct(F8 / F9)", unit: "%" }, unit: "%" },
101
+ { spec: { op: "expr", formula: "F3 * F9", unit: "person" }, unit: "person" },
102
+ ],
103
+ }
104
+
105
+ /** Invariant 5 per operation: sum and diff render by decimal places, the rest by significant figures (expr cases override). */
106
+ const PRECISION: Record<string, "places" | "sigfigs"> = { sum: "places", diff: "places", growth: "sigfigs", share: "sigfigs", ratio: "sigfigs", convert: "sigfigs", expr: "sigfigs" }
107
+
108
+ const RELABELS = [null, "%", "%p", "pp", "bp", "m", "metre", "km", "kilometre", "cm", "C", "case", "person", "USD", "USD/person", "person/USD", "KRW-e8"]
109
+
110
+ type Mutation = { name: string; base: Spec; mutate: (row: Row, book: Ledger) => Row; reason: string }
111
+ const generic = (base: Spec, arity: string[], arityReason = "takes ", missingReason = "no ledger row F999"): Mutation[] => [
112
+ { name: "operands null", base, mutate: (row) => ({ ...row, operands: null }), reason: "operands must be a non-empty array" },
113
+ { name: "operands empty", base, mutate: (row) => ({ ...row, operands: [] }), reason: "operands must be a non-empty array" },
114
+ { name: "wrong arity", base, mutate: (row) => ({ ...row, operands: arity }), reason: arityReason },
115
+ { name: "missing operand id", base, mutate: (row) => ({ ...row, operands: (row.operands as string[]).map((id, index) => (index === 0 ? "F999" : id)) }), reason: missingReason },
116
+ { name: "invalid rational", base, mutate: (row) => ({ ...row, exact: { num: "x", den: "1" } }), reason: "exact must be { num, den }" },
117
+ { name: "no operation", base, mutate: (row) => ({ ...row, operation: "mean" }), reason: 'operation "mean" is not one of' },
118
+ { name: "repeated operand", base, mutate: (row) => ({ ...row, operands: repeatFirst(row.operands as string[]) }), reason: "is repeated" },
119
+ { name: "self reference", base, mutate: (row) => ({ ...row, operands: (row.operands as string[]).map((id, index) => (index === 0 ? row.fact_id : id)) }), reason: "cites itself" },
120
+ { name: "kind operation", base, mutate: (row) => ({ ...row, kind: "assumed" }), reason: "kind assumed" },
121
+ { name: "audit fields", base, mutate: (row) => ({ ...row, factor: "999" }), reason: "factor" },
122
+ { name: "precision field", base, mutate: (row) => ({ ...row, precision: { places: -1, sigfigs: 0 } }), reason: "precision" },
123
+ { name: "compatibility field", base, mutate: (row) => ({ ...row, compatibility: "invalid" }), reason: "compatibility" },
124
+ { name: "metadata fields", base, mutate: (row) => ({ ...row, population: { tampered: true } }), reason: "population" },
125
+ { name: "noncanonical operand", base, mutate: (row, book) => {
126
+ const operand = book.rows.find((r) => r.fact_id === (row.operands as string[])[0])
127
+ if (!operand) throw Error("fixture operand missing")
128
+ operand.unit = `${operand.unit}*bp/%p`
129
+ return row
130
+ }, reason: "canonical" },
131
+ ]
132
+ /** The first operand id in a second slot: [F1, F2] -> [F1, F1]; a one-operand row gets [F1, F1]. */
133
+ const repeatFirst = (ids: string[]) => (ids.length === 1 ? [ids[0], ids[0]] : [...ids.slice(0, -1), ids[0]])
134
+ const operands = (ids: string[]) => (row: Row) => ({ ...row, operands: ids })
135
+
136
+ const MALFORMED: Record<string, Mutation[]> = {
137
+ sum: [
138
+ ...generic(VALID.sum[0].spec, ["F1"]),
139
+ { name: "no context", base: VALID.sum[0].spec, mutate: (row) => ({ ...row, context: null }), reason: "sum rows carry a context object" },
140
+ { name: "% without disjoint", base: PCT_SUM, mutate: (row) => ({ ...row, context: { disjoint: false, reference: "all respondents" } }), reason: "needs disjoint shares" },
141
+ { name: "% without reference", base: PCT_SUM, mutate: (row) => ({ ...row, context: { disjoint: true, reference: null } }), reason: "one shared reference" },
142
+ { name: "% without rationale", base: PCT_SUM, mutate: (row) => ({ ...row, rationale: null }), reason: "needs a rationale" },
143
+ { name: "unequal units", base: VALID.sum[0].spec, mutate: operands(["F1", "F8"]), reason: "unit differs" },
144
+ { name: "affine units", base: VALID.sum[0].spec, mutate: operands(["F13", "F14"]), reason: "sum refuses affine" },
145
+ { name: "closed context", base: VALID.sum[0].spec, mutate: (row) => ({ ...row, context: { disjoint: "true", reference: 7, extra: true } }), reason: "sum context" },
146
+ { name: "share total", base: PCT_SUM, mutate: (row, book) => {
147
+ for (const operand of book.rows.filter((r) => ["F3", "F4"].includes(String(r.fact_id)))) Object.assign(operand, { value: "60", exact: { num: "60", den: "1" } })
148
+ return row
149
+ }, reason: "total <= 100" },
150
+ ],
151
+ diff: [
152
+ ...generic(VALID.diff[0].spec, ["F2", "F1", "F8"]),
153
+ { name: "%p - % pair", base: VALID.diff[2].spec, mutate: operands(["F5", "F3"]), reason: '"%p" - "%" is not a typed pair' },
154
+ { name: "unit - other unit pair", base: VALID.diff[0].spec, mutate: operands(["F1", "F8"]), reason: '"m" - "person" is not a typed pair' },
155
+ { name: "affine units", base: VALID.diff[0].spec, mutate: operands(["F14", "F13"]), reason: "affine (temperature)" },
156
+ { name: "stray context", base: VALID.diff[0].spec, mutate: (row) => ({ ...row, context: "x" }), reason: "diff rows carry no context" },
157
+ ],
158
+ growth: [
159
+ ...generic(VALID.growth[0].spec, ["F6"]),
160
+ { name: "affine units", base: VALID.growth[0].spec, mutate: operands(["F13", "F14"]), reason: "growth refuses affine" },
161
+ { name: "unequal units", base: VALID.growth[0].spec, mutate: operands(["F6", "F8"]), reason: "unit differs" },
162
+ { name: "entity mismatch", base: VALID.growth[0].spec, mutate: operands(["F6", "F17"]), reason: "growth needs one declared entity" },
163
+ { name: "one period", base: VALID.growth[0].spec, mutate: operands(["F6", "F21"]), reason: "two different valid_at periods" },
164
+ { name: "from <= 0", base: VALID.growth[0].spec, mutate: operands(["F18", "F7"]), reason: "positive starting value in F18" },
165
+ { name: "stray context", base: VALID.growth[0].spec, mutate: (row) => ({ ...row, context: { target: "km" } }), reason: "growth rows carry no context" },
166
+ ],
167
+ share: [
168
+ ...generic(VALID.share[0].spec, ["F8"]),
169
+ { name: "affine units", base: VALID.share[0].spec, mutate: operands(["F13", "F14"]), reason: "share refuses affine" },
170
+ { name: "unequal units", base: VALID.share[0].spec, mutate: operands(["F8", "F1"]), reason: "unit differs" },
171
+ { name: "whole <= 0", base: VALID.share[0].spec, mutate: operands(["F8", "F15"]), reason: "positive whole in F15" },
172
+ { name: "part < 0", base: VALID.share[0].spec, mutate: operands(["F19", "F9"]), reason: "F19 is outside [0, F9]" },
173
+ { name: "part above whole", base: VALID.share[0].spec, mutate: operands(["F9", "F8"]), reason: "F9 is outside [0, F8]" },
174
+ { name: "stray context", base: VALID.share[0].spec, mutate: (row) => ({ ...row, context: "x" }), reason: "share rows carry no context" },
175
+ ],
176
+ ratio: [
177
+ ...generic(VALID.ratio[0].spec, ["F10"]),
178
+ { name: "affine units", base: VALID.ratio[0].spec, mutate: operands(["F13", "F14"]), reason: "ratio refuses affine" },
179
+ { name: "divides by zero", base: VALID.ratio[0].spec, mutate: operands(["F10", "F15"]), reason: "denominator F15 is zero" },
180
+ { name: "stray context", base: VALID.ratio[0].spec, mutate: (row) => ({ ...row, context: { target: "km" } }), reason: "ratio rows carry no context" },
181
+ ],
182
+ convert: [
183
+ ...generic(VALID.convert[0].spec, ["F12", "F1"]),
184
+ { name: "no context", base: VALID.convert[0].spec, mutate: (row) => ({ ...row, context: null }), reason: "convert rows carry a context object" },
185
+ { name: "empty target", base: VALID.convert[0].spec, mutate: (row) => ({ ...row, context: { target: "" } }), reason: "carries its target in context.target" },
186
+ { name: "alias target", base: VALID.convert[0].spec, mutate: (row) => ({ ...relabel(row, "kilometre")[0], context: { target: "kilometre" } }), reason: 'context.target "kilometre" is not canonical' },
187
+ { name: "currency target", base: VALID.convert[0].spec, mutate: (row) => ({ ...row, context: { target: "USD" } }), reason: "no table conversion to USD (a currency)" },
188
+ { name: "no factor to target", base: VALID.convert[0].spec, mutate: (row) => ({ ...row, context: { target: "person" } }), reason: 'F12 in "m" has no table conversion to person' },
189
+ { name: "closed context", base: VALID.convert[0].spec, mutate: (row) => ({ ...row, context: { target: "km", extra: true } }), reason: "exactly target" },
190
+ ],
191
+ expr: [
192
+ ...generic(VALID.expr[0].spec, ["F3", "F4", "F5"], "do not match operands", "do not match operands F999, F4"),
193
+ { name: "formula id mismatch", base: VALID.expr[0].spec, mutate: (row) => ({ ...row, formula: "F3 - F99" }), reason: "do not match operands" },
194
+ { name: "parse error", base: VALID.expr[0].spec, mutate: (row) => ({ ...row, formula: "F3 -" }), reason: "the expression ends early" },
195
+ { name: "divides by zero", base: VALID.expr[0].spec, mutate: (row) => ({ ...row, formula: "F3 / (F4 - F4)" }), reason: "the expression divides by zero" },
196
+ { name: "uninferable unit", base: VALID.expr[0].spec, mutate: (row) => ({ ...row, formula: "F3 + F4" }), reason: "adding % values" },
197
+ { name: "affine sum", base: VALID.expr[0].spec, mutate: (row) => ({ ...row, formula: "F13 + F14", operands: ["F13", "F14"] }), reason: "adding affine (temperature)" },
198
+ { name: "stray context", base: VALID.expr[0].spec, mutate: (row) => ({ ...row, context: { target: "km" } }), reason: "expr rows carry no context" },
199
+ { name: "undated rate", base: VALID.expr[0].spec, mutate: (row, book) => {
200
+ const rate = book.rows.find((r) => r.fact_id === "F3")
201
+ if (!rate) throw Error("fixture rate missing")
202
+ rate.unit = "USD/EUR"
203
+ return { ...row, formula: "F3 * F4" }
204
+ }, reason: "dated valid_at" },
205
+ ],
206
+ }
207
+
208
+ /** Review r6 HIGH: one id twice is never disjoint shares; expr repeats F1 inside its formula and stores the id set. */
209
+ const REPEATED: Record<string, Spec> = {
210
+ sum: { ...PCT_SUM, operands: ["F3", "F3"] },
211
+ diff: { op: "diff", operands: ["F3", "F3"], unit: "%p" },
212
+ growth: { op: "growth", from: "F6", to: "F6", unit: "%" },
213
+ share: { op: "share", part: "F9", whole: "F9", unit: "%" },
214
+ ratio: { op: "ratio", num: "F10", den: "F10", unit: null },
215
+ convert: { op: "convert", operands: ["F12", "F12"], to: "km" },
216
+ expr: { op: "expr", formula: "F1 * F1 / F2", unit: "m" },
217
+ }
218
+
219
+ function rowOf(result: { row?: Row; error?: string; detail?: string }): Row {
220
+ if (!result.row) throw new Error(`expected a row, got ${result.error}: ${result.detail}`)
221
+ return result.row
222
+ }
223
+
224
+ /** The row relabeled to `unit`; when the operation persists a context, a second variant rewrites that context too. */
225
+ function relabel(row: Row, unit: string | null): Row[] {
226
+ const shown = display(row.value, unit)
227
+ const render = row.operation === "convert" ? `${shown} ${String(row.render).slice(String(row.render).indexOf("("))}` : shown
228
+ const plain = { ...row, unit, raw: render, render }
229
+ const context = row.context as Row
230
+ if (row.operation === "convert") return [plain, { ...plain, context: { ...context, target: unit } }]
231
+ if (row.operation === "sum") return [plain, { ...plain, context: { ...context, reference: `relabeled ${unit}` } }]
232
+ return [plain]
233
+ }
234
+
235
+ const OPS = Object.keys(OPERATION_RULES)
236
+
237
+ test("the case tables cover exactly the registry's operations, which are the ledger schema's operations", () => {
238
+ expect([...OPS].sort()).toEqual([...OPERATIONS].sort())
239
+ for (const table of [VALID, MALFORMED]) expect(Object.keys(table).sort()).toEqual([...OPS].sort())
240
+ })
241
+
242
+ describe.each(OPS)("%s", (op) => {
243
+ test("valid typed operands derive, and the stored row recomputes ok", () => {
244
+ for (const { spec, unit } of VALID[op]) {
245
+ const book = ledger()
246
+ const row = rowOf(derive(book, spec))
247
+ expect(row.unit).toBe(unit)
248
+ expect(recompute(book, row)).toMatchObject({ ok: true })
249
+ }
250
+ })
251
+
252
+ test("every other unit, alias, or null is a relabel: recompute names the expected unit and derive refuses it", () => {
253
+ for (const { spec, unit } of VALID[op]) {
254
+ const book = ledger()
255
+ const row = rowOf(derive(book, spec))
256
+ for (const other of RELABELS.filter((candidate) => candidate !== unit)) {
257
+ const [plain, ...withContext] = relabel(row, other)
258
+ const result = recompute(book, plain)
259
+ expect({ other, ok: result.ok }).toEqual({ other, ok: false })
260
+ expect(result.detail).toContain(`expected unit ${JSON.stringify(unit)}`)
261
+ for (const variant of withContext) {
262
+ // A rewritten convert target is ok only when it is exactly the row an honest derive to that target writes
263
+ // (0 m to cm is 0 cm); an alias target, or any other rewrite, is ok:false.
264
+ const honest = op === "convert" ? derive(ledger(), { ...spec, to: other }).row : undefined
265
+ const consistent = honest !== undefined && ["unit", "exact", "value", "render", "formula", "factor", "precision", "compatibility"].every((key) => JSON.stringify(honest[key]) === JSON.stringify(variant[key]))
266
+ const rewritten = recompute(book, variant)
267
+ expect({ other, context: variant.context, ok: rewritten.ok }).toEqual({ other, context: variant.context, ok: consistent })
268
+ }
269
+ const declared = derive(ledger(), { ...spec, unit: other })
270
+ if (other !== "bp" && canonicalUnit(other) === unit) expect(declared.row?.unit).toBe(unit)
271
+ else expect({ other, error: declared.error }).toEqual({ other, error: "incompatible_operands" })
272
+ }
273
+ }
274
+ })
275
+
276
+ test("render precision follows invariant 5: sum, diff, a +/- expr root, and %p by decimal places; the rest by significant figures", () => {
277
+ for (const { spec, unit, precision } of VALID[op]) {
278
+ const row = rowOf(derive(ledger(), spec))
279
+ const ast = op === "expr" ? parseExpr(spec.formula) : null
280
+ expect({ spec, precision: renderPrecision(op, row.unit, ast) }).toEqual({ spec, precision: precision ?? PRECISION[op] })
281
+ }
282
+ })
283
+
284
+ test("the malformed cases are exactly the registry's malformed shapes", () => {
285
+ expect(MALFORMED[op].map((mutation) => mutation.name).sort()).toEqual([...OPERATION_RULES[op].malformed].sort())
286
+ })
287
+
288
+ test("operand identity: derive refuses a repeated operand id; only expr may repeat an id, and only inside its formula", () => {
289
+ const spec = REPEATED[op]
290
+ const result = derive(ledger(), spec)
291
+ if (op === "expr") {
292
+ expect(rowOf(result).operands).toEqual(["F1", "F2"])
293
+ return
294
+ }
295
+ expect({ error: result.error, detail: result.detail }).toEqual({ error: "incompatible_operands", detail: expect.stringContaining("is repeated") })
296
+ })
297
+
298
+ test("each malformed shape is ok:false for its own reason and never throws", () => {
299
+ for (const { name, base, mutate, reason } of MALFORMED[op]) {
300
+ const book = ledger()
301
+ const row = rowOf(derive(book, base))
302
+ let result: { ok: boolean; detail?: string } = { ok: true }
303
+ expect(() => {
304
+ result = recompute(book, mutate(row, book))
305
+ }).not.toThrow()
306
+ expect({ name, ok: result.ok }).toEqual({ name, ok: false })
307
+ expect(result).toMatchObject({ shape: expect.any(String), expected: expect.anything() })
308
+ expect({ name, detail: result.detail }).toEqual({ name, detail: expect.stringContaining(reason) })
309
+ }
310
+ })
311
+ })
312
+
313
+ describe("units", () => {
314
+ test("aliases canonicalize at input and composites cancel", () => {
315
+ expect(canonicalUnit("pp")).toBe("%p")
316
+ expect(canonicalUnit("bp")).toBe("%p")
317
+ expect(canonicalUnit("kilometre")).toBe("km")
318
+ expect(canonicalUnit("USD/(USD/person)")).toBe("person")
319
+ expect(canonicalUnit("USD / kg")).toBe("USD/kg")
320
+ expect(canonicalUnit("m/m")).toBeNull()
321
+ expect(canonicalUnit(null)).toBeNull()
322
+ expect(isAffine("C")).toBe(true)
323
+ expect(isAffine("F")).toBe(true)
324
+ expect(isAffine("m")).toBe(false)
325
+ })
326
+
327
+ test("unit text with no registered alias is stored as given, inner whitespace kept", () => {
328
+ expect(canonicalUnit("USD / million people")).toBe("USD/million people")
329
+ for (const unit of ["million people", "metric ton", "USD per person"]) {
330
+ expect(canonicalUnit(` ${unit} `)).toBe(unit)
331
+ const book = emptyLedger() as Ledger
332
+ addRow(book, measured("F1", "5", unit))
333
+ addRow(book, measured("F2", "7", unit))
334
+ const row = rowOf(derive(book, { op: "sum", operands: ["F1", "F2"], unit }))
335
+ expect(row).toMatchObject({ unit, render: `12 ${unit}` })
336
+ expect(recompute(book, row)).toMatchObject({ ok: true })
337
+ }
338
+ })
339
+
340
+ test("review r6 LOW 2: a scale word is never a unit atom; plain unit text that contains one is stored as given", () => {
341
+ for (const unit of ["million", "M", "bn", "billion", "e6", "thousand", "USD/M", "million*person"]) {
342
+ expect(() => canonicalUnit(unit)).toThrow("scale word")
343
+ const book = emptyLedger() as Ledger
344
+ expect(() => addRow(book, measured("F1", "5", unit))).toThrow("scale word")
345
+ addRow(book, measured("F1", "5", "m"))
346
+ addRow(book, measured("F2", "7", "m"))
347
+ book.rows[0].unit = unit
348
+ const result = derive(book, { op: "sum", operands: ["F1", "F2"], unit: "m" })
349
+ expect({ unit, error: result.error, detail: result.detail }).toEqual({ unit, error: "incompatible_operands", detail: expect.stringContaining("scale word") })
350
+ }
351
+ expect(canonicalUnit("million people")).toBe("million people")
352
+ })
353
+
354
+ test("expectedUnit is the registry's answer for stored operand rows", () => {
355
+ const book = ledger()
356
+ const rows = (ids: string[]) => ids.map((id) => book.rows.find((row) => row.fact_id === id) as Row)
357
+ expect(expectedUnit("diff", rows(["F3", "F4"]), {})).toBe("%p")
358
+ expect(expectedUnit("ratio", rows(["F10", "F11"]), {})).toBe("person")
359
+ expect(expectedUnit("convert", rows(["F12"]), { context: { target: "km" } })).toBe("km")
360
+ })
361
+
362
+ test("nested derived operands are verified recursively; a cycle is refused", () => {
363
+ const book = ledger()
364
+ const inner = rowOf(derive(book, { op: "sum", operands: ["F1", "F2"], unit: "m" }))
365
+ const outer = rowOf(derive(book, { op: "convert", operands: [inner.fact_id as string], to: "km" }))
366
+ expect(recompute(book, outer).ok).toBe(true)
367
+ inner.exact = { num: "5", den: "1" }
368
+ const tampered = recompute(book, outer)
369
+ expect(tampered.ok).toBe(false)
370
+ expect(tampered.detail).toContain(String(inner.fact_id))
371
+ const a = { ...inner, fact_id: "F90", label: "cycle-a", operands: ["F91", "F1"], exact: { num: "4", den: "1" } }
372
+ const b = { ...inner, fact_id: "F91", label: "cycle-b", operands: ["F90", "F1"], exact: { num: "4", den: "1" } }
373
+ book.rows.push(a, b)
374
+ let cycle: { ok: boolean; detail?: string } = { ok: true }
375
+ expect(() => {
376
+ cycle = recompute(book, a)
377
+ }).not.toThrow()
378
+ expect(cycle.ok).toBe(false)
379
+ expect(cycle.detail).toContain("cycle")
380
+ const through = derive(book, { op: "sum", operands: ["F90", "F2"], unit: "m" })
381
+ expect({ error: through.error, detail: through.detail }).toEqual({ error: "incompatible_operands", detail: expect.stringContaining("cycle") })
382
+ const self = { ...inner, fact_id: "F92", label: "self", operands: ["F92", "F1"] }
383
+ book.rows.push(self)
384
+ expect(recompute(book, self)).toMatchObject({ ok: false, detail: expect.stringContaining("cites itself") })
385
+ const onSelf = derive(book, { op: "convert", operands: ["F92"], to: "km" })
386
+ expect({ error: onSelf.error, detail: onSelf.detail }).toEqual({ error: "incompatible_operands", detail: expect.stringContaining("cites itself") })
387
+ })
388
+ })
@@ -0,0 +1,130 @@
1
+ // fidelity-binding.mjs - sentence binding and claim-status checks of the fidelity gate.
2
+ //
3
+ // proseSentences walks every leaf prose element (p, li) outside figures, tables and page chrome,
4
+ // keeps the offsets of each data-fact element inside its text, and splits it into sentences, so
5
+ // every check sees which ledger rows a sentence cites. bindingChecks raises unbound_sentence (a
6
+ // findings sentence with no fact, placeholder, citation, or data-claim), claim_status_invalid (a
7
+ // data-claim id that is unknown, refuted or unresolved outside a Gaps or Contradictions section,
8
+ // or high-risk and missing from verified-claims), and conflicting_readings (two active rows with
9
+ // one value and unit but different periods or qualifiers cited in one sentence). Plain Node ESM.
10
+
11
+ import { proseText } from "./html-lite.mjs"
12
+ import { fromDecimal, eq } from "./rational.mjs"
13
+ import { splitSentences } from "./sentence-split.mjs"
14
+ import { normalizeUnit } from "./units.mjs"
15
+
16
+ const SKIP = new Set(["code", "pre", "kbd", "samp", "script", "style", "svg", "template", "noscript"])
17
+ const CONTAINERS = new Set(["figure", "table", "nav", "header", "footer"])
18
+ /** data-kind values whose sentences need no binding (opinion and recommendation blocks). */
19
+ export const EXEMPT_KINDS = Object.freeze(["opinion", "recommendation"])
20
+ export const isExemptKind = (kind) => EXEMPT_KINDS.includes(kind)
21
+ const DECIMAL = /^-?\d+(?:\.\d+)?$/
22
+ /** Gaps and Contradictions headings (English, and the Korean gap/limit/contradiction/conflict words). */
23
+ const GAPS_HEADING = /\b(?:gaps?|contradictions?|unresolved)\b|\uACF5\uBC31|\uD55C\uACC4|\uBAA8\uC21C|\uC0C1\uCDA9/i
24
+
25
+ /** Canonical unit token, or the raw unit without spaces, lowercased; "" for none. */
26
+ export function unitKey(unit) {
27
+ if (unit === null || unit === undefined) return ""
28
+ return normalizeUnit(String(unit)) ?? String(unit).replace(/\s+/g, "").toLowerCase()
29
+ }
30
+
31
+ /** Exact decimal equality; false when either side is not a plain decimal. */
32
+ export function sameValue(a, b) {
33
+ const [x, y] = [String(a ?? "").replace(/,/g, ""), String(b ?? "").replace(/,/g, "")]
34
+ return DECIMAL.test(x) && DECIMAL.test(y) && eq(fromDecimal(x), fromDecimal(y))
35
+ }
36
+
37
+ function serialize(node, out) {
38
+ for (const child of node.children) {
39
+ if (child.tag === "#text") out.text += child.text
40
+ else if (child.tag === "br") out.text += " "
41
+ else if (SKIP.has(child.tag)) continue
42
+ else if (child.attrs["data-fact"] !== undefined) {
43
+ const text = proseText(child)
44
+ out.facts.push({ id: child.attrs["data-fact"], start: out.text.length, end: out.text.length + text.length, text, node: child })
45
+ out.text += text
46
+ } else serialize(child, out)
47
+ }
48
+ return out
49
+ }
50
+
51
+ /**
52
+ * Leaf prose elements with their sentences and the facts each sentence cites.
53
+ * @returns {{ owner: object, scope: object, kind: string|null, sentences: { text: string, start: number, end: number, facts: object[] }[] }[]}
54
+ */
55
+ export function proseSentences(ctx) {
56
+ const { closest, descendants } = ctx.util
57
+ const blocks = []
58
+ for (const { node, scope } of ctx.elements) {
59
+ if (node.tag !== "p" && node.tag !== "li") continue
60
+ if (node.tag === "li" && descendants(node).some((child) => child.tag === "p" || child.tag === "li")) continue
61
+ if (closest(node, (cur) => CONTAINERS.has(cur.tag))) continue
62
+ const { text, facts } = serialize(node, { text: "", facts: [] })
63
+ const sentences = splitSentences(text).map((s) => ({ ...s, facts: facts.filter((f) => f.start < s.end && f.end > s.start) }))
64
+ blocks.push({ owner: node, scope, kind: ctx.kindOf(scope), sentences })
65
+ }
66
+ return blocks
67
+ }
68
+
69
+ function claimList(claims) {
70
+ const list = Array.isArray(claims) ? claims : (claims?.claims ?? [])
71
+ return new Map(list.map((claim) => [claim.claim_id, claim]))
72
+ }
73
+
74
+ const selfOrUp = (ctx, node, test) => test(node) || Boolean(ctx.util.closest(node, test))
75
+
76
+ function claimDefects(ctx, claims) {
77
+ const byId = claimList(claims)
78
+ const out = []
79
+ for (const { node, scope } of ctx.elements) {
80
+ const attr = node.attrs["data-claim"]
81
+ if (attr === undefined) continue
82
+ const heading = scope.section?.heading
83
+ const inGaps = Boolean(heading) && GAPS_HEADING.test(proseText(heading))
84
+ for (const id of attr.split(/[\s,]+/).filter(Boolean)) {
85
+ const claim = byId.get(id)
86
+ let why = null
87
+ if (!claim) why = `data-claim ${id} is not in the claim index`
88
+ else if (/refuted|unresolved/i.test(claim.status) && !inGaps) why = `claim ${id} is ${claim.status} but asserted outside Gaps or Contradictions`
89
+ else if (!inGaps && /high/i.test(claim.risk) && !claim.in_verified) why = `high-risk claim ${id} is not in verified-claims`
90
+ if (why) out.push(ctx.defect("claim_status_invalid", node, why))
91
+ }
92
+ }
93
+ return out
94
+ }
95
+
96
+ function conflict(rows, facts) {
97
+ const cited = [...new Set(facts.map((f) => f.id))].map((id) => rows.get(id)).filter((row) => row && row.status === "active" && row.kind !== "quote")
98
+ for (let i = 0; i < cited.length; i++) {
99
+ for (let k = i + 1; k < cited.length; k++) {
100
+ const [a, b] = [cited[i], cited[k]]
101
+ if (!sameValue(a.value, b.value) || unitKey(a.unit) !== unitKey(b.unit)) continue
102
+ if (a.valid_at !== b.valid_at || a.qualifier !== b.qualifier) return [a, b]
103
+ }
104
+ }
105
+ return null
106
+ }
107
+
108
+ /**
109
+ * @param {object} ctx buildContext(...) from gates-static.mjs
110
+ * @param {{ claims: object[] } | object[] | null} claims indexClaims(...) output or its claims array
111
+ * @param {{ rows: object[] } | null} ledger
112
+ */
113
+ export function bindingChecks(ctx, claims, ledger) {
114
+ const rows = new Map((ledger?.rows ?? []).map((row) => [row.fact_id, row]))
115
+ const out = []
116
+ for (const block of proseSentences(ctx)) {
117
+ const findings = Boolean(block.scope.section) && block.kind === null
118
+ const exempt = selfOrUp(ctx, block.owner, (cur) => isExemptKind(cur.attrs["data-kind"]) || cur.attrs["data-claim"] !== undefined)
119
+ for (const sentence of block.sentences) {
120
+ const bound = sentence.facts.length > 0 || ctx.util.citationMarker.test(sentence.text) || sentence.text.includes("{{")
121
+ if (findings && !exempt && !bound) out.push(ctx.defect("unbound_sentence", block.owner, `no fact, citation, or data-claim binds: "${sentence.text.slice(0, 80)}"`))
122
+ const pair = conflict(rows, sentence.facts)
123
+ if (pair) {
124
+ const [a, b] = pair
125
+ out.push(ctx.defect("conflicting_readings", block.owner, `${a.fact_id} (${a.valid_at ?? "no period"}) and ${b.fact_id} (${b.valid_at ?? "no period"}) share ${a.value} ${a.unit ?? ""} in one sentence`))
126
+ }
127
+ }
128
+ }
129
+ return [...out, ...claimDefects(ctx, claims)]
130
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { readFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+
5
+ import { indexClaims } from "./claims-index.mjs"
6
+ import { bindingChecks } from "./fidelity-binding.mjs"
7
+ import { buildContext } from "./gates-static.mjs"
8
+ import { parseHtml } from "./html-lite.mjs"
9
+
10
+ type Defect = { code: string; message: string }
11
+
12
+ const FIX = join(import.meta.dir, "tests", "fixtures", "fidelity")
13
+ const LEDGER = JSON.parse(readFileSync(join(FIX, "ledger-verify.json"), "utf8"))
14
+ const CLAIMS = indexClaims(readFileSync(join(FIX, "claim-graph.md"), "utf8"))
15
+
16
+ function run(body: string, claims: unknown = CLAIMS): Defect[] {
17
+ const html = /<h2/.test(body) ? body : `<h2>Findings</h2>\n${body}`
18
+ return bindingChecks(buildContext(parseHtml(html), {}), claims, LEDGER) as Defect[]
19
+ }
20
+
21
+ const codes = (defects: Defect[]) => defects.map((defect) => defect.code)
22
+ const fact = (id: string, text: string) => `<data class="fact" data-fact="${id}">${text}</data>`
23
+
24
+ describe("sentence binding", () => {
25
+ test("#given a findings paragraph with no marker #when checked #then unbound_sentence", () => {
26
+ expect(codes(run("<p>The bridge is popular with tourists.</p>"))).toEqual(["unbound_sentence"])
27
+ })
28
+
29
+ test("#given the same paragraph marked as opinion #when checked #then clean", () => {
30
+ expect(run('<p data-kind="opinion">The bridge is popular with tourists.</p>')).toEqual([])
31
+ })
32
+
33
+ test("#given a citation, a fact, or a claim on each sentence #when checked #then clean", () => {
34
+ const body = `<p>Tourism grew [S4]. Units reached ${fact("F2", "1,237")}.</p><p data-claim="C1">The design follows the standard.</p>`
35
+ expect(run(body)).toEqual([])
36
+ })
37
+
38
+ test("#given one unbound sentence after a cited one #when checked #then only that sentence is flagged", () => {
39
+ expect(codes(run("<p>Tourism grew [S4]. Everyone loves it.</p>"))).toEqual(["unbound_sentence"])
40
+ })
41
+
42
+ test("#given an unbound paragraph outside findings (appendix, closing, sources) #when checked #then out of scope", () => {
43
+ const body = "<h2>Appendix</h2><p>Plain words.</p><h2>How this report was made</h2><p>Plain words.</p><h2>Sources</h2><p>Plain words.</p>"
44
+ expect(run(body)).toEqual([])
45
+ })
46
+ })
47
+
48
+ describe("claim status", () => {
49
+ test("#given a data-claim id that is not in the claim index #when checked #then claim_status_invalid", () => {
50
+ expect(codes(run('<p data-claim="C99">The design is the safest.</p>'))).toEqual(["claim_status_invalid"])
51
+ })
52
+
53
+ test("#given a refuted claim cited in findings #when checked #then claim_status_invalid", () => {
54
+ expect(codes(run('<p data-claim="C3">The project finished early.</p>'))).toEqual(["claim_status_invalid"])
55
+ })
56
+
57
+ test("#given the refuted claim in a Contradictions section #when checked #then clean", () => {
58
+ expect(run('<h2>Contradictions</h2><p data-claim="C3">The project finished early.</p>')).toEqual([])
59
+ })
60
+
61
+ test("#given a normal-risk partial claim not in verified-claims #when checked #then clean", () => {
62
+ expect(run('<p data-claim="C2">Revenue grew about ten percent.</p>')).toEqual([])
63
+ })
64
+
65
+ test("#given a high-risk claim missing from verified-claims #when checked #then claim_status_invalid", () => {
66
+ const claims = { claims: [{ claim_id: "C7", statement: "x", risk: "high", status: "supported", in_verified: false }], errors: [] }
67
+ expect(codes(run('<p data-claim="C7">A strong statement.</p>', claims))).toEqual(["claim_status_invalid"])
68
+ })
69
+ })
70
+
71
+ describe("conflicting readings", () => {
72
+ test("#given two 12 % rows with YoY and QoQ periods in one sentence #when checked #then conflicting_readings", () => {
73
+ const body = `<p>Revenue grew ${fact("F3", "12%")} and ${fact("F4", "12%")} [S4].</p>`
74
+ expect(codes(run(body))).toEqual(["conflicting_readings"])
75
+ })
76
+
77
+ test("#given the two readings in separate sentences #when checked #then clean", () => {
78
+ const body = `<p>Revenue grew ${fact("F3", "12%")} [S4]. Quarterly growth was ${fact("F4", "12%")} [S4].</p>`
79
+ expect(run(body)).toEqual([])
80
+ })
81
+ })
@@ -109,6 +109,11 @@ export function isLocked(parsed) {
109
109
  return FIDELITY_FIELDS.every((field) => Object.hasOwn(parsed.fields, field) && LOCKED_ANSWERED_BY.has(parsed.answered_by[field]))
110
110
  }
111
111
 
112
+ /** The brief's `meaning check` field turns the meaning verifier off only when it reads "off". */
113
+ export function meaningCheckEnabled(parsed) {
114
+ return !/^off\b/iu.test(parsed?.fields?.["meaning check"] ?? "")
115
+ }
116
+
112
117
  function incompleteDefect(briefPath, parsed) {
113
118
  const reasons = []
114
119
  if (!parsed.present) reasons.push("no ## Fidelity block")