@omgbase/core 0.1.0 → 0.2.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 (76) hide show
  1. package/README.md +172 -0
  2. package/dist/src/cli/eval-matcher.d.ts +6 -1
  3. package/dist/src/cli/eval-matcher.js +14 -2
  4. package/dist/src/core/hash.d.ts +25 -1
  5. package/dist/src/core/hash.js +110 -10
  6. package/dist/src/core/ids.d.ts +12 -0
  7. package/dist/src/core/ids.js +43 -1
  8. package/dist/src/core/ingest.d.ts +22 -2
  9. package/dist/src/core/ingest.js +92 -18
  10. package/dist/src/core/parse/parse.d.ts +1 -1
  11. package/dist/src/core/parse/parse.js +31 -9
  12. package/dist/src/core/store/nodes.d.ts +13 -0
  13. package/dist/src/core/store/nodes.js +24 -0
  14. package/dist/src/core/utf8.d.ts +29 -0
  15. package/dist/src/core/utf8.js +96 -0
  16. package/dist/src/format/adapter.d.ts +7 -1
  17. package/dist/src/format/markdown.js +9 -18
  18. package/dist/src/graph/history.d.ts +6 -0
  19. package/dist/src/graph/history.js +13 -2
  20. package/dist/src/index.d.ts +8 -5
  21. package/dist/src/index.js +8 -5
  22. package/dist/src/mcp/graph.js +8 -4
  23. package/dist/src/mcp/reference.d.ts +1 -1
  24. package/dist/src/mcp/reference.js +17 -6
  25. package/dist/src/mcp/server.js +12 -7
  26. package/dist/src/mutate/apply.d.ts +7 -0
  27. package/dist/src/mutate/apply.js +17 -7
  28. package/dist/src/mutate/docs.d.ts +2 -0
  29. package/dist/src/mutate/docs.js +10 -6
  30. package/dist/src/mutate/known-ids.js +9 -1
  31. package/dist/src/mutate/macros.js +9 -2
  32. package/dist/src/mutate/ops.d.ts +2 -1
  33. package/dist/src/mutate/ops.js +24 -2
  34. package/dist/src/mutate/plan-update.d.ts +7 -1
  35. package/dist/src/mutate/plan-update.js +3 -1
  36. package/dist/src/oqx-js/context.js +43 -6
  37. package/dist/src/reconcile/eval/generator.d.ts +24 -1
  38. package/dist/src/reconcile/eval/generator.js +248 -0
  39. package/dist/src/reconcile/eval/metrics.d.ts +33 -9
  40. package/dist/src/reconcile/eval/metrics.js +55 -32
  41. package/dist/src/reconcile/flatten.js +7 -5
  42. package/dist/src/reconcile/phase5.d.ts +2 -1
  43. package/dist/src/reconcile/phase5.js +27 -10
  44. package/dist/src/reconcile/phase6a.js +17 -5
  45. package/dist/src/reconcile/phases.d.ts +44 -6
  46. package/dist/src/reconcile/phases.js +0 -0
  47. package/dist/src/reconcile/reconcile.js +16 -9
  48. package/dist/src/reconcile/types.d.ts +4 -1
  49. package/dist/src/reconcile/types.js +11 -2
  50. package/dist/src/search/external.d.ts +10 -0
  51. package/dist/src/search/external.js +18 -1
  52. package/dist/src/search/provider.d.ts +7 -3
  53. package/dist/src/search/provider.js +8 -7
  54. package/dist/src/search/rrf.js +3 -0
  55. package/dist/src/search/vector.js +21 -6
  56. package/dist/src/sync/attach.d.ts +7 -2
  57. package/dist/src/sync/attach.js +10 -8
  58. package/dist/src/sync/checkpoint.d.ts +18 -1
  59. package/dist/src/sync/checkpoint.js +31 -29
  60. package/dist/src/sync/driver.d.ts +15 -4
  61. package/dist/src/sync/driver.js +18 -43
  62. package/dist/src/sync/external-source.js +7 -1
  63. package/dist/src/sync/freshness.d.ts +65 -14
  64. package/dist/src/sync/freshness.js +107 -96
  65. package/dist/src/sync/fs-util.d.ts +19 -0
  66. package/dist/src/sync/fs-util.js +24 -1
  67. package/dist/src/sync/observe.d.ts +53 -5
  68. package/dist/src/sync/observe.js +162 -21
  69. package/dist/src/sync/reconciling-ingest.d.ts +42 -2
  70. package/dist/src/sync/reconciling-ingest.js +82 -10
  71. package/dist/src/sync/recovery.d.ts +5 -1
  72. package/dist/src/sync/recovery.js +8 -8
  73. package/dist/src/sync/workspace.d.ts +11 -3
  74. package/dist/src/sync/workspace.js +39 -30
  75. package/dist/src/sync/writer-lock.js +16 -26
  76. package/package.json +3 -3
