@omgbase/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/LICENSE +21 -0
  2. package/dist/src/cli/eval-matcher.d.ts +13 -0
  3. package/dist/src/cli/eval-matcher.js +36 -0
  4. package/dist/src/core/attach.d.ts +5 -0
  5. package/dist/src/core/attach.js +38 -0
  6. package/dist/src/core/cursor.d.ts +11 -0
  7. package/dist/src/core/cursor.js +35 -0
  8. package/dist/src/core/hash.d.ts +23 -0
  9. package/dist/src/core/hash.js +82 -0
  10. package/dist/src/core/ids.d.ts +6 -0
  11. package/dist/src/core/ids.js +26 -0
  12. package/dist/src/core/index.d.ts +1 -0
  13. package/dist/src/core/index.js +2 -0
  14. package/dist/src/core/ingest.d.ts +43 -0
  15. package/dist/src/core/ingest.js +287 -0
  16. package/dist/src/core/order-key.d.ts +8 -0
  17. package/dist/src/core/order-key.js +72 -0
  18. package/dist/src/core/parse/parse.d.ts +3 -0
  19. package/dist/src/core/parse/parse.js +104 -0
  20. package/dist/src/core/parse/render.d.ts +3 -0
  21. package/dist/src/core/parse/render.js +20 -0
  22. package/dist/src/core/parse/tree.d.ts +8 -0
  23. package/dist/src/core/parse/tree.js +43 -0
  24. package/dist/src/core/parse/types.d.ts +52 -0
  25. package/dist/src/core/parse/types.js +9 -0
  26. package/dist/src/core/read/document.d.ts +107 -0
  27. package/dist/src/core/read/document.js +191 -0
  28. package/dist/src/core/read/nodes.d.ts +47 -0
  29. package/dist/src/core/read/nodes.js +102 -0
  30. package/dist/src/core/read/outline.d.ts +12 -0
  31. package/dist/src/core/read/outline.js +76 -0
  32. package/dist/src/core/read/reader.d.ts +125 -0
  33. package/dist/src/core/read/reader.js +206 -0
  34. package/dist/src/core/read/refs.d.ts +15 -0
  35. package/dist/src/core/read/refs.js +51 -0
  36. package/dist/src/core/store/edges.d.ts +26 -0
  37. package/dist/src/core/store/edges.js +101 -0
  38. package/dist/src/core/store/fts.d.ts +10 -0
  39. package/dist/src/core/store/fts.js +22 -0
  40. package/dist/src/core/store/gc.d.ts +18 -0
  41. package/dist/src/core/store/gc.js +69 -0
  42. package/dist/src/core/store/nodes.d.ts +5 -0
  43. package/dist/src/core/store/nodes.js +57 -0
  44. package/dist/src/core/store/properties.d.ts +38 -0
  45. package/dist/src/core/store/properties.js +177 -0
  46. package/dist/src/core/store/rebuild.d.ts +3 -0
  47. package/dist/src/core/store/rebuild.js +29 -0
  48. package/dist/src/core/store/schema.d.ts +9 -0
  49. package/dist/src/core/store/schema.js +386 -0
  50. package/dist/src/core/store/sections.d.ts +2 -0
  51. package/dist/src/core/store/sections.js +29 -0
  52. package/dist/src/core/store/store.d.ts +24 -0
  53. package/dist/src/core/store/store.js +164 -0
  54. package/dist/src/core/store/writers.d.ts +52 -0
  55. package/dist/src/core/store/writers.js +89 -0
  56. package/dist/src/core/vec.d.ts +10 -0
  57. package/dist/src/core/vec.js +33 -0
  58. package/dist/src/format/adapter.d.ts +97 -0
  59. package/dist/src/format/adapter.js +14 -0
  60. package/dist/src/format/index.d.ts +6 -0
  61. package/dist/src/format/index.js +15 -0
  62. package/dist/src/format/json.d.ts +3 -0
  63. package/dist/src/format/json.js +324 -0
  64. package/dist/src/format/markdown.d.ts +5 -0
  65. package/dist/src/format/markdown.js +217 -0
  66. package/dist/src/format/registry.d.ts +6 -0
  67. package/dist/src/format/registry.js +24 -0
  68. package/dist/src/format/yaml.d.ts +5 -0
  69. package/dist/src/format/yaml.js +413 -0
  70. package/dist/src/graph/extract.d.ts +38 -0
  71. package/dist/src/graph/extract.js +242 -0
  72. package/dist/src/graph/history.d.ts +103 -0
  73. package/dist/src/graph/history.js +210 -0
  74. package/dist/src/graph/inbound-links.d.ts +33 -0
  75. package/dist/src/graph/inbound-links.js +130 -0
  76. package/dist/src/graph/link-destinations.d.ts +22 -0
  77. package/dist/src/graph/link-destinations.js +84 -0
  78. package/dist/src/graph/link-health.d.ts +79 -0
  79. package/dist/src/graph/link-health.js +146 -0
  80. package/dist/src/graph/links.d.ts +23 -0
  81. package/dist/src/graph/links.js +42 -0
  82. package/dist/src/index.d.ts +60 -0
  83. package/dist/src/index.js +70 -0
  84. package/dist/src/mcp/errors.d.ts +17 -0
  85. package/dist/src/mcp/errors.js +18 -0
  86. package/dist/src/mcp/graph.d.ts +63 -0
  87. package/dist/src/mcp/graph.js +212 -0
  88. package/dist/src/mcp/reference.d.ts +1 -0
  89. package/dist/src/mcp/reference.js +227 -0
  90. package/dist/src/mcp/server.d.ts +30 -0
  91. package/dist/src/mcp/server.js +1076 -0
  92. package/dist/src/mcp/stdio.d.ts +13 -0
  93. package/dist/src/mcp/stdio.js +24 -0
  94. package/dist/src/migrate/mrplex.d.ts +27 -0
  95. package/dist/src/migrate/mrplex.js +29 -0
  96. package/dist/src/mutate/apply.d.ts +83 -0
  97. package/dist/src/mutate/apply.js +302 -0
  98. package/dist/src/mutate/doc-store.d.ts +49 -0
  99. package/dist/src/mutate/doc-store.js +71 -0
  100. package/dist/src/mutate/docs.d.ts +60 -0
  101. package/dist/src/mutate/docs.js +232 -0
  102. package/dist/src/mutate/known-ids.d.ts +12 -0
  103. package/dist/src/mutate/known-ids.js +154 -0
  104. package/dist/src/mutate/load.d.ts +3 -0
  105. package/dist/src/mutate/load.js +66 -0
  106. package/dist/src/mutate/lower.d.ts +32 -0
  107. package/dist/src/mutate/lower.js +232 -0
  108. package/dist/src/mutate/macros.d.ts +91 -0
  109. package/dist/src/mutate/macros.js +230 -0
  110. package/dist/src/mutate/ops.d.ts +40 -0
  111. package/dist/src/mutate/ops.js +513 -0
  112. package/dist/src/mutate/opset.d.ts +92 -0
  113. package/dist/src/mutate/opset.js +79 -0
  114. package/dist/src/mutate/plan-update.d.ts +51 -0
  115. package/dist/src/mutate/plan-update.js +187 -0
  116. package/dist/src/mutate/tree.d.ts +46 -0
  117. package/dist/src/mutate/tree.js +167 -0
  118. package/dist/src/oqx/run.d.ts +1 -0
  119. package/dist/src/oqx/run.js +6 -0
  120. package/dist/src/oqx-js/context.d.ts +23 -0
  121. package/dist/src/oqx-js/context.js +564 -0
  122. package/dist/src/oqx-js/planner.d.ts +10 -0
  123. package/dist/src/oqx-js/planner.js +91 -0
  124. package/dist/src/oqx-js/run.d.ts +41 -0
  125. package/dist/src/oqx-js/run.js +332 -0
  126. package/dist/src/oqx-js/sql/translate.d.ts +27 -0
  127. package/dist/src/oqx-js/sql/translate.js +290 -0
  128. package/dist/src/reconcile/crossdoc.d.ts +34 -0
  129. package/dist/src/reconcile/crossdoc.js +76 -0
  130. package/dist/src/reconcile/eval/generator.d.ts +21 -0
  131. package/dist/src/reconcile/eval/generator.js +129 -0
  132. package/dist/src/reconcile/eval/metrics.d.ts +32 -0
  133. package/dist/src/reconcile/eval/metrics.js +74 -0
  134. package/dist/src/reconcile/flatten.d.ts +12 -0
  135. package/dist/src/reconcile/flatten.js +37 -0
  136. package/dist/src/reconcile/phase5.d.ts +8 -0
  137. package/dist/src/reconcile/phase5.js +167 -0
  138. package/dist/src/reconcile/phase6a.d.ts +2 -0
  139. package/dist/src/reconcile/phase6a.js +159 -0
  140. package/dist/src/reconcile/phases.d.ts +28 -0
  141. package/dist/src/reconcile/phases.js +0 -0
  142. package/dist/src/reconcile/reconcile.d.ts +21 -0
  143. package/dist/src/reconcile/reconcile.js +115 -0
  144. package/dist/src/reconcile/similarity.d.ts +7 -0
  145. package/dist/src/reconcile/similarity.js +41 -0
  146. package/dist/src/reconcile/types.d.ts +46 -0
  147. package/dist/src/reconcile/types.js +19 -0
  148. package/dist/src/search/cel/ast.d.ts +74 -0
  149. package/dist/src/search/cel/ast.js +3 -0
  150. package/dist/src/search/cel/compile.d.ts +60 -0
  151. package/dist/src/search/cel/compile.js +836 -0
  152. package/dist/src/search/cel/lexer.d.ts +9 -0
  153. package/dist/src/search/cel/lexer.js +139 -0
  154. package/dist/src/search/cel/parser.d.ts +7 -0
  155. package/dist/src/search/cel/parser.js +219 -0
  156. package/dist/src/search/drain.d.ts +39 -0
  157. package/dist/src/search/drain.js +97 -0
  158. package/dist/src/search/embeddings.d.ts +131 -0
  159. package/dist/src/search/embeddings.js +263 -0
  160. package/dist/src/search/external.d.ts +13 -0
  161. package/dist/src/search/external.js +177 -0
  162. package/dist/src/search/fts-query.d.ts +1 -0
  163. package/dist/src/search/fts-query.js +64 -0
  164. package/dist/src/search/provider.d.ts +17 -0
  165. package/dist/src/search/provider.js +13 -0
  166. package/dist/src/search/resolve.d.ts +18 -0
  167. package/dist/src/search/resolve.js +23 -0
  168. package/dist/src/search/rrf.d.ts +36 -0
  169. package/dist/src/search/rrf.js +84 -0
  170. package/dist/src/search/tasks.d.ts +12 -0
  171. package/dist/src/search/tasks.js +131 -0
  172. package/dist/src/search/text.d.ts +16 -0
  173. package/dist/src/search/text.js +21 -0
  174. package/dist/src/search/vector.d.ts +18 -0
  175. package/dist/src/search/vector.js +49 -0
  176. package/dist/src/sync/admin.d.ts +59 -0
  177. package/dist/src/sync/admin.js +53 -0
  178. package/dist/src/sync/attach.d.ts +13 -0
  179. package/dist/src/sync/attach.js +29 -0
  180. package/dist/src/sync/checkpoint.d.ts +21 -0
  181. package/dist/src/sync/checkpoint.js +52 -0
  182. package/dist/src/sync/driver.d.ts +25 -0
  183. package/dist/src/sync/driver.js +80 -0
  184. package/dist/src/sync/external-source.d.ts +14 -0
  185. package/dist/src/sync/external-source.js +137 -0
  186. package/dist/src/sync/freshness.d.ts +42 -0
  187. package/dist/src/sync/freshness.js +152 -0
  188. package/dist/src/sync/fs-util.d.ts +12 -0
  189. package/dist/src/sync/fs-util.js +55 -0
  190. package/dist/src/sync/git-heuristics.d.ts +6 -0
  191. package/dist/src/sync/git-heuristics.js +16 -0
  192. package/dist/src/sync/observe.d.ts +63 -0
  193. package/dist/src/sync/observe.js +84 -0
  194. package/dist/src/sync/plugin.d.ts +54 -0
  195. package/dist/src/sync/plugin.js +12 -0
  196. package/dist/src/sync/reconciling-ingest.d.ts +12 -0
  197. package/dist/src/sync/reconciling-ingest.js +209 -0
  198. package/dist/src/sync/recovery.d.ts +6 -0
  199. package/dist/src/sync/recovery.js +34 -0
  200. package/dist/src/sync/settings.d.ts +18 -0
  201. package/dist/src/sync/settings.js +58 -0
  202. package/dist/src/sync/sources.d.ts +37 -0
  203. package/dist/src/sync/sources.js +75 -0
  204. package/dist/src/sync/tombstone.d.ts +9 -0
  205. package/dist/src/sync/tombstone.js +55 -0
  206. package/dist/src/sync/watch-lease.d.ts +14 -0
  207. package/dist/src/sync/watch-lease.js +91 -0
  208. package/dist/src/sync/watcher.d.ts +19 -0
  209. package/dist/src/sync/watcher.js +34 -0
  210. package/dist/src/sync/workspace.d.ts +34 -0
  211. package/dist/src/sync/workspace.js +120 -0
  212. package/dist/src/sync/writer-lock.d.ts +16 -0
  213. package/dist/src/sync/writer-lock.js +121 -0
  214. package/package.json +80 -0
