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.
- package/README.de.md +0 -5
- package/README.es.md +0 -5
- package/README.fr.md +0 -5
- package/README.it.md +0 -5
- package/README.ja.md +0 -5
- package/README.ko.md +0 -5
- package/README.md +4 -5
- package/README.pt-br.md +0 -5
- package/README.ru.md +0 -6
- package/README.zh-cn.md +0 -5
- package/dist/cli/index.js +76 -156
- package/dist/cli-node/index.js +76 -156
- package/package.json +1 -1
- package/packages/lsp-daemon/dist/cli.js +3 -3
- package/packages/lsp-daemon/dist/client.js +3 -3
- package/packages/lsp-daemon/dist/index.js +3 -3
- package/packages/lsp-tools-mcp/dist/cli.js +3 -3
- package/packages/lsp-tools-mcp/dist/mcp.js +3 -3
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +22 -12
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/package-lock.json +12 -12
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/scripts/sync-skills.mjs +8 -3
- package/packages/omo-codex/plugin/shared/test/config-loader.test.ts +0 -2
- package/packages/omo-codex/plugin/skills/ast-grep/AGENTS.md +1 -1
- package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
- package/packages/omo-codex/plugin/skills/frontend/ATTRIBUTION.md +2 -2
- package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/AGENTS.md +1 -1
- package/packages/omo-codex/plugin/skills/ulw-research/AGENTS.md +14 -2
- package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +36 -11
- package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +73 -13
- package/packages/omo-codex/plugin/skills/ulw-research/references/fact-ledger-grammar.md +19 -1
- package/packages/omo-codex/plugin/skills/ulw-research/references/fact-ledger.md +95 -0
- package/packages/omo-codex/plugin/skills/ulw-research/references/fidelity-claim-kinds.md +19 -0
- package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +21 -4
- package/packages/omo-codex/plugin/skills/ulw-research/references/report-visualization.md +2 -0
- package/packages/omo-codex/plugin/skills/ulw-research/references/unit-rules.md +157 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/claim-kinds.mjs +116 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/cli-support.mjs +19 -5
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +27 -6
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-meaning.mjs +191 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-verify.mjs +164 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands.mjs +239 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-derive.mjs +66 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-expr.mjs +108 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-fill.mjs +194 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-ledger-kinds.mjs +127 -13
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-ledger-schema.mjs +33 -16
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-ledger.mjs +29 -9
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-locate.mjs +231 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-pairs.mjs +187 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-recompute.mjs +89 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-binding.mjs +130 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-brief.mjs +5 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-claims.mjs +89 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-lexicon.mjs +145 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-records.mjs +163 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-state.mjs +2 -1
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-trace.mjs +85 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-values.mjs +229 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-fidelity.mjs +113 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-static.mjs +28 -8
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-lite.mjs +2 -2
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-markup.mjs +182 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/md-lite.mjs +193 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/meaning-check.mjs +196 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/outcome.mjs +6 -5
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/placeholder.mjs +184 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/polish-apply.mjs +115 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/polish-diff.mjs +31 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/polish-project.mjs +125 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/polish-rules.mjs +170 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools-commands.mjs +19 -8
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools.mjs +15 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/source-commands.mjs +140 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/source-snapshot.mjs +176 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-algebra.mjs +101 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-errors.mjs +23 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-metadata.mjs +31 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-pairs.mjs +72 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-precision.mjs +44 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-rules-shapes.mjs +64 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-rules.mjs +173 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/units.mjs +4 -4
- package/packages/omo-codex/plugin/test/sync-skills-codex-compatibility.test.mjs +19 -0
- package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +7 -3
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +26 -16
- package/packages/shared-skills/skills/ast-grep/AGENTS.md +1 -1
- package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +1 -1
- package/packages/shared-skills/skills/frontend/ATTRIBUTION.md +2 -2
- package/packages/shared-skills/skills/ultimate-browsing/engine/AGENTS.md +1 -1
- package/packages/shared-skills/skills/ulw-research/AGENTS.md +14 -2
- package/packages/shared-skills/skills/ulw-research/SKILL.md +37 -12
- package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +73 -13
- package/packages/shared-skills/skills/ulw-research/references/fact-ledger-grammar.md +19 -1
- package/packages/shared-skills/skills/ulw-research/references/fact-ledger.md +95 -0
- package/packages/shared-skills/skills/ulw-research/references/fidelity-claim-kinds.md +19 -0
- package/packages/shared-skills/skills/ulw-research/references/report-gates.md +21 -4
- package/packages/shared-skills/skills/ulw-research/references/report-visualization.md +2 -0
- package/packages/shared-skills/skills/ulw-research/references/unit-rules.md +157 -0
- package/packages/shared-skills/skills/ulw-research/scripts/claim-kinds.mjs +116 -0
- package/packages/shared-skills/skills/ulw-research/scripts/claim-kinds.test.ts +193 -0
- package/packages/shared-skills/skills/ulw-research/scripts/cli-support.mjs +19 -5
- package/packages/shared-skills/skills/ulw-research/scripts/cli-support.test.ts +13 -2
- package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +27 -6
- package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +3 -0
- package/packages/shared-skills/skills/ulw-research/scripts/copy-returns.test.ts +183 -0
- package/packages/shared-skills/skills/ulw-research/scripts/delivery-barrier.test.ts +245 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-meaning.mjs +191 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-verify.mjs +164 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-commands.mjs +239 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-derive.mjs +66 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-derive.test.ts +375 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-expr.mjs +108 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-fill.mjs +194 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-fill.test.ts +221 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-grammar.test.ts +9 -2
- package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-kinds.mjs +127 -13
- package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-kinds.test.ts +18 -3
- package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-legacy.test.ts +160 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-schema.mjs +33 -16
- package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger.mjs +29 -9
- package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger.test.ts +2 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-locate.mjs +231 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-locate.test.ts +118 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-pairs.mjs +187 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-pairs.test.ts +167 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-recompute.mjs +89 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-unit-dimensions.test.ts +409 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-unit-rules.test.ts +388 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fidelity-binding.mjs +130 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fidelity-binding.test.ts +81 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fidelity-brief.mjs +5 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fidelity-claims.mjs +89 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fidelity-lexicon.mjs +145 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fidelity-lexicon.test.ts +117 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fidelity-records.mjs +163 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fidelity-state.mjs +2 -1
- package/packages/shared-skills/skills/ulw-research/scripts/fidelity-trace.mjs +85 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fidelity-trace.test.ts +58 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fidelity-values.mjs +229 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fidelity-values.test.ts +270 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-fidelity.mjs +113 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-fidelity.test.ts +73 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-static.mjs +28 -8
- package/packages/shared-skills/skills/ulw-research/scripts/html-lite.mjs +2 -2
- package/packages/shared-skills/skills/ulw-research/scripts/html-markup.mjs +182 -0
- package/packages/shared-skills/skills/ulw-research/scripts/md-lite.mjs +193 -0
- package/packages/shared-skills/skills/ulw-research/scripts/md-lite.test.ts +271 -0
- package/packages/shared-skills/skills/ulw-research/scripts/meaning-check.mjs +196 -0
- package/packages/shared-skills/skills/ulw-research/scripts/meaning-check.test.ts +228 -0
- package/packages/shared-skills/skills/ulw-research/scripts/outcome.mjs +6 -5
- package/packages/shared-skills/skills/ulw-research/scripts/placeholder.mjs +184 -0
- package/packages/shared-skills/skills/ulw-research/scripts/placeholder.test.ts +77 -0
- package/packages/shared-skills/skills/ulw-research/scripts/polish-apply.mjs +115 -0
- package/packages/shared-skills/skills/ulw-research/scripts/polish-apply.test.ts +363 -0
- package/packages/shared-skills/skills/ulw-research/scripts/polish-diff.mjs +31 -0
- package/packages/shared-skills/skills/ulw-research/scripts/polish-diff.test.ts +168 -0
- package/packages/shared-skills/skills/ulw-research/scripts/polish-markup.test.ts +187 -0
- package/packages/shared-skills/skills/ulw-research/scripts/polish-project.mjs +125 -0
- package/packages/shared-skills/skills/ulw-research/scripts/polish-rules.mjs +170 -0
- package/packages/shared-skills/skills/ulw-research/scripts/report-tools-commands.mjs +19 -8
- package/packages/shared-skills/skills/ulw-research/scripts/report-tools.mjs +15 -0
- package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +458 -2
- package/packages/shared-skills/skills/ulw-research/scripts/source-commands.mjs +140 -0
- package/packages/shared-skills/skills/ulw-research/scripts/source-snapshot.mjs +176 -0
- package/packages/shared-skills/skills/ulw-research/scripts/source-snapshot.test.ts +113 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/assets/share.csv +3 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/gen-legacy-dev.mjs +89 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/ledger-basic.json +223 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/ledger-legacy-dev.json +859 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/ledger-verify.json +420 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/report-bad.html +19 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/report-good.html +39 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4/acronyms.json +3 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4/meta.json +9 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4/sentences.jsonl +7 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4/text.txt +7 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4.html +19 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S5/acronyms.json +1 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S5/meta.json +9 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S5/sentences.jsonl +1 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S5/text.txt +1 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S9.txt +1 -0
- package/packages/shared-skills/skills/ulw-research/scripts/unit-algebra.mjs +101 -0
- package/packages/shared-skills/skills/ulw-research/scripts/unit-errors.mjs +23 -0
- package/packages/shared-skills/skills/ulw-research/scripts/unit-metadata.mjs +31 -0
- package/packages/shared-skills/skills/ulw-research/scripts/unit-pairs.mjs +72 -0
- package/packages/shared-skills/skills/ulw-research/scripts/unit-precision.mjs +44 -0
- package/packages/shared-skills/skills/ulw-research/scripts/unit-rules-shapes.mjs +64 -0
- package/packages/shared-skills/skills/ulw-research/scripts/unit-rules.mjs +173 -0
- package/packages/shared-skills/skills/ulw-research/scripts/units.mjs +4 -4
|
@@ -31,7 +31,7 @@ function decimal(random: Random) {
|
|
|
31
31
|
const whole = `${pick(random, ["-", ""])}${int(random, 5000)}`
|
|
32
32
|
return random() < 0.5 ? whole : `${whole}.${String(int(random, 1000)).padStart(pick(random, [1, 2, 3]), "0").slice(0, 3)}`
|
|
33
33
|
}
|
|
34
|
-
const rational = (random: Random) => (
|
|
34
|
+
const rational = (random: Random) => exactFromDecimal(decimal(random))
|
|
35
35
|
const locator = (random: Random) => ({ artifact: `sources/S${1 + int(random, 9)}/text.txt`, char_start: 3, char_end: 3 + int(random, 80) })
|
|
36
36
|
const sha = (random: Random) => Array.from({ length: 64 }, () => "0123456789abcdef"[int(random, 16)]).join("")
|
|
37
37
|
|
|
@@ -60,11 +60,17 @@ const GENERATORS: Record<string, Gen> = {
|
|
|
60
60
|
},
|
|
61
61
|
},
|
|
62
62
|
rational_or_null: { valid: (r) => pick(r, [null, rational(r)]), invalid: (r) => pick(r, [{ num: "1", den: "0" }, { num: 1, den: "2" }, "1/2"]) },
|
|
63
|
-
by_derive: { valid:
|
|
63
|
+
by_derive: { valid: rational, invalid: (r) => pick(r, [null, { num: "1", den: "0" }, { num: "x", den: "2" }, "1/2"]) },
|
|
64
|
+
context_by_derive: { valid: (_r, row) => row.kind === "converted" ? { target: "km" } : null, invalid: (r) => pick(r, ["km", 3, ["km"], false]) },
|
|
65
|
+
derived_operation: { valid: () => "expr", invalid: (r) => pick(r, [null, "convert", "mean"]) },
|
|
66
|
+
convert_operation: { valid: () => "convert", invalid: (r) => pick(r, [null, "sum", "mean"]) },
|
|
67
|
+
precision: { valid: () => ({ places: 2, sigfigs: 3 }), invalid: (r) => pick(r, [null, { places: -1, sigfigs: 3 }, { places: 2, sigfigs: 0 }]) },
|
|
68
|
+
compatibility: { valid: (_r, row) => row.operation === "expr" ? "unchecked" : "checked", invalid: () => "invalid" },
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
function baseRow(kind: string): Row {
|
|
67
72
|
const row: Record<string, unknown> = { ...structuredClone(ROW_DEFAULTS), kind, raw: "raw", quote: "quoted text", extracted_by: "fact add", observed_at: "2026-10-23T08:00:00Z", label: "x" }
|
|
73
|
+
if (kind === "derived" || kind === "converted") Object.assign(row, { operands: ["F1000"], derived_from: ["F1000"], formula: "F1000", render: "raw" })
|
|
68
74
|
return row as Row
|
|
69
75
|
}
|
|
70
76
|
|
|
@@ -75,7 +81,7 @@ function validRow(kind: string, random: Random) {
|
|
|
75
81
|
return row
|
|
76
82
|
}
|
|
77
83
|
|
|
78
|
-
const ledgerOf = (row: object) => ({ schemaVersion: LEDGER_SCHEMA_VERSION, rows: [row], events: [] })
|
|
84
|
+
const ledgerOf = (row: object) => ({ schemaVersion: LEDGER_SCHEMA_VERSION, rows: [row, { ...validRow("measured", prng(42)), fact_id: "F1000", label: "dependency" }], events: [] })
|
|
79
85
|
const SEEDS = Array.from({ length: 60 }, (_, index) => index + 1)
|
|
80
86
|
|
|
81
87
|
describe("KIND_RULES covers the grammar", () => {
|
|
@@ -116,6 +122,15 @@ describe("rows generated from KIND_RULES", () => {
|
|
|
116
122
|
expect(validateLedger(ledgerOf({ ...row, exact: { num: "-1", den: "2" } }))).toEqual([])
|
|
117
123
|
})
|
|
118
124
|
|
|
125
|
+
test("review r6 LOW 3: context is a derived or converted column; a measured, quote, or assumed row carrying one is rejected", () => {
|
|
126
|
+
for (const kind of ["measured", "quote", "assumed"]) {
|
|
127
|
+
const row = { ...validRow(kind, prng(3)), context: { target: "km" } }
|
|
128
|
+
expect({ kind, named: validateLedger(ledgerOf(row)).some((error: string) => error.startsWith("rows[0].context")) }).toEqual({ kind, named: true })
|
|
129
|
+
}
|
|
130
|
+
expect(validateLedger(ledgerOf(validRow("converted", prng(4))))).toEqual([])
|
|
131
|
+
expect(validateLedger(ledgerOf({ ...validRow("derived", prng(4)), context: { target: "km" } })).length).toBeGreaterThan(0)
|
|
132
|
+
})
|
|
133
|
+
|
|
119
134
|
test("review LOW 1: a quote row with a non-null exact is rejected", () => {
|
|
120
135
|
const row = { ...validRow("quote", prng(2)), exact: { num: "1", den: "1" } }
|
|
121
136
|
expect(validateLedger(ledgerOf(row)).some((error: string) => error.startsWith("rows[0].exact"))).toBe(true)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, expect, onTestFinished, test } from "bun:test"
|
|
2
|
+
import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } 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 { LEDGER_FILENAME, addRow, emptyLedger, readLedger, validateLedger, writeLedger } from "./fact-ledger.mjs"
|
|
8
|
+
|
|
9
|
+
// Generated by dev's own addRow (tests/fixtures/fidelity/gen-legacy-dev.mjs pins the dev commit):
|
|
10
|
+
// no context/precision/compatibility columns, alias units, unreduced and zero-padded exacts.
|
|
11
|
+
const FIXTURE = join(import.meta.dir, "tests", "fixtures", "fidelity", "ledger-legacy-dev.json")
|
|
12
|
+
const SOURCE_IDS = ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "Q1", "F9", "F10", "F11", "F12", "F13", "F16", "F17", "F18"]
|
|
13
|
+
|
|
14
|
+
type Row = Record<string, unknown>
|
|
15
|
+
type Ledger = { schemaVersion: number; rows: Row[]; events: unknown[] }
|
|
16
|
+
|
|
17
|
+
function tempDir(): string {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), "ulw-legacy-"))
|
|
19
|
+
onTestFinished(() => rmSync(dir, { recursive: true, force: true }))
|
|
20
|
+
return dir
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const fixture = (): Ledger => JSON.parse(readFileSync(FIXTURE, "utf8"))
|
|
24
|
+
const rowOf = (ledger: Ledger, id: string): Row => ledger.rows.find((row) => row.fact_id === id) as Row
|
|
25
|
+
|
|
26
|
+
function loaded(): { dir: string; ledger: Ledger } {
|
|
27
|
+
const dir = tempDir()
|
|
28
|
+
copyFileSync(FIXTURE, join(dir, LEDGER_FILENAME))
|
|
29
|
+
return { dir, ledger: readLedger(dir) as Ledger }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("ledgers written by dev's writer (dimension 31 wins at read time)", () => {
|
|
33
|
+
test("#given dev's generated ledger #when validated #then it has no errors", () => {
|
|
34
|
+
expect(validateLedger(fixture())).toEqual([])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("#given dev's ledger #when read #then exacts, decimals, and source units are normalized", () => {
|
|
38
|
+
const { ledger } = loaded()
|
|
39
|
+
expect(["F1", "F3", "F4", "F5", "F6", "F8", "F11"].map((id) => rowOf(ledger, id).unit)).toEqual(["km", "%", "%p", "person", "KRW-e3", "%", "km"])
|
|
40
|
+
const exacts = Object.fromEntries(["F10", "F11", "F12", "F13", "F14", "F15", "F18"].map((id) => [id, rowOf(ledger, id).exact]))
|
|
41
|
+
expect(exacts).toEqual({ F10: { num: "1", den: "1" }, F11: { num: "5", den: "2" }, F12: { num: "7", den: "1" }, F13: { num: "-5", den: "2" }, F14: { num: "41", den: "2" }, F15: { num: "12000", den: "1" }, F18: { num: "3", den: "1", note: "from the brief" } })
|
|
42
|
+
expect(rowOf(ledger, "F7").value).toBe("7")
|
|
43
|
+
for (const id of SOURCE_IDS) {
|
|
44
|
+
for (const column of ["context", "precision", "compatibility"]) expect(rowOf(ledger, id)[column]).toBeNull()
|
|
45
|
+
}
|
|
46
|
+
for (const id of ["F14", "F15"]) expect(Object.hasOwn(rowOf(ledger, id), "precision")).toBe(false)
|
|
47
|
+
expect(validateLedger(ledger)).toEqual([])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("#given dev's ledger #when measured rows are summed #then the new row is canonical and recomputes", () => {
|
|
51
|
+
for (const ledger of [fixture(), loaded().ledger]) {
|
|
52
|
+
const result = derive(ledger, { op: "sum", operands: ["F1", "F2"], unit: "km" })
|
|
53
|
+
expect(result.error).toBeUndefined()
|
|
54
|
+
expect(result.row).toMatchObject({ kind: "derived", unit: "km", value: "21", exact: { num: "41", den: "2" }, compatibility: "checked" })
|
|
55
|
+
const padded = derive(ledger, { op: "sum", operands: ["F1", "F7"], unit: "km" })
|
|
56
|
+
expect(padded.row).toMatchObject({ value: "19", exact: { num: "19", den: "1" } })
|
|
57
|
+
expect(validateLedger(ledger)).toEqual([])
|
|
58
|
+
expect(recompute(ledger, result.row).ok).toBe(true)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("#given a legacy derived or converted row #when recomputed #then it reports a legacy derived row", () => {
|
|
63
|
+
for (const ledger of [fixture(), loaded().ledger]) {
|
|
64
|
+
for (const id of ["F14", "F15"]) {
|
|
65
|
+
const result = recompute(ledger, rowOf(ledger, id))
|
|
66
|
+
expect(result.ok).toBe(false)
|
|
67
|
+
expect(result.shape).toBe("legacy derived row")
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("#given a legacy derived or converted row #when used as an operand #then derive refuses it", () => {
|
|
73
|
+
const ledger = fixture()
|
|
74
|
+
const before = ledger.rows.length
|
|
75
|
+
for (const id of ["F14", "F15"]) {
|
|
76
|
+
const result = derive(ledger, { op: "sum", operands: [id, "F2"], unit: "km" })
|
|
77
|
+
expect(result.error).toBe("incompatible_operands")
|
|
78
|
+
expect(result.shape).toBe("legacy derived row")
|
|
79
|
+
}
|
|
80
|
+
expect(ledger.rows.length).toBe(before)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("#given a loadable row the strict dimensions reject #when used as an operand #then it is flagged by dimension", () => {
|
|
84
|
+
const ledger = loaded().ledger
|
|
85
|
+
const before = ledger.rows.length
|
|
86
|
+
for (const [id, shape] of [["F16", "dimension 18"], ["F17", "dimension 1"]]) {
|
|
87
|
+
const result = derive(ledger, { op: "sum", operands: [id, "F2"], unit: "km" })
|
|
88
|
+
expect(result.error).toBe("incompatible_operands")
|
|
89
|
+
expect(result.shape).toBe(shape)
|
|
90
|
+
}
|
|
91
|
+
expect(ledger.rows.length).toBe(before)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("#given a new source row with a flaw dev accepted #when added #then this version's writer refuses it", () => {
|
|
95
|
+
const ledger = fixture()
|
|
96
|
+
const blank = { ...rowOf(ledger, "F16"), fact_id: undefined, label: "blank-again" }
|
|
97
|
+
expect(() => addRow(ledger, blank)).toThrow("dimension 18")
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test("#given dev's ledger #when read and written twice #then the second pass changes nothing", () => {
|
|
101
|
+
const { dir, ledger } = loaded()
|
|
102
|
+
writeLedger(dir, ledger)
|
|
103
|
+
const once = readFileSync(join(dir, LEDGER_FILENAME), "utf8")
|
|
104
|
+
const written = JSON.parse(once) as Ledger
|
|
105
|
+
expect(rowOf(written, "F11").exact).toEqual({ num: "5", den: "2" })
|
|
106
|
+
expect(Object.hasOwn(rowOf(written, "F14"), "precision")).toBe(false)
|
|
107
|
+
writeLedger(dir, readLedger(dir))
|
|
108
|
+
expect(readFileSync(join(dir, LEDGER_FILENAME), "utf8")).toBe(once)
|
|
109
|
+
expect(readLedger(dir)).toEqual(written)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("#given a current derived row with its later columns stripped #when validated #then it reads as legacy and never recomputes", () => {
|
|
113
|
+
const ledger = fixture()
|
|
114
|
+
const made = derive(ledger, { op: "sum", operands: ["F1", "F2"], unit: "km" }).row as Row
|
|
115
|
+
const stripped = rowOf(ledger, made.fact_id as string)
|
|
116
|
+
for (const column of ["context", "precision", "compatibility"]) delete stripped[column]
|
|
117
|
+
stripped.unit = "kilometers"
|
|
118
|
+
expect(validateLedger(ledger)).toEqual([])
|
|
119
|
+
expect(recompute(ledger, stripped).shape).toBe("legacy derived row")
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test("#given a ledger written by this version #when read and written #then it is unchanged", () => {
|
|
123
|
+
const dir = tempDir()
|
|
124
|
+
const ledger = emptyLedger() as Ledger
|
|
125
|
+
const source = { kind: "measured", raw: "", quote: "", source_id: "S1", locator: { artifact: "sources/S1/text.txt", char_start: 0, char_end: 4 }, snapshot_sha256: "b".repeat(64), extracted_by: "fact add --find", observed_at: "2026-10-23T08:00:00Z" }
|
|
126
|
+
addRow(ledger, { ...source, value: "3", unit: "kilometers", label: "a" })
|
|
127
|
+
addRow(ledger, { ...source, value: "4", unit: "km", label: "b" })
|
|
128
|
+
expect(derive(ledger, { op: "sum", operands: ["F1", "F2"], unit: "km" }).error).toBeUndefined()
|
|
129
|
+
writeLedger(dir, ledger)
|
|
130
|
+
const first = readFileSync(join(dir, LEDGER_FILENAME), "utf8")
|
|
131
|
+
writeLedger(dir, readLedger(dir))
|
|
132
|
+
expect(readFileSync(join(dir, LEDGER_FILENAME), "utf8")).toBe(first)
|
|
133
|
+
expect(readLedger(dir)).toEqual(JSON.parse(first))
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test("#given a legacy exact with an unknown key #when read and written #then the key survives and the row is refused as an operand", () => {
|
|
137
|
+
const { dir, ledger } = loaded()
|
|
138
|
+
writeLedger(dir, ledger)
|
|
139
|
+
const written = JSON.parse(readFileSync(join(dir, LEDGER_FILENAME), "utf8")) as Ledger
|
|
140
|
+
expect(rowOf(written, "F18").exact).toEqual({ num: "3", den: "1", note: "from the brief" })
|
|
141
|
+
const result = derive(ledger, { op: "sum", operands: ["F18", "F2"], unit: "km" })
|
|
142
|
+
expect(result.error).toBe("incompatible_operands")
|
|
143
|
+
expect(result.shape).toBe("dimension 7")
|
|
144
|
+
expect(recompute(ledger, rowOf(ledger, "F18")).ok).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test("#given an assumed row whose value and exact disagree #when read #then it loads flagged and a new row is refused (dimension 32)", () => {
|
|
148
|
+
const dir = tempDir()
|
|
149
|
+
const seed = fixture()
|
|
150
|
+
const mismatched = { ...rowOf(seed, "F11"), fact_id: "F19", label: "mismatched-exact", unit: "km", value: "1.5", exact: { num: "3", den: "1" } }
|
|
151
|
+
seed.rows.push(mismatched)
|
|
152
|
+
writeFileSync(join(dir, LEDGER_FILENAME), JSON.stringify(seed))
|
|
153
|
+
const ledger = readLedger(dir) as Ledger
|
|
154
|
+
expect(rowOf(ledger, "F19").value).toBe("1.5")
|
|
155
|
+
const result = derive(ledger, { op: "sum", operands: ["F19", "F2"], unit: "km" })
|
|
156
|
+
expect(result.error).toBe("incompatible_operands")
|
|
157
|
+
expect(result.shape).toBe("dimension 32")
|
|
158
|
+
expect(() => addRow(ledger, { ...mismatched, fact_id: undefined, label: "mismatch-new" })).toThrow("dimension 32")
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// KIND_RULES (fact-ledger-kinds.mjs), never here.
|
|
5
5
|
// Pure functions with no IO; fact-ledger.mjs owns reading, writing, and row changes. node: builtins only.
|
|
6
6
|
|
|
7
|
-
import { DECIMAL, isCount, isNonEmpty, isObject, isString, kindRuleErrors } from "./fact-ledger-kinds.mjs"
|
|
7
|
+
import { DECIMAL, isCount, isCurrentDerived, isNonEmpty, isObject, isString, kindRuleErrors, LATER_COLUMNS, readRow, storedErrors } from "./fact-ledger-kinds.mjs"
|
|
8
|
+
import { malformed } from "./unit-rules.mjs"
|
|
8
9
|
|
|
9
10
|
export { DECIMAL, isNonEmpty, isString } from "./fact-ledger-kinds.mjs"
|
|
10
11
|
|
|
@@ -50,8 +51,13 @@ export const ROW_DEFAULTS = Object.freeze({
|
|
|
50
51
|
formula: null,
|
|
51
52
|
factor: null,
|
|
52
53
|
rationale: null,
|
|
54
|
+
context: null,
|
|
55
|
+
precision: null,
|
|
56
|
+
compatibility: null,
|
|
53
57
|
status: "active",
|
|
54
58
|
})
|
|
59
|
+
/** Fields added after schema version 1 shipped: written on every new row, optional when reading an older ledger. */
|
|
60
|
+
const OPTIONAL_FIELDS = new Set(LATER_COLUMNS)
|
|
55
61
|
const REQUIRED = ["fact_id", "kind", "raw", "quote", "extracted_by", "observed_at", "label"]
|
|
56
62
|
export const ROW_FIELDS = Object.freeze([...REQUIRED, ...Object.keys(ROW_DEFAULTS)])
|
|
57
63
|
|
|
@@ -70,10 +76,16 @@ function nullOr(value, test) {
|
|
|
70
76
|
function validateRow(row, index, errors) {
|
|
71
77
|
const at = `rows[${index}]`
|
|
72
78
|
if (!isObject(row)) return errors.push(`${at} must be an object`)
|
|
73
|
-
for (const key of ROW_FIELDS) check(errors, Object.hasOwn(row, key), `${at}.${key} is required`)
|
|
79
|
+
for (const key of ROW_FIELDS) check(errors, OPTIONAL_FIELDS.has(key) || Object.hasOwn(row, key), `${at}.${key} is required`)
|
|
74
80
|
for (const key of Object.keys(row)) check(errors, ROW_FIELDS.includes(key), `${at}.${key} is not a ledger field`)
|
|
75
81
|
check(errors, FACT_KINDS.includes(row.kind), `${at}.kind must be one of ${FACT_KINDS.join(", ")}`)
|
|
76
|
-
|
|
82
|
+
// Dimension 31 wins at read time: per-kind rules see the row as readRow normalizes it.
|
|
83
|
+
errors.push(...kindRuleErrors(readRow(row), at))
|
|
84
|
+
errors.push(...storedErrors(row).map((e) => `${at}.${e}`))
|
|
85
|
+
if (isCurrentDerived(row)) {
|
|
86
|
+
const problem = malformed(row)
|
|
87
|
+
if (problem) errors.push(`${at}: ${problem}`)
|
|
88
|
+
}
|
|
77
89
|
check(errors, row.unit === undefined || nullOr(row.unit, isNonEmpty), `${at}.unit must be null or a non-empty token`)
|
|
78
90
|
for (const key of ["raw", "quote"]) check(errors, row[key] === undefined || isString(row[key]), `${at}.${key} must be a string`)
|
|
79
91
|
check(errors, row.extracted_by === undefined || isNonEmpty(row.extracted_by), `${at}.extracted_by must be a non-empty string`)
|
|
@@ -100,6 +112,12 @@ function validateRow(row, index, errors) {
|
|
|
100
112
|
check(errors, row.status === undefined || FACT_STATUSES.includes(row.status), `${at}.status must be one of ${FACT_STATUSES.join(", ")}`)
|
|
101
113
|
}
|
|
102
114
|
|
|
115
|
+
export function rowErrors(row) {
|
|
116
|
+
const errors = []
|
|
117
|
+
validateRow(row, 0, errors)
|
|
118
|
+
return errors
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
// The status machine and event rule of references/fact-ledger-grammar.md. validateLedger replays
|
|
104
122
|
// events through transitionError, and every writer in fact-ledger.mjs calls it before appending.
|
|
105
123
|
export const TRANSITIONS = Object.freeze({ active: Object.freeze(["retracted", "superseded"]), retracted: Object.freeze([]), superseded: Object.freeze([]) })
|
|
@@ -163,20 +181,19 @@ export function provenanceErrors(rows) {
|
|
|
163
181
|
}
|
|
164
182
|
})
|
|
165
183
|
const state = new Map()
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (
|
|
184
|
+
for (const root of refs.keys()) {
|
|
185
|
+
const stack = [{ id: root, finish: false }]
|
|
186
|
+
const path = []
|
|
187
|
+
while (stack.length) {
|
|
188
|
+
const { id, finish } = stack.pop()
|
|
189
|
+
if (state.get(id) === "done") continue
|
|
190
|
+
if (finish) { state.set(id, "done"); path.pop(); continue }
|
|
191
|
+
if (state.get(id) === "open") return [...errors, `${id} derives from itself through cycle ${[...path, id].join(" -> ")}`]
|
|
192
|
+
state.set(id, "open")
|
|
193
|
+
path.push(id)
|
|
194
|
+
stack.push({ id, finish: true })
|
|
195
|
+
for (const next of refs.get(id)) if (next !== id && refs.has(next)) stack.push({ id: next, finish: false })
|
|
173
196
|
}
|
|
174
|
-
state.set(id, "done")
|
|
175
|
-
return null
|
|
176
|
-
}
|
|
177
|
-
for (const id of refs.keys()) {
|
|
178
|
-
const cycle = visit(id, [])
|
|
179
|
-
if (cycle) return [...errors, `${cycle[0]} derives from itself through ${cycle.join(" -> ")}`]
|
|
180
197
|
}
|
|
181
198
|
return errors
|
|
182
199
|
}
|
|
@@ -7,8 +7,9 @@ import { existsSync } from "node:fs"
|
|
|
7
7
|
import { join } from "node:path"
|
|
8
8
|
|
|
9
9
|
import { CliError, readJsonFile, writeJsonAtomic } from "./cli-support.mjs"
|
|
10
|
-
import { DECIMAL, exactComesFromValue, exactFromDecimal, idPrefix, isString } from "./fact-ledger-kinds.mjs"
|
|
10
|
+
import { DECIMAL, exactComesFromValue, exactFromDecimal, idPrefix, isString, readFlaws, readRow } from "./fact-ledger-kinds.mjs"
|
|
11
11
|
import { LEDGER_SCHEMA_VERSION, ROW_DEFAULTS, ROW_FIELDS, transitionError, validateLedger } from "./fact-ledger-schema.mjs"
|
|
12
|
+
import { normalizeInput } from "./unit-algebra.mjs"
|
|
12
13
|
|
|
13
14
|
export { exactFromDecimal } from "./fact-ledger-kinds.mjs"
|
|
14
15
|
export { FACT_KINDS, FACT_STATUSES, LEDGER_SCHEMA_VERSION, OPERATIONS, QUALIFIERS, ROW_FIELDS, TRANSITIONS, transitionError, validateLedger } from "./fact-ledger-schema.mjs"
|
|
@@ -24,11 +25,24 @@ function assertValid(ledger) {
|
|
|
24
25
|
if (errors.length > 0) throw new CliError(`invalid fact ledger: ${errors.join("; ")}`)
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** The row's fields in ROW_FIELDS order, then any unknown field (which validation reports). */
|
|
29
|
+
function ordered(row) {
|
|
30
|
+
const out = Object.fromEntries(ROW_FIELDS.filter((key) => Object.hasOwn(row, key)).map((key) => [key, row[key]]))
|
|
31
|
+
for (const key of Object.keys(row)) if (!ROW_FIELDS.includes(key)) out[key] = row[key]
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every row comes back as readRow normalizes it (reduced exacts and decimals; source rows with canonical
|
|
37
|
+
* units and null later columns) before any strict rule runs; the file changes on the next write.
|
|
38
|
+
*/
|
|
27
39
|
export function readLedger(dir) {
|
|
28
40
|
const file = join(dir, LEDGER_FILENAME)
|
|
29
41
|
if (!existsSync(file)) return emptyLedger()
|
|
30
42
|
const ledger = readJsonFile(file)
|
|
31
43
|
assertValid(ledger)
|
|
44
|
+
ledger.rows = ledger.rows.map((row) => ordered(readRow(row)))
|
|
45
|
+
assertValid(ledger)
|
|
32
46
|
return ledger
|
|
33
47
|
}
|
|
34
48
|
|
|
@@ -44,12 +58,12 @@ const ID_NUMBER = Object.freeze({ F: /^F(\d+)$/, Q: /^Q(\d+)$/ })
|
|
|
44
58
|
/** The next id with the kind's prefix from KIND_RULES (F<n> for value rows, Q<n> for quotes). */
|
|
45
59
|
export function nextId(ledger, kind) {
|
|
46
60
|
const prefix = idPrefix(kind)
|
|
47
|
-
let max =
|
|
61
|
+
let max = 0n
|
|
48
62
|
for (const row of ledger.rows) {
|
|
49
63
|
const match = ID_NUMBER[prefix].exec(row.fact_id ?? "")
|
|
50
|
-
if (match) max =
|
|
64
|
+
if (match && BigInt(match[1]) > max) max = BigInt(match[1])
|
|
51
65
|
}
|
|
52
|
-
return `${prefix}${max +
|
|
66
|
+
return `${prefix}${max + 1n}`
|
|
53
67
|
}
|
|
54
68
|
|
|
55
69
|
/**
|
|
@@ -60,14 +74,20 @@ export function nextId(ledger, kind) {
|
|
|
60
74
|
*/
|
|
61
75
|
export function addRow(ledger, row) {
|
|
62
76
|
const filled = { ...structuredClone(ROW_DEFAULTS), ...row, fact_id: row.fact_id ?? nextId(ledger, row.kind) }
|
|
77
|
+
if (!["derived", "converted"].includes(filled.kind)) {
|
|
78
|
+
const normalized = normalizeInput(filled.unit, filled.value)
|
|
79
|
+
if (normalized.value !== filled.value && filled.exact !== null) throw new CliError("scaled input must omit exact; it is computed after normalization")
|
|
80
|
+
Object.assign(filled, readRow({ ...filled, ...normalized }))
|
|
81
|
+
}
|
|
63
82
|
if (exactComesFromValue(filled.kind) && filled.exact === null && isString(filled.value) && DECIMAL.test(filled.value)) {
|
|
64
83
|
filled.exact = exactFromDecimal(filled.value)
|
|
65
84
|
}
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
ledger.rows
|
|
70
|
-
|
|
85
|
+
const added = ordered(filled)
|
|
86
|
+
const flaws = readFlaws(added)
|
|
87
|
+
if (flaws.length > 0) throw new CliError(`invalid fact ledger: ${flaws.map((flaw) => `${flaw.shape}: ${flaw.detail}`).join("; ")}`)
|
|
88
|
+
assertValid({ ...ledger, rows: [...ledger.rows, added] })
|
|
89
|
+
ledger.rows.push(added)
|
|
90
|
+
return added
|
|
71
91
|
}
|
|
72
92
|
|
|
73
93
|
/** Build the event from the row's current status and append it only if transitionError allows it. */
|
|
@@ -121,6 +121,7 @@ describe("ids and status", () => {
|
|
|
121
121
|
operands: ["F1", "F2"],
|
|
122
122
|
derived_from: ["F1", "F2"],
|
|
123
123
|
exact: { num: "1", den: "3" },
|
|
124
|
+
formula: "F1 / F2", render: "0.333", precision: { places: 3, sigfigs: 3 }, compatibility: "checked",
|
|
124
125
|
})
|
|
125
126
|
expect(derived.fact_id).toBe("F3")
|
|
126
127
|
expect(nextId(ledger, "quote")).toBe("Q2")
|
|
@@ -194,6 +195,7 @@ describe("read and write", () => {
|
|
|
194
195
|
operands: ["F1", "F2"],
|
|
195
196
|
derived_from: ["F1", "F2"],
|
|
196
197
|
exact: { num: "1", den: "3" },
|
|
198
|
+
formula: "F1 / F2", render: "0.333", precision: { places: 3, sigfigs: 3 }, compatibility: "checked",
|
|
197
199
|
})
|
|
198
200
|
writeLedger(dir, ledger)
|
|
199
201
|
const loaded = readLedger(dir)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// fact-locate.mjs - deterministic fact locators over a source snapshot.
|
|
2
|
+
//
|
|
3
|
+
// Each locator returns { value, unit, raw, quote, value_span, sentence_id, qualifier, locator } or
|
|
4
|
+
// { error: "fact_not_located" | "fact_ambiguous", candidates }; it never guesses. `quote` is the
|
|
5
|
+
// whole containing sentence, cell, or line of text.txt, `value_span` is [start, end] inside
|
|
6
|
+
// `quote` with quote.slice(...value_span) === raw, and locator.char_start/char_end place `quote`
|
|
7
|
+
// in text.txt. Every call takes `sourceId` (S<n>) so sentence ids match sentences.jsonl.
|
|
8
|
+
// Plain Node ESM, sibling imports only; no network, no OCR.
|
|
9
|
+
|
|
10
|
+
import { proseText } from "./html-lite.mjs"
|
|
11
|
+
import { QUALIFIERS } from "./lexicon.mjs"
|
|
12
|
+
import { parseNumeral } from "./numerals.mjs"
|
|
13
|
+
import { indexSentences, snapshotBlocks } from "./source-snapshot.mjs"
|
|
14
|
+
import { findNormalized, normalizeText, tokenize } from "./text-normalize.mjs"
|
|
15
|
+
import { similarity } from "./text-similarity.mjs"
|
|
16
|
+
import { normalizeUnit } from "./units.mjs"
|
|
17
|
+
|
|
18
|
+
const FIND_WINDOW = 80
|
|
19
|
+
const CONTEXT = 60
|
|
20
|
+
const FUZZY_FLOOR = 0.95
|
|
21
|
+
const PHRASES = QUALIFIERS.map((phrase) => phrase.split(" "))
|
|
22
|
+
const SINGLE_QUALIFIERS = new Set(QUALIFIERS.filter((phrase) => !phrase.includes(" ")))
|
|
23
|
+
|
|
24
|
+
const notLocated = (candidates = []) => ({ error: "fact_not_located", candidates })
|
|
25
|
+
const ambiguous = (candidates) => ({ error: "fact_ambiguous", candidates })
|
|
26
|
+
const label = (text) => normalizeText(text).toLowerCase()
|
|
27
|
+
const clean = (token) => token.replace(/^[^\p{L}\p{N}%]+|[^\p{L}\p{N}%]+$/gu, "").toLowerCase()
|
|
28
|
+
const sentenceAt = (sentences, offset) => sentences.find((row) => row.start <= offset && offset < row.end) ?? null
|
|
29
|
+
|
|
30
|
+
function requireSource(sourceId) {
|
|
31
|
+
if (!/^S[1-9]\d*$/.test(String(sourceId))) throw new TypeError(`sourceId must be S<n>, got ${sourceId}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every normalized occurrence of `needle`, as raw [start, end) offsets. */
|
|
35
|
+
function findAll(text, needle) {
|
|
36
|
+
const hits = []
|
|
37
|
+
for (let from = 0; from < text.length; ) {
|
|
38
|
+
const hit = findNormalized(text.slice(from), needle)
|
|
39
|
+
if (!hit) break
|
|
40
|
+
hits.push({ start: from + hit.start, end: from + hit.end })
|
|
41
|
+
from += hit.start + 1
|
|
42
|
+
}
|
|
43
|
+
return hits
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function locatorFor(text, sourceId, start, end, extra = {}) {
|
|
47
|
+
const before = text.slice(0, start)
|
|
48
|
+
const pageStart = before.lastIndexOf("\f") + 1
|
|
49
|
+
const page = text.includes("\f") ? { page: before.split("\f").length } : {}
|
|
50
|
+
return { artifact: `${sourceId}/text.txt`, ...page, line: before.slice(pageStart).split("\n").length, char_start: start, char_end: end, ...extra }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function candidate(text, sentences, start, end) {
|
|
54
|
+
return { sentence_id: sentenceAt(sentences, start)?.sid ?? null, char_start: start, char_end: end, context: text.slice(Math.max(0, start - CONTEXT), end + CONTEXT) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A QUALIFIERS token ending within the 2 tokens before the value, or the 1 token right after it. */
|
|
58
|
+
function qualifierAround(quote, [start, end]) {
|
|
59
|
+
const before = quote.slice(0, start).split(/\s+/).filter(Boolean).map(clean)
|
|
60
|
+
for (let skip = 0; skip < 2; skip++) {
|
|
61
|
+
for (const words of PHRASES) {
|
|
62
|
+
const stop = before.length - skip
|
|
63
|
+
if (stop - words.length >= 0 && before.slice(stop - words.length, stop).join(" ") === words.join(" ")) return words.join(" ")
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const next = /^\s*(\S+)/.exec(quote.slice(end))
|
|
67
|
+
return next && SINGLE_QUALIFIERS.has(clean(next[1])) ? clean(next[1]) : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A located quantity: `holder` is the quote's { sid, start, end, text }, `numeral` is parsed from holder.text. */
|
|
71
|
+
function quantityResult(text, sourceId, holder, numeral, extra = {}, unitFallback = null) {
|
|
72
|
+
const unit = numeral.unitRaw ? (normalizeUnit(numeral.unitRaw) ?? numeral.unitRaw) : unitFallback
|
|
73
|
+
return {
|
|
74
|
+
value: numeral.value,
|
|
75
|
+
unit,
|
|
76
|
+
raw: numeral.raw,
|
|
77
|
+
quote: holder.text,
|
|
78
|
+
value_span: [numeral.span[0], numeral.span[1]],
|
|
79
|
+
sentence_id: holder.sid,
|
|
80
|
+
qualifier: qualifierAround(holder.text, numeral.span),
|
|
81
|
+
locator: locatorFor(text, sourceId, holder.start, holder.end, extra),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** --find: one anchor occurrence, then the first numeral starting within 80 chars after it in the same sentence. */
|
|
86
|
+
export function locateFind(text, { find, unitExpected, sourceId }) {
|
|
87
|
+
requireSource(sourceId)
|
|
88
|
+
const sentences = indexSentences(text, sourceId)
|
|
89
|
+
const hits = findAll(text, find)
|
|
90
|
+
if (hits.length === 0) return notLocated()
|
|
91
|
+
if (hits.length > 1) return ambiguous(hits.map((hit) => candidate(text, sentences, hit.start, hit.end)))
|
|
92
|
+
const [hit] = hits
|
|
93
|
+
const sentence = sentenceAt(sentences, hit.start)
|
|
94
|
+
if (!sentence || hit.end > sentence.end) return notLocated()
|
|
95
|
+
const from = hit.end - sentence.start
|
|
96
|
+
const numeral = parseNumeral(sentence.text, from)
|
|
97
|
+
if (!numeral || numeral.span[0] - from > FIND_WINDOW) return notLocated([candidate(text, sentences, hit.start, hit.end)])
|
|
98
|
+
const result = quantityResult(text, sourceId, sentence, numeral)
|
|
99
|
+
if (unitExpected === undefined) return result
|
|
100
|
+
const expected = normalizeUnit(unitExpected) ?? unitExpected
|
|
101
|
+
return result.unit === expected ? result : { error: "unit_expected_mismatch", expected, found: result.unit, candidates: [result] }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cellsOf(row) {
|
|
105
|
+
const cells = []
|
|
106
|
+
for (const cell of row.children.filter((child) => child.tag === "td" || child.tag === "th")) {
|
|
107
|
+
const span = Math.max(1, Number.parseInt(cell.attrs.colspan ?? "1", 10) || 1)
|
|
108
|
+
for (let k = 0; k < span; k++) cells.push(cell)
|
|
109
|
+
}
|
|
110
|
+
return cells
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function rowsOf(table) {
|
|
114
|
+
const rows = []
|
|
115
|
+
const walk = (node) => {
|
|
116
|
+
for (const child of node.children) {
|
|
117
|
+
if (child.tag === "tr") rows.push(child)
|
|
118
|
+
else if (child.tag !== "table" && child.tag !== "#text") walk(child)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
walk(table)
|
|
122
|
+
return rows
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function tablesOf(root) {
|
|
126
|
+
const tables = []
|
|
127
|
+
const walk = (node) => {
|
|
128
|
+
for (const child of node.children) {
|
|
129
|
+
if (child.tag === "table") tables.push(child)
|
|
130
|
+
if (child.tag !== "#text") walk(child)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
walk(root)
|
|
134
|
+
return tables.map((table) => {
|
|
135
|
+
const rows = rowsOf(table)
|
|
136
|
+
const header = rows.find((row) => row.children.some((child) => child.tag === "th")) ?? rows[0]
|
|
137
|
+
const caption = table.children.find((child) => child.tag === "caption")
|
|
138
|
+
return { table, header, rows: rows.filter((row) => row !== header), caption: caption ? label(proseText(caption)) : null }
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const cellLabel = (cell) => label(proseText(cell))
|
|
143
|
+
const indexesOf = (labels, wanted) => labels.flatMap((value, index) => (value === wanted ? [index] : []))
|
|
144
|
+
|
|
145
|
+
/** --table/--row/--col: the table by id, caption, or a header label; row and column by normalized label, never by index. */
|
|
146
|
+
export function locateTable(sourceHtml, { table, row, col, sourceId }) {
|
|
147
|
+
requireSource(sourceId)
|
|
148
|
+
const { root, text, blocks } = snapshotBlocks(sourceHtml)
|
|
149
|
+
const key = label(table)
|
|
150
|
+
const tables = tablesOf(root).filter((entry) => entry.table.attrs.id === table || entry.caption === key || (entry.header && cellsOf(entry.header).map(cellLabel).includes(key)))
|
|
151
|
+
if (tables.length !== 1) return tables.length === 0 ? notLocated() : ambiguous(tables.map((entry) => ({ table: entry.table.attrs.id ?? entry.caption })))
|
|
152
|
+
const [{ header, rows }] = tables
|
|
153
|
+
const headerCells = header ? cellsOf(header) : []
|
|
154
|
+
const columns = indexesOf(headerCells.map(cellLabel), label(col))
|
|
155
|
+
const matches = rows.filter((candidateRow) => cellsOf(candidateRow).length > 0 && cellLabel(cellsOf(candidateRow)[0]) === label(row))
|
|
156
|
+
if (columns.length > 1 || matches.length > 1) return ambiguous([...columns.map((index) => ({ col: index })), ...matches.map((match) => ({ row_line: match.line }))])
|
|
157
|
+
const cell = columns.length === 1 && matches.length === 1 ? cellsOf(matches[0])[columns[0]] : null
|
|
158
|
+
const block = cell ? blocks.find((entry) => entry.node === cell) : null
|
|
159
|
+
if (!block) return notLocated()
|
|
160
|
+
const holder = { sid: sentenceAt(indexSentences(text, sourceId), block.start)?.sid ?? null, start: block.start, end: block.end, text: text.slice(block.start, block.end) }
|
|
161
|
+
const numeral = parseNumeral(holder.text)
|
|
162
|
+
if (!numeral) return notLocated()
|
|
163
|
+
const headerUnit = /\(([^()]+)\)\s*$/.exec(cellLabel(headerCells[columns[0]]))
|
|
164
|
+
return quantityResult(text, sourceId, holder, numeral, { table, row_label: row, col_label: col }, headerUnit ? normalizeUnit(headerUnit[1]) : null)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** --page/--line: exactly one quantity on that line of that page (pages split on a form feed). */
|
|
168
|
+
export function locatePageLine(text, { page, line, sourceId }) {
|
|
169
|
+
requireSource(sourceId)
|
|
170
|
+
const pages = text.split("\f")
|
|
171
|
+
if (!Number.isInteger(page) || page < 1 || page > pages.length) return notLocated()
|
|
172
|
+
const lines = pages[page - 1].split("\n")
|
|
173
|
+
if (!Number.isInteger(line) || line < 1 || line > lines.length) return notLocated()
|
|
174
|
+
let start = pages.slice(0, page - 1).reduce((sum, body) => sum + body.length + 1, 0)
|
|
175
|
+
for (const body of lines.slice(0, line - 1)) start += body.length + 1
|
|
176
|
+
const holder = { start, end: start + lines[line - 1].length, text: lines[line - 1] }
|
|
177
|
+
const numerals = []
|
|
178
|
+
for (let numeral = parseNumeral(holder.text); numeral; numeral = parseNumeral(holder.text, numeral.span[1])) numerals.push(numeral)
|
|
179
|
+
const sentences = indexSentences(text, sourceId)
|
|
180
|
+
if (numerals.length !== 1) return numerals.length === 0 ? notLocated() : ambiguous(numerals.map((numeral) => candidate(text, sentences, start + numeral.span[0], start + numeral.span[1])))
|
|
181
|
+
holder.sid = sentenceAt(sentences, start + numerals[0].span[0])?.sid ?? null
|
|
182
|
+
return quantityResult(text, sourceId, holder, numerals[0])
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Token-aligned windows of `sentence` whose similarity to `match` reaches the fuzzy floor; best first. */
|
|
186
|
+
function fuzzyWindow(sentence, match) {
|
|
187
|
+
const target = normalizeText(match).length
|
|
188
|
+
const wanted = new Set(tokenize(match))
|
|
189
|
+
const present = new Set(tokenize(sentence.text))
|
|
190
|
+
if (wanted.size === 0 || [...wanted].filter((token) => present.has(token)).length / wanted.size < 0.5) return null
|
|
191
|
+
const bounds = [...sentence.text.matchAll(/[\p{L}\p{N}]+/gu)].map((m) => [m.index, m.index + m[0].length])
|
|
192
|
+
let best = null
|
|
193
|
+
for (let i = 0; i < bounds.length; i++) {
|
|
194
|
+
for (let j = i; j < bounds.length; j++) {
|
|
195
|
+
const window = sentence.text.slice(bounds[i][0], bounds[j][1])
|
|
196
|
+
if (window.length > target * 1.06 + 2) break
|
|
197
|
+
if (window.length < target * 0.94 - 2) continue
|
|
198
|
+
const score = similarity(window, match)
|
|
199
|
+
if (score >= FUZZY_FLOOR && (!best || score > best.score)) best = { score, start: sentence.start + bounds[i][0], end: sentence.start + bounds[j][1] }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return best
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function quoteResult(text, sourceId, sentences, start, end, fuzzy) {
|
|
206
|
+
const first = sentenceAt(sentences, start)
|
|
207
|
+
const last = sentenceAt(sentences, end - 1) ?? first
|
|
208
|
+
return {
|
|
209
|
+
value: null,
|
|
210
|
+
unit: null,
|
|
211
|
+
raw: text.slice(start, end),
|
|
212
|
+
quote: text.slice(first.start, last.end),
|
|
213
|
+
value_span: null,
|
|
214
|
+
sentence_id: first.sid,
|
|
215
|
+
qualifier: null,
|
|
216
|
+
fuzzy,
|
|
217
|
+
locator: locatorFor(text, sourceId, first.start, last.end),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** --match: a normalized exact match (fuzzy false), else the one window with similarity >= 0.95 (fuzzy true). */
|
|
222
|
+
export function locateMatch(text, { match, sourceId }) {
|
|
223
|
+
requireSource(sourceId)
|
|
224
|
+
const sentences = indexSentences(text, sourceId)
|
|
225
|
+
const hits = findAll(text, match)
|
|
226
|
+
if (hits.length > 1) return ambiguous(hits.map((hit) => candidate(text, sentences, hit.start, hit.end)))
|
|
227
|
+
if (hits.length === 1) return quoteResult(text, sourceId, sentences, hits[0].start, hits[0].end, false)
|
|
228
|
+
const windows = sentences.map((sentence) => fuzzyWindow(sentence, match)).filter(Boolean)
|
|
229
|
+
if (windows.length > 1) return ambiguous(windows.map((window) => candidate(text, sentences, window.start, window.end)))
|
|
230
|
+
return windows.length === 1 ? quoteResult(text, sourceId, sentences, windows[0].start, windows[0].end, true) : notLocated()
|
|
231
|
+
}
|