@@ -1,6 +1,7 @@
1
1
  import type { Store } from "../core/store/store.js";
2
2
  import { type ReconcileConfig } from "../reconcile/types.js";
3
3
  import { type ApplyResult } from "./apply.js";
4
+ import type { DocStore } from "./doc-store.js";
4
5
  import { type Opset } from "./opset.js";
5
6
  export interface PlanUpdateOptions {
6
7
  config?: ReconcileConfig;
@@ -13,7 +14,10 @@ export interface PlanUpdateOptions {
13
14
  export declare function planUpdate(store: Store, repoId: string, rootPath: string, docRef: string, content: string, opts?: PlanUpdateOptions): Opset;
14
15
  export interface ApplyOpsetRequest {
15
16
  repoId: string;
16
- rootPath: string;
17
+ /** Working-tree root for the default filesystem write target; omit only with an explicit `docStore`. */
18
+ rootPath?: string;
19
+ /** Write target (ADR-014 §5); defaults to a filesystem store at `rootPath`. */
20
+ docStore?: DocStore;
17
21
  opset: Opset;
18
22
  origin: {
19
23
  actor: string;
@@ -21,6 +25,8 @@ export interface ApplyOpsetRequest {
21
25
  };
22
26
  dryRun?: boolean;
23
27
  omgbaseDir?: string;
28
+ /** Commit timestamp (RFC 3339 UTC); defaults to now (see `ApplyRequest.ts`). */
29
+ ts?: string;
24
30
  }
25
31
  /**
26
32
  * Apply an opset through the kernel write path. Validates the plan's
@@ -159,11 +159,13 @@ export function applyOpset(store, req) {
159
159
  }
160
160
  return apply(store, {
161
161
  repoId: req.repoId,
162
- rootPath: req.rootPath,
162
+ ...(req.rootPath !== undefined ? { rootPath: req.rootPath } : {}),
163
+ ...(req.docStore !== undefined ? { docStore: req.docStore } : {}),
163
164
  ops: opsetKernelOps(opset),
164
165
  origin: req.origin,
165
166
  ...(req.dryRun !== undefined ? { dryRun: req.dryRun } : {}),
166
167
  ...(req.omgbaseDir !== undefined ? { omgbaseDir: req.omgbaseDir } : {}),
168
+ ...(req.ts !== undefined ? { ts: req.ts } : {}),
167
169
  ...(opset.frontmatter ? { setFrontmatter: [{ doc: opset.precondition.doc, raw: opset.frontmatter.raw }] } : {}),
168
170
  });
169
171
  }
@@ -159,11 +159,49 @@ export function makeStoreContext(store, repoId, opts = {}) {
159
159
  const r = one(`SELECT ordinal FROM blocks WHERE doc_id = ? AND block_id = ?`, block.doc_id, first);
160
160
  return r?.ordinal ?? block.ordinal;
161
161
  };
162
+ // ---- a document's live blocks in DOCUMENT order (spec/surface §1.2) --------
163
+ // Pre-order over the containment forest (children by parent_block, siblings by
164
+ // (ordinal, block_id)); each row is paired with its top-level ancestor's
165
+ // ordinal, which is what section ranges are expressed in.
166
+ const docBlocksPreorder = (docId, path) => {
167
+ const rows = all(`SELECT b.*, ? AS __path FROM blocks b WHERE b.doc_id = ? AND b.deleted_commit IS NULL ORDER BY b.ordinal, b.block_id`, path, docId);
168
+ const ids = new Set(rows.map((r) => r.block_id));
169
+ const byParent = new Map();
170
+ for (const r of rows) {
171
+ const parent = r.parent_block != null && ids.has(r.parent_block) ? r.parent_block : null;
172
+ let list = byParent.get(parent);
173
+ if (!list)
174
+ byParent.set(parent, (list = []));
175
+ list.push(r);
176
+ }
177
+ const out = [];
178
+ const walk = (parent, top) => {
179
+ for (const r of byParent.get(parent) ?? []) {
180
+ const t = top ?? r.ordinal;
181
+ out.push({ row: r, top: t });
182
+ walk(r.block_id, t);
183
+ }
184
+ };
185
+ walk(null, null);
186
+ return out;
187
+ };
188
+ // A document's nodes in DOCUMENT order: by the owning block's pre-order rank
189
+ // (block-less nodes first), then span_start, then node_id. Node ids are content
190
+ // hashes over random doc ids, so an id order would differ per ingest.
191
+ const docNodesInOrder = (docId, path) => {
192
+ const rank = new Map();
193
+ docBlocksPreorder(docId, path).forEach((x, i) => rank.set(x.row.block_id, i));
194
+ const rows = all(`SELECT n.*, ? AS __path FROM nodes n WHERE n.doc_id = ?`, path, docId);
195
+ const pos = (n) => (n.block_id == null ? -1 : rank.get(n.block_id) ?? Number.MAX_SAFE_INTEGER);
196
+ const span = (n) => (typeof n.span_start === "number" ? n.span_start : -1);
197
+ rows.sort((a, b) => pos(a) - pos(b) || span(a) - span(b) || (a.node_id < b.node_id ? -1 : a.node_id === b.node_id ? 0 : 1));
198
+ return tagAll(rows, "nodes");
199
+ };
162
200
  // ---- relations (return row arrays) ----------------------------------------
163
201
  const rel = {
164
202
  docs: {
165
- nodes: (r) => tagAll(all(`SELECT n.*, ? AS __path FROM nodes n WHERE n.doc_id = ?`, r.path, r.doc_id), "nodes"),
166
- blocks: (r) => tagAll(all(`SELECT b.*, ? AS __path FROM blocks b WHERE b.doc_id = ? AND b.deleted_commit IS NULL ORDER BY b.ordinal, b.block_id`, r.path, r.doc_id), "blocks"),
203
+ nodes: (r) => docNodesInOrder(r.doc_id, r.path),
204
+ blocks: (r) => tagAll(docBlocksPreorder(r.doc_id, r.path).map((x) => x.row), "blocks"),
167
205
  out: (r) => tagAll(all(`SELECT DISTINCT d2.* FROM docs d2 JOIN edges e ON e.dst_node = d2.doc_id
168
206
  WHERE e.src_doc = ? AND e.to_commit IS NULL AND d2.repo_id = ? AND d2.deleted_commit IS NULL ORDER BY d2.path, d2.doc_id`, r.doc_id, repoId), "docs"),
169
207
  in: (r) => tagAll(all(`SELECT DISTINCT d2.* FROM docs d2 JOIN edges e ON e.src_doc = d2.doc_id
@@ -174,7 +212,7 @@ export function makeStoreContext(store, repoId, opts = {}) {
174
212
  },
175
213
  blocks: {
176
214
  children: (r) => tagAll(all(`SELECT b.*, ? AS __path FROM blocks b WHERE b.parent_block = ? AND b.deleted_commit IS NULL ORDER BY b.ordinal, b.block_id`, r.__path, r.block_id), "blocks"),
177
- nodes: (r) => tagAll(all(`SELECT n.*, ? AS __path FROM nodes n WHERE n.block_id = ?`, r.__path, r.block_id), "nodes"),
215
+ nodes: (r) => tagAll(all(`SELECT n.*, ? AS __path FROM nodes n WHERE n.block_id = ? ORDER BY n.span_start, n.node_id`, r.__path, r.block_id), "nodes"),
178
216
  out_edges: (r) => tagAll(all(`SELECT e.*, ? AS __path FROM edges e WHERE e.src_block = ? AND e.to_commit IS NULL ORDER BY e.predicate, e.edge_id`, r.__path, r.block_id), "edges"),
179
217
  // enclosing section node(s): md:section whose range contains this block's top ordinal.
180
218
  section: (r) => {
@@ -190,9 +228,8 @@ export function makeStoreContext(store, repoId, opts = {}) {
190
228
  const f = jattr(r, "first_ordinal"), l = jattr(r, "last_ordinal");
191
229
  if (f == null || l == null)
192
230
  return [];
193
- // blocks whose top-level ordinal falls in the section range.
194
- const rows = all(`SELECT b.*, ? AS __path FROM blocks b WHERE b.doc_id = ? AND b.deleted_commit IS NULL ORDER BY b.ordinal, b.block_id`, r.__path, r.doc_id);
195
- const kept = rows.filter((b) => { const t = topOrdinal(b); return t >= f && t <= l; });
231
+ // blocks whose top-level ancestor's ordinal falls in the section range, in document order.
232
+ const kept = docBlocksPreorder(r.doc_id, r.__path).filter((x) => x.top >= f && x.top <= l).map((x) => x.row);
196
233
  return tagAll(kept, "blocks");
197
234
  },
198
235
  subsections: (r) => {
@@ -1,12 +1,22 @@
1
1
  import type { MatchBlock } from "../types.js";
2
2
  export type EditClass = "edit" | "insert" | "delete" | "move" | "reorder" | "split" | "merge" | "copy";
3
+ /** Structured-mode classes (list-item edits; see generateStructuredCase). */
4
+ export type StructuredEditClass = "item-edit-mid" | "item-edit-last" | "item-insert" | "item-delete" | "item-move" | "item-gains-nested-list" | "item-reorder"
5
+ /** an item deleted and an unrelated item inserted in its slot (the new item must mint; m2.3 singleton-rule cost) */
6
+ | "item-replace"
7
+ /** the list container whose items were edited (must carry) */
8
+ | "list"
9
+ /** any block the script did not touch (must carry) */
10
+ | "same"
11
+ /** the paragraph / nested list / nested items minted under an item that gained a nested list */
12
+ | "nested-new";
3
13
  export interface GroundTruth {
4
14
  /** new block key → old id it should carry (absent ⇒ should be minted) */
5
15
  carries: Map<string, string>;
6
16
  /** old ids expected to be deleted */
7
17
  deleted: Set<string>;
8
18
  /** per new key, the edit class that produced it (for per-class metrics) */
9
- classOf: Map<string, EditClass>;
19
+ classOf: Map<string, EditClass | StructuredEditClass>;
10
20
  }
11
21
  export interface GeneratedCase {
12
22
  old: MatchBlock[];
@@ -19,3 +29,16 @@ export declare function generateCase(seed: number, opts?: {
19
29
  size?: number;
20
30
  intensity?: number;
21
31
  }): GeneratedCase;
32
+ export interface StructuredOpts {
33
+ /** top-level blocks in the base document (default 10) */
34
+ size?: number;
35
+ /** edits per case ≈ size × intensity (default 0.3 ⇒ 3) */
36
+ intensity?: number;
37
+ /** words per list item; a number fixes it, absent ⇒ 2–6 per item */
38
+ itemWords?: number;
39
+ }
40
+ type Cls = StructuredEditClass;
41
+ export declare const STRUCTURED_ITEM_CLASSES: readonly Cls[];
42
+ /** Generate one structured case (see the mode comment above). */
43
+ export declare function generateStructuredCase(seed: number, opts?: StructuredOpts): GeneratedCase;
44
+ export {};
@@ -1,4 +1,5 @@
1
1
  import { sha256, normalizeVisibleText } from "../../core/hash.js";
2
+ import { flatten } from "../flatten.js";
2
3
  // Small deterministic PRNG (mulberry32).
3
4
  export function rng(seed) {
4
5
  let a = seed >>> 0;
@@ -126,4 +127,251 @@ export function generateCase(seed, opts = {}) {
126
127
  deleted.add(b.id);
127
128
  return { old, neu, truth: { carries, deleted, classOf } };
128
129
  }
130
+ // A larger vocabulary than the flat mode's so short items stay unique within a
131
+ // document (ground truth needs distinguishable items; duplicates would make the
132
+ // "right" id a coin toss the matcher cannot be blamed for).
133
+ const ITEM_WORDS = ("alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa " +
134
+ "quebec romeo sierra tango uniform victor whiskey xray yankee zulu apple banana cherry damson elder " +
135
+ "fig grape hazel iris jasmine kale lemon mango nectar olive peach quince radish sage thyme umber " +
136
+ "violet walnut yarrow zest amber birch cedar dune ember flint gorse heath ivy jade kelp lotus moss " +
137
+ "nickel onyx pearl quartz reed slate tundra vale willow").split(" ");
138
+ function itemToSource(it, withId) {
139
+ const own = it.words.join(" ");
140
+ if (it.nested === null) {
141
+ const s = { type: "list_item", raw: `- ${own}`, children: [] };
142
+ if (withId && it.id)
143
+ s.blockId = it.id;
144
+ return s;
145
+ }
146
+ // Parser shape: item raw keeps the nested lines; children are the folded
147
+ // paragraph and the nested list; the nested list's first line starts at content.
148
+ const nestedItemRaws = it.nested.map((w) => `- ${w}`);
149
+ const nestedList = {
150
+ type: "list",
151
+ raw: nestedItemRaws.join("\n "),
152
+ children: nestedItemRaws.map((raw) => ({ type: "list_item", raw, children: [] })),
153
+ };
154
+ const s = {
155
+ type: "list_item",
156
+ raw: `- ${own}\n ${nestedItemRaws.join("\n ")}`,
157
+ children: [{ type: "paragraph", raw: own, children: [] }, nestedList],
158
+ };
159
+ if (withId && it.id)
160
+ s.blockId = it.id;
161
+ return s;
162
+ }
163
+ function blockToSource(b, withId) {
164
+ if (b.kind === "list") {
165
+ const children = b.items.map((it) => itemToSource(it, withId));
166
+ const s = { type: "list", raw: children.map((c) => c.raw).join("\n"), children };
167
+ if (withId)
168
+ s.blockId = b.id;
169
+ return s;
170
+ }
171
+ const s = { type: b.kind, raw: b.raw, children: [] };
172
+ if (withId)
173
+ s.blockId = b.id;
174
+ return s;
175
+ }
176
+ /** Walk a FlatSource tree in flatten()'s order, yielding the same positional keys. */
177
+ function walkKeys(list, parentKey, fn) {
178
+ list.forEach((b, index) => {
179
+ const key = `${parentKey ?? ""}/${index}`;
180
+ fn(b, key);
181
+ walkKeys(b.children, key, fn);
182
+ });
183
+ }
184
+ export const STRUCTURED_ITEM_CLASSES = [
185
+ "item-edit-mid", "item-edit-last", "item-insert", "item-delete", "item-move", "item-gains-nested-list", "item-reorder",
186
+ "item-replace",
187
+ ];
188
+ /** Generate one structured case (see the mode comment above). */
189
+ export function generateStructuredCase(seed, opts = {}) {
190
+ // Offset the seed space so structured cases never share a PRNG stream with flat ones.
191
+ const r = rng((seed + 0x5a5a0000) >>> 0);
192
+ const size = opts.size ?? 10;
193
+ const intensity = opts.intensity ?? 0.3;
194
+ const pick = (arr) => arr[Math.floor(r() * arr.length)];
195
+ const wordCount = () => opts.itemWords ?? 2 + Math.floor(r() * 5);
196
+ let nextId = 0;
197
+ const mint = () => `b_${nextId++}`;
198
+ const used = new Set(); // item texts, unique across the document
199
+ const freshWords = (n) => {
200
+ for (let attempt = 0; attempt < 50; attempt++) {
201
+ const w = Array.from({ length: n }, () => pick(ITEM_WORDS));
202
+ const key = w.join(" ");
203
+ if (!used.has(key)) {
204
+ used.add(key);
205
+ return w;
206
+ }
207
+ }
208
+ // Vocabulary exhausted for this length (only plausible for 1-word items): accept a repeat.
209
+ return Array.from({ length: n }, () => pick(ITEM_WORDS));
210
+ };
211
+ const replaceWord = (words, at) => {
212
+ // Replace so the resulting item text is new to the document (bounded retries).
213
+ for (let attempt = 0; attempt < 50; attempt++) {
214
+ const w = pick(ITEM_WORDS);
215
+ if (w === words[at])
216
+ continue;
217
+ const trial = [...words];
218
+ trial[at] = w;
219
+ const key = trial.join(" ");
220
+ if (!used.has(key)) {
221
+ used.add(key);
222
+ words[at] = w;
223
+ return;
224
+ }
225
+ }
226
+ words[at] = pick(ITEM_WORDS.filter((w) => w !== words[at]));
227
+ };
228
+ // Base document: ids are assigned in document (pre-order) order.
229
+ const blocks = [];
230
+ for (let i = 0; i < size; i++) {
231
+ const roll = r();
232
+ // Guarantee two lists so there is always something to edit.
233
+ const wantList = roll < 0.5 || (i >= size - 2 && blocks.filter((b) => b.kind === "list").length < 2);
234
+ if (wantList) {
235
+ const id = mint();
236
+ const n = 3 + Math.floor(r() * 5); // 3–7 items
237
+ const items = Array.from({ length: n }, () => ({ id: mint(), words: freshWords(wordCount()), cls: null, nested: null }));
238
+ blocks.push({ kind: "list", id, items, cls: null });
239
+ }
240
+ else if (roll < 0.65) {
241
+ blocks.push({ kind: "heading", id: mint(), raw: `${"#".repeat(1 + Math.floor(r() * 3))} ${sentence(r, 2 + Math.floor(r() * 3))}` });
242
+ }
243
+ else {
244
+ blocks.push({ kind: "paragraph", id: mint(), raw: sentence(r, 8 + Math.floor(r() * 8)) });
245
+ }
246
+ }
247
+ const oldTree = blocks.map((b) => blockToSource(b, true));
248
+ const old = flatten(oldTree);
249
+ // Scripted item edits.
250
+ const deleted = new Set();
251
+ const lists = blocks.filter((b) => b.kind === "list");
252
+ const nEdits = Math.max(1, Math.floor(size * intensity));
253
+ for (let e = 0; e < nEdits; e++) {
254
+ const list = pick(lists);
255
+ const cls = pick(STRUCTURED_ITEM_CLASSES);
256
+ const untouched = list.items.map((it, i) => [it, i]).filter(([it]) => it.id !== null && it.cls === null);
257
+ if (untouched.length === 0)
258
+ continue;
259
+ const [it, idx] = pick(untouched);
260
+ switch (cls) {
261
+ case "item-edit-mid": {
262
+ // change 1–2 words, none of them the last one
263
+ const positions = it.words.length - 1;
264
+ if (positions < 1)
265
+ continue;
266
+ const changes = Math.min(positions, 1 + Math.floor(r() * 2));
267
+ const chosen = new Set();
268
+ while (chosen.size < changes)
269
+ chosen.add(Math.floor(r() * positions));
270
+ used.delete(it.words.join(" "));
271
+ for (const at of chosen)
272
+ replaceWord(it.words, at);
273
+ it.cls = cls;
274
+ break;
275
+ }
276
+ case "item-edit-last": {
277
+ used.delete(it.words.join(" "));
278
+ replaceWord(it.words, it.words.length - 1);
279
+ it.cls = cls;
280
+ break;
281
+ }
282
+ case "item-insert": {
283
+ const at = Math.floor(r() * (list.items.length + 1));
284
+ list.items.splice(at, 0, { id: null, words: freshWords(wordCount()), cls, nested: null });
285
+ break;
286
+ }
287
+ case "item-delete": {
288
+ if (list.items.length < 3)
289
+ continue;
290
+ deleted.add(it.id);
291
+ list.items.splice(idx, 1);
292
+ break;
293
+ }
294
+ case "item-move": {
295
+ if (list.items.length < 3)
296
+ continue;
297
+ list.items.splice(idx, 1);
298
+ let dest = Math.floor(r() * list.items.length);
299
+ if (dest >= idx)
300
+ dest++; // any position other than the original
301
+ list.items.splice(Math.min(dest, list.items.length), 0, it);
302
+ it.cls = cls;
303
+ break;
304
+ }
305
+ case "item-reorder": {
306
+ const j = idx + 1 < list.items.length ? idx + 1 : idx - 1;
307
+ if (j < 0)
308
+ continue;
309
+ const other = list.items[j];
310
+ if (other.id === null || other.cls !== null)
311
+ continue;
312
+ list.items[idx] = other;
313
+ list.items[j] = it;
314
+ it.cls = cls;
315
+ other.cls = cls;
316
+ break;
317
+ }
318
+ case "item-replace": {
319
+ // Delete the item and put an unrelated fresh item in its slot: the same
320
+ // shape the singleton rule (spec/reconcile §5 phase 4a step 5) reads as
321
+ // an edit, so this class measures its cost (the new item must mint).
322
+ deleted.add(it.id);
323
+ list.items[idx] = { id: null, words: freshWords(wordCount()), cls, nested: null };
324
+ break;
325
+ }
326
+ case "item-gains-nested-list": {
327
+ it.nested = Array.from({ length: 2 + Math.floor(r() * 2) }, () => freshWords(1)[0]);
328
+ it.cls = cls;
329
+ break;
330
+ }
331
+ default:
332
+ continue;
333
+ }
334
+ list.cls = "list";
335
+ }
336
+ // New tree: built with the truth ids attached, harvested, then stripped.
337
+ const newTree = blocks.map((b) => blockToSource(b, true));
338
+ const carries = new Map();
339
+ const classOf = new Map();
340
+ const clsByKey = new Map();
341
+ {
342
+ // Classes by key: mirror blockToSource's shape.
343
+ const listIdx = new Map();
344
+ blocks.forEach((b, i) => listIdx.set(b, i));
345
+ for (const b of blocks) {
346
+ const bk = `/${listIdx.get(b)}`;
347
+ if (b.kind !== "list") {
348
+ clsByKey.set(bk, "same");
349
+ continue;
350
+ }
351
+ clsByKey.set(bk, b.cls ?? "same");
352
+ b.items.forEach((it, i) => {
353
+ const ik = `${bk}/${i}`;
354
+ clsByKey.set(ik, it.cls ?? (it.id === null ? "item-insert" : "same"));
355
+ if (it.nested !== null) {
356
+ clsByKey.set(`${ik}/0`, "nested-new");
357
+ clsByKey.set(`${ik}/1`, "nested-new");
358
+ it.nested.forEach((_, j) => clsByKey.set(`${ik}/1/${j}`, "nested-new"));
359
+ }
360
+ });
361
+ }
362
+ }
363
+ walkKeys(newTree, null, (b, key) => {
364
+ const cls = clsByKey.get(key) ?? "same";
365
+ classOf.set(key, cls);
366
+ if (b.blockId && !deleted.has(b.blockId))
367
+ carries.set(key, b.blockId);
368
+ delete b.blockId;
369
+ });
370
+ const neu = flatten(newTree);
371
+ const carriedIds = new Set(carries.values());
372
+ for (const b of old)
373
+ if (!carriedIds.has(b.blockId))
374
+ deleted.add(b.blockId);
375
+ return { old, neu, truth: { carries, deleted, classOf } };
376
+ }
129
377
  //# sourceMappingURL=generator.js.map
@@ -1,6 +1,8 @@
1
- import type { ReconcileConfig } from "../types.js";
2
- import { type EditClass, type GeneratedCase } from "./generator.js";
1
+ import { type ReconcileConfig } from "../types.js";
2
+ import { type EditClass, type GeneratedCase, type StructuredEditClass } from "./generator.js";
3
3
  export interface ClassMetrics {
4
+ /** new blocks of this class (carried or minted in truth) */
5
+ newBlocks: number;
4
6
  truePairs: number;
5
7
  carriedPairs: number;
6
8
  correctCarries: number;
@@ -9,24 +11,46 @@ export interface ClassMetrics {
9
11
  }
10
12
  export interface EvalReport {
11
13
  cases: number;
14
+ mode: EvalMode;
12
15
  overall: ClassMetrics;
13
16
  perClass: Record<string, ClassMetrics>;
17
+ /** old ids the truth deletes, and how many of them the matcher carried anyway */
18
+ deleted: {
19
+ expected: number;
20
+ wronglyCarried: number;
21
+ };
22
+ /** the effective config the suite ran under */
14
23
  config: ReconcileConfig;
15
24
  }
16
- declare function blank(): {
25
+ export type EvalMode = "flat" | "structured";
26
+ type Agg = {
27
+ newBlocks: number;
17
28
  truePairs: number;
18
29
  carriedPairs: number;
19
30
  correctCarries: number;
20
31
  };
21
32
  export declare function evaluateCase(c: GeneratedCase, config?: ReconcileConfig): {
22
- overall: ReturnType<typeof blank>;
23
- perClass: Map<string, ReturnType<typeof blank>>;
33
+ overall: Agg;
34
+ perClass: Map<string, Agg>;
35
+ deleted: {
36
+ expected: number;
37
+ wronglyCarried: number;
38
+ };
24
39
  };
25
- /** Run the synthetic suite over `n` seeded cases and produce the report. */
26
- export declare function runEval(opts?: {
40
+ export interface RunEvalOptions {
27
41
  n?: number;
42
+ /** flat: top-level paragraphs per doc (12); structured: top-level blocks per doc (10) */
28
43
  size?: number;
29
44
  intensity?: number;
45
+ /** structured mode only: words per list item (absent ⇒ 2–6 per item) */
46
+ itemWords?: number;
47
+ /** "flat" (default; the release-gate suite) or "structured" (lists of short items) */
48
+ mode?: EvalMode;
49
+ /** full config; defaults to DEFAULT_CONFIG */
30
50
  config?: ReconcileConfig;
31
- }): EvalReport;
32
- export type { EditClass };
51
+ /** fields to override on top of `config` (e.g. `{ contextSimFloor: 0.3 }`) */
52
+ configOverrides?: Partial<ReconcileConfig>;
53
+ }
54
+ /** Run the synthetic suite over `n` seeded cases and produce the report. */
55
+ export declare function runEval(opts?: RunEvalOptions): EvalReport;
56
+ export type { EditClass, StructuredEditClass };
@@ -1,38 +1,51 @@
1
1
  import { reconcileDocument } from "../reconcile.js";
2
- import { generateCase } from "./generator.js";
2
+ import { DEFAULT_CONFIG } from "../types.js";
3
+ import { generateCase, generateStructuredCase, } from "./generator.js";
3
4
  function blank() {
4
- return { truePairs: 0, carriedPairs: 0, correctCarries: 0 };
5
+ return { newBlocks: 0, truePairs: 0, carriedPairs: 0, correctCarries: 0 };
6
+ }
7
+ function add(into, from) {
8
+ into.newBlocks += from.newBlocks;
9
+ into.truePairs += from.truePairs;
10
+ into.carriedPairs += from.carriedPairs;
11
+ into.correctCarries += from.correctCarries;
5
12
  }
6
13
  export function evaluateCase(c, config) {
7
14
  const res = reconcileDocument(c.old, c.neu, config ? { config } : {});
8
15
  const overall = blank();
9
16
  const perClass = new Map();
10
17
  const bucket = (cls) => perClass.get(cls) ?? perClass.set(cls, blank()).get(cls);
18
+ // Flat mode leaves untouched blocks unclassified and they count as "edit"
19
+ // (kept: the release gates were tuned against that bucketing).
20
+ const classOf = (key) => c.truth.classOf.get(key) ?? "edit";
21
+ for (const b of c.neu) {
22
+ overall.newBlocks++;
23
+ bucket(classOf(b.key)).newBlocks++;
24
+ }
11
25
  // True pairs: ground-truth carries.
12
26
  for (const [key, oldId] of c.truth.carries) {
13
27
  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) {
28
+ bucket(classOf(key)).truePairs++;
29
+ if (res.assignment.get(key) === oldId) {
18
30
  overall.correctCarries++;
19
- bucket(cls).correctCarries++;
31
+ bucket(classOf(key)).correctCarries++;
20
32
  }
21
33
  }
22
34
  // Carried pairs: assignments where the matcher reused an OLD id (not minted).
23
35
  const oldIds = new Set(c.old.map((b) => b.blockId));
24
- const truthByKey = c.truth.carries;
36
+ const carriedOld = new Set();
25
37
  for (const [key, id] of res.assignment) {
26
38
  if (oldIds.has(id)) {
27
39
  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;
40
+ bucket(classOf(key)).carriedPairs++;
41
+ carriedOld.add(id);
33
42
  }
34
43
  }
35
- return { overall, perClass };
44
+ let wronglyCarried = 0;
45
+ for (const id of c.truth.deleted)
46
+ if (carriedOld.has(id))
47
+ wronglyCarried++;
48
+ return { overall, perClass, deleted: { expected: c.truth.deleted.size, wronglyCarried } };
36
49
  }
37
50
  function finalize(agg) {
38
51
  return {
@@ -44,31 +57,41 @@ function finalize(agg) {
44
57
  /** Run the synthetic suite over `n` seeded cases and produce the report. */
45
58
  export function runEval(opts = {}) {
46
59
  const n = opts.n ?? 200;
60
+ const mode = opts.mode ?? "flat";
61
+ const config = { ...(opts.config ?? DEFAULT_CONFIG), ...(opts.configOverrides ?? {}) };
47
62
  const overall = blank();
48
63
  const perClass = new Map();
64
+ const deleted = { expected: 0, wronglyCarried: 0 };
49
65
  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;
66
+ let c;
67
+ if (mode === "structured") {
68
+ const genOpts = {};
69
+ if (opts.size !== undefined)
70
+ genOpts.size = opts.size;
71
+ if (opts.intensity !== undefined)
72
+ genOpts.intensity = opts.intensity;
73
+ if (opts.itemWords !== undefined)
74
+ genOpts.itemWords = opts.itemWords;
75
+ c = generateStructuredCase(seed, genOpts);
76
+ }
77
+ else {
78
+ const genOpts = {};
79
+ if (opts.size !== undefined)
80
+ genOpts.size = opts.size;
81
+ if (opts.intensity !== undefined)
82
+ genOpts.intensity = opts.intensity;
83
+ c = generateCase(seed, genOpts);
65
84
  }
85
+ const r = evaluateCase(c, config);
86
+ add(overall, r.overall);
87
+ for (const [cls, agg] of r.perClass)
88
+ add(perClass.get(cls) ?? perClass.set(cls, blank()).get(cls), agg);
89
+ deleted.expected += r.deleted.expected;
90
+ deleted.wronglyCarried += r.deleted.wronglyCarried;
66
91
  }
67
92
  const perClassOut = {};
68
93
  for (const [cls, agg] of perClass)
69
94
  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 };
95
+ return { cases: n, mode, overall: finalize(overall), perClass: perClassOut, deleted, config };
73
96
  }
74
97
  //# sourceMappingURL=metrics.js.map
@@ -1,4 +1,4 @@
1
- import { sha256, normalizeVisibleText } from "../core/hash.js";
1
+ import { sha256, childQuoteDepth, visibleText } from "../core/hash.js";
2
2
  /** Adapt a parsed RawBlock-ish tree (no ids) to FlatSource. */
3
3
  export function fromInput(blocks) {
4
4
  return blocks.map((b) => ({
@@ -10,10 +10,12 @@ export function fromInput(blocks) {
10
10
  }
11
11
  export function flatten(blocks) {
12
12
  const out = [];
13
- const walk = (list, parentKey) => {
13
+ // quoteDepth = blockquote ancestors; text follows spec/format §4.1 (containers
14
+ // compose from children, nested raws lose their `> ` prefixes).
15
+ const walk = (list, parentKey, quoteDepth) => {
14
16
  list.forEach((b, index) => {
15
17
  const key = `${parentKey ?? ""}/${index}`;
16
- const text = normalizeVisibleText(b.raw, b.type);
18
+ const text = visibleText(b, quoteDepth);
17
19
  const mb = {
18
20
  type: b.type,
19
21
  rawHashHex: sha256(b.raw).toString("hex"),
@@ -28,10 +30,10 @@ export function flatten(blocks) {
28
30
  mb.blockId = b.blockId;
29
31
  out.push(mb);
30
32
  if (b.children.length > 0)
31
- walk(b.children, key);
33
+ walk(b.children, key, childQuoteDepth(b.type, quoteDepth));
32
34
  });
33
35
  };
34
- walk(blocks, null);
36
+ walk(blocks, null, 0);
35
37
  return out;
36
38
  }
37
39
  //# sourceMappingURL=flatten.js.map
@@ -3,6 +3,7 @@ import type { PhaseState } from "./phases.js";
3
3
  * Phase 5 — scored assignment. Greedy-by-score with R3 order constraints:
4
4
  * accept the highest-scoring candidate, discard conflicting candidates
5
5
  * (same old or new, or order-crossing with an already-accepted same-parent
6
- * pair), repeat. Accept while score ≥ θ (θ_small for tiny blocks).
6
+ * pair), repeat. Accept a candidate when score ≥ θ (θ_small for tiny blocks);
7
+ * a candidate below its θ is skipped, not a stopping point.
7
8
  */
8
9
  export declare function phase5Scored(state: PhaseState): void;