@@ -0,0 +1,290 @@
1
+ // Semantics-faithful OQX-expression → SQLite translator (ADR-013 pushdown seam).
2
+ //
3
+ // This is the piece that lets the SQLite adapter push scalar work DOWN into the
4
+ // store instead of materializing rows and finishing them in the in-memory
5
+ // engine. It walks an `@omgbase/oqx` scalar `Expr` and returns a SQL fragment +
6
+ // bound params, or `null` when it cannot translate a construction faithfully —
7
+ // in which case the planner leaves that conjunct residual (correct, just slower).
8
+ //
9
+ // The cardinal rule is FIDELITY, not cleverness: the SQL a fragment emits MUST
10
+ // evaluate to the same result as `@omgbase/oqx`'s in-memory `semantics.ts` for
11
+ // every input, because the differential conformance suite runs each query BOTH
12
+ // ways and asserts equality. Two consequences drive the design:
13
+ //
14
+ // • oqx-js string ops are CASE-SENSITIVE, but SQLite `LIKE` is case-insensitive
15
+ // for ASCII. So `startsWith`/`contains`/`endsWith` translate to
16
+ // `substr`/`instr`, NEVER `LIKE`. `$path.lower().startsWith("lab/")` becomes
17
+ // `substr(lower(d.path), 1, length(?)) = ?` — the lowercasing is explicit and
18
+ // the match is byte-exact, matching the in-memory path.
19
+ // • oqx-js `==`/`!=` are absence-NORMALIZED (two absent values are equal;
20
+ // `absent != v` is true). SQLite `=`/`<>` are not null-safe. So `==` → `IS`
21
+ // and `!=` → `IS NOT` (SQLite's null-safe operators), which reproduce
22
+ // `equals(a,b)` including the both-absent and negation cases, while still
23
+ // honoring SQLite's typed comparison (`5 IS '5'` is false, matching strict
24
+ // `5 === "5"`).
25
+ //
26
+ // Only forms that are faithful in a POSITIVE, AND-composed context are
27
+ // translated (that is the only context `partitionPushable` pushes into). `||`,
28
+ // `!`, `in`, `matches` (needs a regexp UDF), and bare content-property routing
29
+ // are deliberately declined here and left residual for now.
30
+ // ---- intrinsics -------------------------------------------------------------
31
+ // A `$`-namespaced intrinsic → a param-free SQL scalar, per target. Anything not
32
+ // mapped (e.g. docs `$body`, reconstructed; `$title`/`$tags`, computed) returns
33
+ // null → residual. `$updated_at`/`$dst_path`/`$dst_uri` are correlated subqueries.
34
+ function intrinsicSql(name, ctx) {
35
+ const { self, doc, target } = ctx;
36
+ if (target === "docs")
37
+ switch (name) {
38
+ case "$id": return `${self}.doc_id`;
39
+ case "$path": return `${doc}.path`;
40
+ case "$content_hash": return `lower(hex(${self}.file_hash))`;
41
+ case "$updated_at": return `(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = ${self}.current_rev)`;
42
+ }
43
+ else if (target === "blocks")
44
+ switch (name) {
45
+ case "$id": return `${self}.block_id`;
46
+ case "$doc": return `${self}.doc_id`;
47
+ case "$path": return `${doc}.path`;
48
+ case "$ordinal": return `${self}.ordinal`;
49
+ case "$depth": return `${self}.depth`;
50
+ case "$body": return `${self}.text`;
51
+ case "$content_hash": return `lower(hex(${self}.raw_hash))`;
52
+ }
53
+ else if (target === "nodes")
54
+ switch (name) {
55
+ case "$id":
56
+ case "$node_id": return `${self}.node_id`;
57
+ case "$doc_id": return `${self}.doc_id`;
58
+ case "$block_id": return `${self}.block_id`;
59
+ case "$path": return `${doc}.path`;
60
+ }
61
+ else if (target === "edges")
62
+ switch (name) {
63
+ case "$id": return `${self}.edge_id`;
64
+ case "$src": return `${self}.src_doc`;
65
+ case "$dst": return `${self}.dst_node`;
66
+ case "$src_block": return `${self}.src_block`;
67
+ case "$via": return `${self}.via_node`;
68
+ case "$from_commit": return `${self}.from_commit`;
69
+ case "$path": return `${doc}.path`;
70
+ case "$dst_path": return `(SELECT dd.path FROM docs dd WHERE dd.doc_id = ${self}.dst_node)`;
71
+ case "$dst_uri": return `(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = ${self}.dst_node)`;
72
+ }
73
+ return null;
74
+ }
75
+ // docs intrinsics whose BARE (non-$) form is a loud error in-memory (10 §2) — not
76
+ // pushable, so the residual raises it.
77
+ const RESERVED_DOC_BASENAMES = new Set(["id", "path", "updated_at", "content_hash", "body"]);
78
+ const SEG = /^[A-Za-z_][A-Za-z0-9_]*$/; // injection-safe inlined identifier
79
+ // A single-valued document property (the CEL scalar-in-scope rule): the scalar
80
+ // value only when the key has exactly one row in scope and it is card='scalar',
81
+ // else NULL — matching the store context's `docProp` for a scalar read.
82
+ function propScalar(docAlias, key) {
83
+ if (!SEG.test(key))
84
+ return null;
85
+ return `(SELECT COALESCE(p.val_text, p.val_num, p.val_bool) FROM properties p
86
+ WHERE p.doc_id = ${docAlias}.doc_id AND p.key = '${key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL
87
+ AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = ${docAlias}.doc_id AND p2.key = '${key}' AND p2.deleted_commit IS NULL) = 1
88
+ LIMIT 1)`;
89
+ }
90
+ // json_extract path from validated segments, or null if any segment is unsafe.
91
+ function jsonExtract(col, segs) {
92
+ if (segs.some((s) => !SEG.test(s)))
93
+ return null;
94
+ return `json_extract(${col}, '$.${segs.join(".")}')`;
95
+ }
96
+ // better-sqlite3 binds only number/string/bigint/buffer/null — coerce booleans
97
+ // to 1/0 (matching how json_extract surfaces JSON booleans, so `attrs.b == true`
98
+ // compares against `1`). Non-bindable values are left as-is (they only reach a
99
+ // bound `?` for lit/binding operands, which are scalars in practice).
100
+ function bindable(v) {
101
+ return typeof v === "boolean" ? (v ? 1 : 0) : v;
102
+ }
103
+ // The dotted `attrs.a.b` / `doc.x` receiver chain as segments, or null if it is
104
+ // not a plain identifier navigation.
105
+ function memberSegments(e) {
106
+ if (e.kind === "ident")
107
+ return [e.name];
108
+ if (e.kind === "member") {
109
+ const base = memberSegments(e.recv);
110
+ return base ? [...base, e.name] : null;
111
+ }
112
+ return null;
113
+ }
114
+ // ---- value position ---------------------------------------------------------
115
+ /** Translate an expression used as a VALUE (comparison operand, method receiver,
116
+ * function argument) to a SQL scalar. Returns null if not faithfully translatable. */
117
+ export function translateValue(e, ctx) {
118
+ const { self, doc, target } = ctx;
119
+ switch (e.kind) {
120
+ case "lit":
121
+ return { sql: "?", params: [bindable(e.value)] };
122
+ case "binding":
123
+ return { sql: "?", params: [bindable(ctx.params[e.index])] };
124
+ case "ident": {
125
+ if (e.name.startsWith("$")) {
126
+ const sql = intrinsicSql(e.name, ctx);
127
+ return sql ? { sql, params: [] } : null;
128
+ }
129
+ // Bare field per target.
130
+ if (target === "docs") {
131
+ if (e.name === "format")
132
+ return { sql: `${self}.format`, params: [] }; // a column, not a property
133
+ if (RESERVED_DOC_BASENAMES.has(e.name))
134
+ return null; // residual raises the guard
135
+ const sql = propScalar(doc, e.name);
136
+ return sql ? { sql, params: [] } : null;
137
+ }
138
+ if (target === "blocks") {
139
+ if (e.name === "type" || e.name === "text")
140
+ return { sql: `${self}.${e.name}`, params: [] };
141
+ // Bare non-structural identifier flattens into attrs — same pushdown as
142
+ // the `attrs.<k>` member form (json_extract), so `checked` == `attrs.checked`.
143
+ const sql = jsonExtract(`${self}.attrs`, [e.name]);
144
+ return sql ? { sql, params: [] } : null;
145
+ }
146
+ if (target === "nodes") {
147
+ if (e.name === "kind" || e.name === "name" || e.name === "value")
148
+ return { sql: `${self}.${e.name}`, params: [] };
149
+ // Bare non-structural identifier flattens into attrs (see blocks above).
150
+ const sql = jsonExtract(`${self}.attrs`, [e.name]);
151
+ return sql ? { sql, params: [] } : null;
152
+ }
153
+ if (target === "edges") {
154
+ if (["predicate", "provenance", "dst_kind", "anchor", "src_field"].includes(e.name))
155
+ return { sql: `${self}.${e.name}`, params: [] };
156
+ return null;
157
+ }
158
+ return null;
159
+ }
160
+ case "member": {
161
+ const segs = memberSegments(e);
162
+ if (!segs || segs.length < 2)
163
+ return null;
164
+ const [head, ...rest] = segs;
165
+ // attrs.<path> → json_extract on the row's attrs (blocks/nodes).
166
+ if (head === "attrs" && (target === "blocks" || target === "nodes")) {
167
+ const sql = jsonExtract(`${self}.attrs`, rest);
168
+ return sql ? { sql, params: [] } : null;
169
+ }
170
+ // doc.<x> reach-through — the owning doc (alias `doc`, which is `d`). On the
171
+ // docs target `doc` is the row itself; either way it resolves against `doc`.
172
+ if (head === "doc") {
173
+ if (rest.length !== 1)
174
+ return null;
175
+ const k = rest[0];
176
+ if (k === "$path")
177
+ return { sql: `${doc}.path`, params: [] };
178
+ if (k === "format")
179
+ return { sql: `${doc}.format`, params: [] };
180
+ if (k.startsWith("$"))
181
+ return null;
182
+ if (RESERVED_DOC_BASENAMES.has(k))
183
+ return null;
184
+ const sql = propScalar(doc, k);
185
+ return sql ? { sql, params: [] } : null;
186
+ }
187
+ // block.type / block.text reach-through from a node.
188
+ if (head === "block" && target === "nodes" && rest.length === 1 && (rest[0] === "type" || rest[0] === "text")) {
189
+ return { sql: `(SELECT bb.${rest[0]} FROM blocks bb WHERE bb.block_id = ${self}.block_id)`, params: [] };
190
+ }
191
+ return null;
192
+ }
193
+ case "call": {
194
+ // `.lower()` / `.upper()` are the value-position string methods.
195
+ if (e.recv !== null && e.args.length === 0 && (e.name === "lower" || e.name === "upper")) {
196
+ const recv = translateValue(e.recv, ctx);
197
+ if (!recv)
198
+ return null;
199
+ return { sql: `${e.name}(${recv.sql})`, params: recv.params };
200
+ }
201
+ return null;
202
+ }
203
+ default:
204
+ return null;
205
+ }
206
+ }
207
+ // ---- predicate position -----------------------------------------------------
208
+ const IS_OP = {
209
+ "==": "IS",
210
+ "!=": "IS NOT",
211
+ "<": "<",
212
+ "<=": "<=",
213
+ ">": ">",
214
+ ">=": ">=",
215
+ };
216
+ /** Translate an expression used as a boolean PREDICATE to a SQL boolean, or null
217
+ * if it cannot be pushed faithfully. Only positive, AND-safe forms are handled. */
218
+ export function translatePredicate(e, ctx) {
219
+ switch (e.kind) {
220
+ case "logical":
221
+ // Only `&&` composes faithfully in a positive context; `||` is declined
222
+ // (its NULL/short-circuit interaction is not worth risking — leave residual).
223
+ if (e.op !== "&&")
224
+ return null;
225
+ return join2(translatePredicate(e.left, ctx), translatePredicate(e.right, ctx), "AND");
226
+ case "binary":
227
+ return translateComparison(e, ctx);
228
+ case "call":
229
+ return translateStringPredicate(e, ctx);
230
+ default:
231
+ // `unary` (!), `in`, bare truthy idents, member reach-through: residual.
232
+ return null;
233
+ }
234
+ }
235
+ function translateComparison(e, ctx) {
236
+ const op = IS_OP[e.op];
237
+ if (!op)
238
+ return null; // arithmetic operator in predicate position → residual
239
+ const l = translateValue(e.left, ctx);
240
+ const r = translateValue(e.right, ctx);
241
+ if (!l || !r)
242
+ return null;
243
+ // `==`/`!=` → null-safe IS / IS NOT (absence-normalized equality, faithful in
244
+ // any context). Relational ops → plain SQL: a NULL operand yields NULL, which
245
+ // is excluded in the positive AND context these fragments are pushed into,
246
+ // matching `relate`'s absent-operand ⇒ false rule.
247
+ return { sql: `(${l.sql} ${op} ${r.sql})`, params: [...l.params, ...r.params] };
248
+ }
249
+ // startsWith / contains / endsWith — CASE-SENSITIVE, via substr/instr (never
250
+ // LIKE). `matches` (regex) is declined until a `regexp` UDF is registered.
251
+ function translateStringPredicate(e, ctx) {
252
+ if (e.recv === null || e.args.length !== 1)
253
+ return null;
254
+ const recv = translateValue(e.recv, ctx);
255
+ const arg = translateValue(e.args[0], ctx);
256
+ if (!recv || !arg)
257
+ return null;
258
+ switch (e.name) {
259
+ case "startsWith":
260
+ // recv begins with arg ⇔ its first length(arg) chars equal arg.
261
+ return {
262
+ sql: `(substr(${recv.sql}, 1, length(${arg.sql})) = ${arg.sql})`,
263
+ params: [...recv.params, ...arg.params, ...arg.params],
264
+ };
265
+ case "endsWith":
266
+ // recv ends with arg ⇔ its last length(arg) chars equal arg. When arg is
267
+ // longer than recv, substr clamps to the whole string (a shorter value),
268
+ // so the equality is false — matching String.prototype.endsWith.
269
+ return {
270
+ sql: `(substr(${recv.sql}, -length(${arg.sql})) = ${arg.sql})`,
271
+ params: [...recv.params, ...arg.params, ...arg.params],
272
+ };
273
+ case "contains":
274
+ // substring test, case-sensitive.
275
+ return {
276
+ sql: `(instr(${recv.sql}, ${arg.sql}) > 0)`,
277
+ params: [...recv.params, ...arg.params],
278
+ };
279
+ default:
280
+ return null;
281
+ }
282
+ }
283
+ // Combine two optional fragments with a boolean connective; null if either is
284
+ // untranslatable (the whole conjunct then stays residual).
285
+ function join2(a, b, connective) {
286
+ if (!a || !b)
287
+ return null;
288
+ return { sql: `(${a.sql} ${connective} ${b.sql})`, params: [...a.params, ...b.params] };
289
+ }
290
+ //# sourceMappingURL=translate.js.map
@@ -0,0 +1,34 @@
1
+ import type { MatchBlock, ReconcileConfig } from "./types.js";
2
+ import type { DocReconcileResult } from "./reconcile.js";
3
+ export interface PerDocUnmatched {
4
+ docId: string;
5
+ /** old blocks that ended up deleted this run (id + content) */
6
+ deleted: {
7
+ block: MatchBlock;
8
+ }[];
9
+ /** new blocks minted as 'inserted' this run (key + assigned id + content) */
10
+ inserted: {
11
+ block: MatchBlock;
12
+ mintedId: string;
13
+ }[];
14
+ }
15
+ export interface CrossDocMatch {
16
+ /** source doc where the block was deleted */
17
+ fromDoc: string;
18
+ /** destination doc where it reappeared */
19
+ toDoc: string;
20
+ /** the surviving id (the deleted block's id carries across) */
21
+ carriedId: string;
22
+ /** the minted id that should be REPLACED by carriedId */
23
+ replacedMintedId: string;
24
+ newKey: string;
25
+ kind: "moved" | "edited_moved";
26
+ confidence: number;
27
+ }
28
+ export declare function crossDocMatch(docs: PerDocUnmatched[], config?: ReconcileConfig): CrossDocMatch[];
29
+ /**
30
+ * Apply cross-doc matches back into per-doc reconcile results: the destination
31
+ * doc's minted 'inserted' id is replaced by the carried id (moved/edited_moved),
32
+ * and the source doc's 'deleted' disposition for that id is dropped.
33
+ */
34
+ export declare function applyCrossDocMatches(resultsByDoc: Map<string, DocReconcileResult>, matches: CrossDocMatch[], matcherV: string): void;
@@ -0,0 +1,76 @@
1
+ import { DEFAULT_CONFIG } from "./types.js";
2
+ import { textSim, tokenCount } from "./similarity.js";
3
+ export function crossDocMatch(docs, config = DEFAULT_CONFIG) {
4
+ // Pool everything.
5
+ const deleted = [];
6
+ const inserted = [];
7
+ for (const d of docs) {
8
+ for (const del of d.deleted)
9
+ deleted.push({ docId: d.docId, block: del.block });
10
+ for (const ins of d.inserted)
11
+ inserted.push({ docId: d.docId, block: ins.block, mintedId: ins.mintedId });
12
+ }
13
+ const pairs = [];
14
+ for (let di = 0; di < deleted.length; di++) {
15
+ for (let ii = 0; ii < inserted.length; ii++) {
16
+ const o = deleted[di].block;
17
+ const n = inserted[ii].block;
18
+ if (o.type !== n.type)
19
+ continue;
20
+ // don't match within the same doc (that's the intra-doc matcher's job)
21
+ if (deleted[di].docId === inserted[ii].docId)
22
+ continue;
23
+ const ratio = Math.max(tokenCount(o.text), tokenCount(n.text)) / Math.max(1, Math.min(tokenCount(o.text), tokenCount(n.text)));
24
+ if (ratio > 3)
25
+ continue;
26
+ const score = textSim(o.text, n.text);
27
+ if (score >= config.thetaXdoc)
28
+ pairs.push({ di, ii, score });
29
+ }
30
+ }
31
+ pairs.sort((a, b) => b.score - a.score || a.di - b.di || a.ii - b.ii);
32
+ const usedDel = new Set();
33
+ const usedIns = new Set();
34
+ const matches = [];
35
+ for (const p of pairs) {
36
+ if (usedDel.has(p.di) || usedIns.has(p.ii))
37
+ continue;
38
+ usedDel.add(p.di);
39
+ usedIns.add(p.ii);
40
+ const o = deleted[p.di];
41
+ const n = inserted[p.ii];
42
+ const edited = o.block.rawHashHex !== n.block.rawHashHex;
43
+ matches.push({
44
+ fromDoc: o.docId,
45
+ toDoc: n.docId,
46
+ carriedId: o.block.blockId,
47
+ replacedMintedId: n.mintedId,
48
+ newKey: n.block.key,
49
+ kind: edited ? "edited_moved" : "moved",
50
+ confidence: p.score,
51
+ });
52
+ }
53
+ return matches;
54
+ }
55
+ /**
56
+ * Apply cross-doc matches back into per-doc reconcile results: the destination
57
+ * doc's minted 'inserted' id is replaced by the carried id (moved/edited_moved),
58
+ * and the source doc's 'deleted' disposition for that id is dropped.
59
+ */
60
+ export function applyCrossDocMatches(resultsByDoc, matches, matcherV) {
61
+ for (const m of matches) {
62
+ const dest = resultsByDoc.get(m.toDoc);
63
+ const src = resultsByDoc.get(m.fromDoc);
64
+ if (!dest || !src)
65
+ continue;
66
+ // Replace minted id with carried id in the destination assignment.
67
+ dest.assignment.set(m.newKey, m.carriedId);
68
+ // Drop the 'inserted' disposition for the minted id; add moved/edited_moved.
69
+ dest.dispositions = dest.dispositions.filter((d) => !(d.blockId === m.replacedMintedId && d.kind === "inserted"));
70
+ dest.dispositions.push({ blockId: m.carriedId, kind: m.kind, confidence: m.confidence, reason: "scored", matcherV, detail: { fromDoc: m.fromDoc } });
71
+ // Drop the 'deleted' disposition in the source doc.
72
+ src.dispositions = src.dispositions.filter((d) => !(d.blockId === m.carriedId && d.kind === "deleted"));
73
+ src.deleted = src.deleted.filter((id) => id !== m.carriedId);
74
+ }
75
+ }
76
+ //# sourceMappingURL=crossdoc.js.map
@@ -0,0 +1,21 @@
1
+ import type { MatchBlock } from "../types.js";
2
+ export type EditClass = "edit" | "insert" | "delete" | "move" | "reorder" | "split" | "merge" | "copy";
3
+ export interface GroundTruth {
4
+ /** new block key → old id it should carry (absent ⇒ should be minted) */
5
+ carries: Map<string, string>;
6
+ /** old ids expected to be deleted */
7
+ deleted: Set<string>;
8
+ /** per new key, the edit class that produced it (for per-class metrics) */
9
+ classOf: Map<string, EditClass>;
10
+ }
11
+ export interface GeneratedCase {
12
+ old: MatchBlock[];
13
+ neu: MatchBlock[];
14
+ truth: GroundTruth;
15
+ }
16
+ export declare function rng(seed: number): () => number;
17
+ /** Generate one case: a base doc plus a scripted edit of the given intensity. */
18
+ export declare function generateCase(seed: number, opts?: {
19
+ size?: number;
20
+ intensity?: number;
21
+ }): GeneratedCase;
@@ -0,0 +1,129 @@
1
+ import { sha256, normalizeVisibleText } from "../../core/hash.js";
2
+ // Small deterministic PRNG (mulberry32).
3
+ export function rng(seed) {
4
+ let a = seed >>> 0;
5
+ return () => {
6
+ a |= 0;
7
+ a = (a + 0x6d2b79f5) | 0;
8
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
9
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
10
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
11
+ };
12
+ }
13
+ const WORDS = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam quis nostrud".split(" ");
14
+ function sentence(r, n = 10) {
15
+ const out = [];
16
+ for (let i = 0; i < n; i++)
17
+ out[i] = WORDS[Math.floor(r() * WORDS.length)];
18
+ return out.join(" ");
19
+ }
20
+ function mkOld(blocks) {
21
+ return blocks.map((b, index) => {
22
+ const text = normalizeVisibleText(b.raw, "paragraph");
23
+ return {
24
+ blockId: b.id, type: "paragraph",
25
+ rawHashHex: sha256(b.raw).toString("hex"),
26
+ normHashHex: sha256(text).toString("hex"),
27
+ text, anchors: [], parentKey: null, index, key: `/${index}`,
28
+ };
29
+ });
30
+ }
31
+ function mkNew(raws) {
32
+ return raws.map((raw, index) => {
33
+ const text = normalizeVisibleText(raw, "paragraph");
34
+ return {
35
+ type: "paragraph",
36
+ rawHashHex: sha256(raw).toString("hex"),
37
+ normHashHex: sha256(text).toString("hex"),
38
+ text, anchors: [], parentKey: null, index, key: `/${index}`,
39
+ };
40
+ });
41
+ }
42
+ /** Generate one case: a base doc plus a scripted edit of the given intensity. */
43
+ export function generateCase(seed, opts = {}) {
44
+ const r = rng(seed);
45
+ const size = opts.size ?? 12;
46
+ const intensity = opts.intensity ?? 0.3;
47
+ // Base document.
48
+ const base = Array.from({ length: size }, (_, i) => ({ id: `b_${i}`, raw: sentence(r, 8 + Math.floor(r() * 8)) }));
49
+ const old = mkOld(base);
50
+ let items = base.map((b) => ({ id: b.id, raw: b.raw, cls: null }));
51
+ const deleted = new Set();
52
+ const nEdits = Math.max(1, Math.floor(size * intensity));
53
+ for (let e = 0; e < nEdits; e++) {
54
+ const roll = r();
55
+ const idx = Math.floor(r() * items.length);
56
+ if (items.length === 0)
57
+ break;
58
+ if (roll < 0.35) {
59
+ // edit: change a few words but keep majority (carry expected)
60
+ const it = items[idx];
61
+ if (it.id && it.cls === null) {
62
+ const words = it.raw.split(" ");
63
+ const changes = Math.max(1, Math.floor(words.length * 0.2));
64
+ for (let c = 0; c < changes; c++)
65
+ words[Math.floor(r() * words.length)] = WORDS[Math.floor(r() * WORDS.length)];
66
+ it.raw = words.join(" ");
67
+ it.cls = "edit";
68
+ }
69
+ }
70
+ else if (roll < 0.5) {
71
+ // insert a brand-new block
72
+ items.splice(idx, 0, { id: null, raw: sentence(r, 10), cls: "insert" });
73
+ }
74
+ else if (roll < 0.62) {
75
+ // delete
76
+ const it = items[idx];
77
+ if (it.id)
78
+ deleted.add(it.id);
79
+ items.splice(idx, 1);
80
+ }
81
+ else if (roll < 0.8) {
82
+ // move/reorder: pop and reinsert elsewhere
83
+ const [it] = items.splice(idx, 1);
84
+ if (it) {
85
+ const dest = Math.floor(r() * (items.length + 1));
86
+ if (it.cls === null && it.id)
87
+ it.cls = "move";
88
+ items.splice(dest, 0, it);
89
+ }
90
+ }
91
+ else if (roll < 0.9) {
92
+ // copy: near-duplicate an existing block (append a word so the original
93
+ // still locks uniquely and the copy is detected against it, per 03 §4).
94
+ const it = items[idx];
95
+ if (it.id)
96
+ items.splice(idx + 1, 0, { id: null, raw: `${it.raw} ${WORDS[Math.floor(r() * WORDS.length)]}`, cls: "copy" });
97
+ }
98
+ else {
99
+ // reorder two adjacent
100
+ if (idx + 1 < items.length) {
101
+ const tmp = items[idx];
102
+ items[idx] = items[idx + 1];
103
+ items[idx + 1] = tmp;
104
+ if (items[idx].id && items[idx].cls === null)
105
+ items[idx].cls = "reorder";
106
+ }
107
+ }
108
+ }
109
+ const neu = mkNew(items.map((it) => it.raw));
110
+ const carries = new Map();
111
+ const classOf = new Map();
112
+ items.forEach((it, index) => {
113
+ const key = `/${index}`;
114
+ if (it.id && !deleted.has(it.id)) {
115
+ carries.set(key, it.id);
116
+ classOf.set(key, it.cls ?? "edit");
117
+ }
118
+ else if (it.cls) {
119
+ classOf.set(key, it.cls);
120
+ }
121
+ });
122
+ // Any old id not present in carries and not explicitly deleted is a delete.
123
+ const carriedIds = new Set(carries.values());
124
+ for (const b of base)
125
+ if (!carriedIds.has(b.id))
126
+ deleted.add(b.id);
127
+ return { old, neu, truth: { carries, deleted, classOf } };
128
+ }
129
+ //# sourceMappingURL=generator.js.map
@@ -0,0 +1,32 @@
1
+ import type { ReconcileConfig } from "../types.js";
2
+ import { type EditClass, type GeneratedCase } from "./generator.js";
3
+ export interface ClassMetrics {
4
+ truePairs: number;
5
+ carriedPairs: number;
6
+ correctCarries: number;
7
+ precision: number;
8
+ recall: number;
9
+ }
10
+ export interface EvalReport {
11
+ cases: number;
12
+ overall: ClassMetrics;
13
+ perClass: Record<string, ClassMetrics>;
14
+ config: ReconcileConfig;
15
+ }
16
+ declare function blank(): {
17
+ truePairs: number;
18
+ carriedPairs: number;
19
+ correctCarries: number;
20
+ };
21
+ export declare function evaluateCase(c: GeneratedCase, config?: ReconcileConfig): {
22
+ overall: ReturnType<typeof blank>;
23
+ perClass: Map<string, ReturnType<typeof blank>>;
24
+ };
25
+ /** Run the synthetic suite over `n` seeded cases and produce the report. */
26
+ export declare function runEval(opts?: {
27
+ n?: number;
28
+ size?: number;
29
+ intensity?: number;
30
+ config?: ReconcileConfig;
31
+ }): EvalReport;
32
+ export type { EditClass };
@@ -0,0 +1,74 @@
1
+ import { reconcileDocument } from "../reconcile.js";
2
+ import { generateCase } from "./generator.js";
3
+ function blank() {
4
+ return { truePairs: 0, carriedPairs: 0, correctCarries: 0 };
5
+ }
6
+ export function evaluateCase(c, config) {
7
+ const res = reconcileDocument(c.old, c.neu, config ? { config } : {});
8
+ const overall = blank();
9
+ const perClass = new Map();
10
+ const bucket = (cls) => perClass.get(cls) ?? perClass.set(cls, blank()).get(cls);
11
+ // True pairs: ground-truth carries.
12
+ for (const [key, oldId] of c.truth.carries) {
13
+ overall.truePairs++;
14
+ const cls = c.truth.classOf.get(key) ?? "edit";
15
+ bucket(cls).truePairs++;
16
+ const got = res.assignment.get(key);
17
+ if (got === oldId) {
18
+ overall.correctCarries++;
19
+ bucket(cls).correctCarries++;
20
+ }
21
+ }
22
+ // Carried pairs: assignments where the matcher reused an OLD id (not minted).
23
+ const oldIds = new Set(c.old.map((b) => b.blockId));
24
+ const truthByKey = c.truth.carries;
25
+ for (const [key, id] of res.assignment) {
26
+ if (oldIds.has(id)) {
27
+ overall.carriedPairs++;
28
+ const cls = c.truth.classOf.get(key) ?? "edit";
29
+ bucket(cls).carriedPairs++;
30
+ // correctness already tallied above when it matches truth; recount for
31
+ // precision denominators handled via correctCarries computed there.
32
+ void truthByKey;
33
+ }
34
+ }
35
+ return { overall, perClass };
36
+ }
37
+ function finalize(agg) {
38
+ return {
39
+ ...agg,
40
+ precision: agg.carriedPairs === 0 ? 1 : agg.correctCarries / agg.carriedPairs,
41
+ recall: agg.truePairs === 0 ? 1 : agg.correctCarries / agg.truePairs,
42
+ };
43
+ }
44
+ /** Run the synthetic suite over `n` seeded cases and produce the report. */
45
+ export function runEval(opts = {}) {
46
+ const n = opts.n ?? 200;
47
+ const overall = blank();
48
+ const perClass = new Map();
49
+ for (let seed = 1; seed <= n; seed++) {
50
+ const genOpts = {};
51
+ if (opts.size !== undefined)
52
+ genOpts.size = opts.size;
53
+ if (opts.intensity !== undefined)
54
+ genOpts.intensity = opts.intensity;
55
+ const c = generateCase(seed, genOpts);
56
+ const r = evaluateCase(c, opts.config);
57
+ overall.truePairs += r.overall.truePairs;
58
+ overall.carriedPairs += r.overall.carriedPairs;
59
+ overall.correctCarries += r.overall.correctCarries;
60
+ for (const [cls, agg] of r.perClass) {
61
+ const b = perClass.get(cls) ?? perClass.set(cls, blank()).get(cls);
62
+ b.truePairs += agg.truePairs;
63
+ b.carriedPairs += agg.carriedPairs;
64
+ b.correctCarries += agg.correctCarries;
65
+ }
66
+ }
67
+ const perClassOut = {};
68
+ for (const [cls, agg] of perClass)
69
+ perClassOut[cls] = finalize(agg);
70
+ // config echo: default when not provided
71
+ const cfg = opts.config ?? undefined;
72
+ return { cases: n, overall: finalize(overall), perClass: perClassOut, config: cfg };
73
+ }
74
+ //# sourceMappingURL=metrics.js.map
@@ -0,0 +1,12 @@
1
+ import type { MatchBlock } from "./types.js";
2
+ import type { TreeInputBlock } from "../core/store/writers.js";
3
+ export interface FlatSource {
4
+ blockId?: string;
5
+ type: string;
6
+ raw: string;
7
+ anchors?: string[];
8
+ children: FlatSource[];
9
+ }
10
+ /** Adapt a parsed RawBlock-ish tree (no ids) to FlatSource. */
11
+ export declare function fromInput(blocks: TreeInputBlock[]): FlatSource[];
12
+ export declare function flatten(blocks: FlatSource[]): MatchBlock[];