foottrace 0.0.0-stage → 1.0.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 (196) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -2
  3. package/dist/esm/index.d.ts +27 -0
  4. package/dist/esm/index.js +12 -0
  5. package/dist/esm/lib/capture/freeze.d.ts +91 -0
  6. package/dist/esm/lib/capture/freeze.js +313 -0
  7. package/dist/esm/lib/capture/ownData.d.ts +18 -0
  8. package/dist/esm/lib/capture/ownData.js +35 -0
  9. package/dist/esm/lib/capture/valueKinds.d.ts +57 -0
  10. package/dist/esm/lib/capture/valueKinds.js +141 -0
  11. package/dist/esm/lib/ids/runtimeStageId.d.ts +173 -0
  12. package/dist/esm/lib/ids/runtimeStageId.js +193 -0
  13. package/dist/esm/lib/memory/EventLog.d.ts +105 -0
  14. package/dist/esm/lib/memory/EventLog.js +147 -0
  15. package/dist/esm/lib/memory/RecordFrame.d.ts +232 -0
  16. package/dist/esm/lib/memory/RecordFrame.js +295 -0
  17. package/dist/esm/lib/memory/SharedMemory.d.ts +46 -0
  18. package/dist/esm/lib/memory/SharedMemory.js +87 -0
  19. package/dist/esm/lib/memory/TransactionBuffer.d.ts +521 -0
  20. package/dist/esm/lib/memory/TransactionBuffer.js +946 -0
  21. package/dist/esm/lib/memory/admission.d.ts +81 -0
  22. package/dist/esm/lib/memory/admission.js +152 -0
  23. package/dist/esm/lib/memory/backtrack.d.ts +286 -0
  24. package/dist/esm/lib/memory/backtrack.js +456 -0
  25. package/dist/esm/lib/memory/commitLogUtils.d.ts +217 -0
  26. package/dist/esm/lib/memory/commitLogUtils.js +383 -0
  27. package/dist/esm/lib/memory/deltaEncoding.d.ts +66 -0
  28. package/dist/esm/lib/memory/deltaEncoding.js +121 -0
  29. package/dist/esm/lib/memory/equality.d.ts +93 -0
  30. package/dist/esm/lib/memory/equality.js +261 -0
  31. package/dist/esm/lib/memory/eventPosition.d.ts +18 -0
  32. package/dist/esm/lib/memory/eventPosition.js +1 -0
  33. package/dist/esm/lib/memory/honesty.d.ts +93 -0
  34. package/dist/esm/lib/memory/honesty.js +91 -0
  35. package/dist/esm/lib/memory/keyPaths.d.ts +125 -0
  36. package/dist/esm/lib/memory/keyPaths.js +226 -0
  37. package/dist/esm/lib/memory/logModel.d.ts +76 -0
  38. package/dist/esm/lib/memory/logModel.js +363 -0
  39. package/dist/esm/lib/memory/merge.d.ts +26 -0
  40. package/dist/esm/lib/memory/merge.js +51 -0
  41. package/dist/esm/lib/memory/pathOps.d.ts +118 -0
  42. package/dist/esm/lib/memory/pathOps.js +292 -0
  43. package/dist/esm/lib/memory/paths.d.ts +56 -0
  44. package/dist/esm/lib/memory/paths.js +60 -0
  45. package/dist/esm/lib/memory/placeholders.d.ts +39 -0
  46. package/dist/esm/lib/memory/placeholders.js +39 -0
  47. package/dist/esm/lib/memory/recordCommit.d.ts +45 -0
  48. package/dist/esm/lib/memory/recordCommit.js +60 -0
  49. package/dist/esm/lib/memory/scrub.d.ts +52 -0
  50. package/dist/esm/lib/memory/scrub.js +72 -0
  51. package/dist/esm/lib/memory/types.d.ts +209 -0
  52. package/dist/esm/lib/memory/types.js +9 -0
  53. package/dist/esm/lib/memory/utils.d.ts +73 -0
  54. package/dist/esm/lib/memory/utils.js +150 -0
  55. package/dist/esm/lib/memory/verbs.d.ts +333 -0
  56. package/dist/esm/lib/memory/verbs.js +426 -0
  57. package/dist/esm/lib/recorder/CommitRangeIndex.d.ts +147 -0
  58. package/dist/esm/lib/recorder/CommitRangeIndex.js +206 -0
  59. package/dist/esm/lib/slice/elementProvenance.d.ts +81 -0
  60. package/dist/esm/lib/slice/elementProvenance.js +189 -0
  61. package/dist/esm/lib/slice/forwardSliceForKey.d.ts +88 -0
  62. package/dist/esm/lib/slice/forwardSliceForKey.js +242 -0
  63. package/dist/esm/lib/slice/index.d.ts +26 -0
  64. package/dist/esm/lib/slice/index.js +25 -0
  65. package/dist/esm/lib/slice/keyIndex.d.ts +137 -0
  66. package/dist/esm/lib/slice/keyIndex.js +292 -0
  67. package/dist/esm/lib/slice/keyTimeline.d.ts +49 -0
  68. package/dist/esm/lib/slice/keyTimeline.js +112 -0
  69. package/dist/esm/lib/slice/keysReadSources.d.ts +51 -0
  70. package/dist/esm/lib/slice/keysReadSources.js +94 -0
  71. package/dist/esm/lib/slice/serialize.d.ts +78 -0
  72. package/dist/esm/lib/slice/serialize.js +277 -0
  73. package/dist/esm/lib/slice/sliceForKey.d.ts +40 -0
  74. package/dist/esm/lib/slice/sliceForKey.js +78 -0
  75. package/dist/esm/lib/slice/types.d.ts +567 -0
  76. package/dist/esm/lib/slice/types.js +24 -0
  77. package/dist/esm/lib/time-travel/axis.d.ts +119 -0
  78. package/dist/esm/lib/time-travel/axis.js +178 -0
  79. package/dist/esm/lib/time-travel/bundles.d.ts +77 -0
  80. package/dist/esm/lib/time-travel/bundles.js +135 -0
  81. package/dist/esm/lib/time-travel/chain.d.ts +93 -0
  82. package/dist/esm/lib/time-travel/chain.js +187 -0
  83. package/dist/esm/lib/time-travel/commitStops.d.ts +55 -0
  84. package/dist/esm/lib/time-travel/commitStops.js +201 -0
  85. package/dist/esm/lib/time-travel/index.d.ts +22 -0
  86. package/dist/esm/lib/time-travel/index.js +20 -0
  87. package/dist/esm/lib/time-travel/stateAt.d.ts +102 -0
  88. package/dist/esm/lib/time-travel/stateAt.js +152 -0
  89. package/dist/esm/lib/time-travel/tagStops.d.ts +72 -0
  90. package/dist/esm/lib/time-travel/tagStops.js +101 -0
  91. package/dist/esm/lib/time-travel/timeTravel.d.ts +70 -0
  92. package/dist/esm/lib/time-travel/timeTravel.js +359 -0
  93. package/dist/esm/lib/time-travel/types.d.ts +470 -0
  94. package/dist/esm/lib/time-travel/types.js +22 -0
  95. package/dist/esm/package.json +1 -0
  96. package/dist/esm/paths.d.ts +4 -0
  97. package/dist/esm/paths.js +4 -0
  98. package/dist/esm/write.d.ts +49 -0
  99. package/dist/esm/write.js +47 -0
  100. package/dist/index.js +67 -0
  101. package/dist/lib/capture/freeze.js +319 -0
  102. package/dist/lib/capture/ownData.js +40 -0
  103. package/dist/lib/capture/valueKinds.js +145 -0
  104. package/dist/lib/ids/runtimeStageId.js +209 -0
  105. package/dist/lib/memory/EventLog.js +151 -0
  106. package/dist/lib/memory/RecordFrame.js +299 -0
  107. package/dist/lib/memory/SharedMemory.js +91 -0
  108. package/dist/lib/memory/TransactionBuffer.js +950 -0
  109. package/dist/lib/memory/admission.js +158 -0
  110. package/dist/lib/memory/backtrack.js +462 -0
  111. package/dist/lib/memory/commitLogUtils.js +398 -0
  112. package/dist/lib/memory/deltaEncoding.js +127 -0
  113. package/dist/lib/memory/equality.js +265 -0
  114. package/dist/lib/memory/eventPosition.js +2 -0
  115. package/dist/lib/memory/honesty.js +94 -0
  116. package/dist/lib/memory/keyPaths.js +242 -0
  117. package/dist/lib/memory/logModel.js +371 -0
  118. package/dist/lib/memory/merge.js +55 -0
  119. package/dist/lib/memory/pathOps.js +308 -0
  120. package/dist/lib/memory/paths.js +66 -0
  121. package/dist/lib/memory/placeholders.js +42 -0
  122. package/dist/lib/memory/recordCommit.js +64 -0
  123. package/dist/lib/memory/scrub.js +77 -0
  124. package/dist/lib/memory/types.js +10 -0
  125. package/dist/lib/memory/utils.js +169 -0
  126. package/dist/lib/memory/verbs.js +443 -0
  127. package/dist/lib/recorder/CommitRangeIndex.js +210 -0
  128. package/dist/lib/slice/elementProvenance.js +194 -0
  129. package/dist/lib/slice/forwardSliceForKey.js +246 -0
  130. package/dist/lib/slice/index.js +42 -0
  131. package/dist/lib/slice/keyIndex.js +312 -0
  132. package/dist/lib/slice/keyTimeline.js +116 -0
  133. package/dist/lib/slice/keysReadSources.js +100 -0
  134. package/dist/lib/slice/serialize.js +285 -0
  135. package/dist/lib/slice/sliceForKey.js +82 -0
  136. package/dist/lib/slice/types.js +25 -0
  137. package/dist/lib/time-travel/axis.js +183 -0
  138. package/dist/lib/time-travel/bundles.js +143 -0
  139. package/dist/lib/time-travel/chain.js +192 -0
  140. package/dist/lib/time-travel/commitStops.js +205 -0
  141. package/dist/lib/time-travel/index.js +31 -0
  142. package/dist/lib/time-travel/stateAt.js +159 -0
  143. package/dist/lib/time-travel/tagStops.js +105 -0
  144. package/dist/lib/time-travel/timeTravel.js +363 -0
  145. package/dist/lib/time-travel/types.js +23 -0
  146. package/dist/paths.js +14 -0
  147. package/dist/types/index.d.ts +27 -0
  148. package/dist/types/lib/capture/freeze.d.ts +91 -0
  149. package/dist/types/lib/capture/ownData.d.ts +18 -0
  150. package/dist/types/lib/capture/valueKinds.d.ts +57 -0
  151. package/dist/types/lib/ids/runtimeStageId.d.ts +173 -0
  152. package/dist/types/lib/memory/EventLog.d.ts +105 -0
  153. package/dist/types/lib/memory/RecordFrame.d.ts +232 -0
  154. package/dist/types/lib/memory/SharedMemory.d.ts +46 -0
  155. package/dist/types/lib/memory/TransactionBuffer.d.ts +521 -0
  156. package/dist/types/lib/memory/admission.d.ts +81 -0
  157. package/dist/types/lib/memory/backtrack.d.ts +286 -0
  158. package/dist/types/lib/memory/commitLogUtils.d.ts +217 -0
  159. package/dist/types/lib/memory/deltaEncoding.d.ts +66 -0
  160. package/dist/types/lib/memory/equality.d.ts +93 -0
  161. package/dist/types/lib/memory/eventPosition.d.ts +18 -0
  162. package/dist/types/lib/memory/honesty.d.ts +93 -0
  163. package/dist/types/lib/memory/keyPaths.d.ts +125 -0
  164. package/dist/types/lib/memory/logModel.d.ts +76 -0
  165. package/dist/types/lib/memory/merge.d.ts +26 -0
  166. package/dist/types/lib/memory/pathOps.d.ts +118 -0
  167. package/dist/types/lib/memory/paths.d.ts +56 -0
  168. package/dist/types/lib/memory/placeholders.d.ts +39 -0
  169. package/dist/types/lib/memory/recordCommit.d.ts +45 -0
  170. package/dist/types/lib/memory/scrub.d.ts +52 -0
  171. package/dist/types/lib/memory/types.d.ts +209 -0
  172. package/dist/types/lib/memory/utils.d.ts +73 -0
  173. package/dist/types/lib/memory/verbs.d.ts +333 -0
  174. package/dist/types/lib/recorder/CommitRangeIndex.d.ts +147 -0
  175. package/dist/types/lib/slice/elementProvenance.d.ts +81 -0
  176. package/dist/types/lib/slice/forwardSliceForKey.d.ts +88 -0
  177. package/dist/types/lib/slice/index.d.ts +26 -0
  178. package/dist/types/lib/slice/keyIndex.d.ts +137 -0
  179. package/dist/types/lib/slice/keyTimeline.d.ts +49 -0
  180. package/dist/types/lib/slice/keysReadSources.d.ts +51 -0
  181. package/dist/types/lib/slice/serialize.d.ts +78 -0
  182. package/dist/types/lib/slice/sliceForKey.d.ts +40 -0
  183. package/dist/types/lib/slice/types.d.ts +567 -0
  184. package/dist/types/lib/time-travel/axis.d.ts +119 -0
  185. package/dist/types/lib/time-travel/bundles.d.ts +77 -0
  186. package/dist/types/lib/time-travel/chain.d.ts +93 -0
  187. package/dist/types/lib/time-travel/commitStops.d.ts +55 -0
  188. package/dist/types/lib/time-travel/index.d.ts +22 -0
  189. package/dist/types/lib/time-travel/stateAt.d.ts +102 -0
  190. package/dist/types/lib/time-travel/tagStops.d.ts +72 -0
  191. package/dist/types/lib/time-travel/timeTravel.d.ts +70 -0
  192. package/dist/types/lib/time-travel/types.d.ts +470 -0
  193. package/dist/types/paths.d.ts +4 -0
  194. package/dist/types/write.d.ts +49 -0
  195. package/dist/write.js +56 -0
  196. package/package.json +48 -4
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ /**
3
+ * admission.ts — the admitted record's check (9.30.0): does a commit's bundle
4
+ * fold back to what its stage read? (docs/design/2026-10-admitted-record.md)
5
+ *
6
+ * THE LAW: a commit is admitted only if its bundle, replayed onto the state
7
+ * the stage began from, gives back the stage's read-your-writes view at every
8
+ * path the stage touched — and at every container and array slot its writes
9
+ * made on the way there, below the stage's address.
10
+ *
11
+ * Pure: handed the candidate rows, the stage's diff base, its working copy,
12
+ * its op trace and its address, {@link lossyFamilies} answers which families
13
+ * of touched paths do not fold back. `TransactionBuffer · admit` asks once per
14
+ * commit that staged a `merge` or a nested op, and lays the rows out again
15
+ * with those families written as what the stage read. The fold is the one
16
+ * verb law ({@link dryFold}, no clone); the compare is {@link deepEqual}.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.addressDepthOf = exports.touchedFamilies = exports.lossyFamilies = void 0;
20
+ const pathOps_js_1 = require("./pathOps.js");
21
+ const utils_js_1 = require("./utils.js");
22
+ /**
23
+ * The families of `ops` whose candidate rows do not fold back to what the
24
+ * stage read — `undefined` when every family does. Each family ROOT is
25
+ * compared ({@link foldsBack}): its value and the way down to it.
26
+ */
27
+ function lossyFamilies(candidate, base, workingCopy, ops, address) {
28
+ const fold = (0, utils_js_1.dryFold)(base, candidate.updates, candidate.overwrite, candidate.trace);
29
+ const { rootOf, families } = touchedFamilies(ops);
30
+ let lossy;
31
+ for (const family of families.values()) {
32
+ if (!foldsBack(fold, workingCopy, family, address))
33
+ (lossy ??= new Map()).set(family.root, family);
34
+ }
35
+ return lossy === undefined ? undefined : { rootOf, families: lossy };
36
+ }
37
+ exports.lossyFamilies = lossyFamilies;
38
+ /**
39
+ * Does the fold give back what the stage read at a family root? The way down
40
+ * ({@link spineHeld}), then the value by {@link deepEqual} (a root covers every
41
+ * descendant; an own `undefined` is a deleted key) — against the read-back as
42
+ * a record can hold it. A record holds `structuredClone`s of what a stage
43
+ * wrote (state values must survive one), and a few values do not survive one
44
+ * whole: an `Error`'s own fields are dropped. When the fold equals the
45
+ * read-back's clone, no row could hold more, and the family is admitted as it
46
+ * is; the clone, taken only for a root the cheap compare refused, is kept for
47
+ * the re-encoding.
48
+ */
49
+ function foldsBack(fold, workingCopy, family, address) {
50
+ const segments = family.rootSegments;
51
+ if (!spineHeld(fold, workingCopy, segments, address))
52
+ return false;
53
+ const folded = (0, pathOps_js_1.nativeGet)(fold, segments);
54
+ const read = (0, pathOps_js_1.nativeGet)(workingCopy, segments);
55
+ // Both sides are the same values as a record holds them: an opaque one (a Blob) is never the same object.
56
+ if ((0, utils_js_1.deepEqual)(folded, read, 'copies'))
57
+ return true;
58
+ family.held = { value: structuredClone(read) };
59
+ return (0, utils_js_1.deepEqual)(folded, family.held.value, 'copies');
60
+ }
61
+ /**
62
+ * Every staged path grouped under its SHALLOWEST staged ancestor-or-self —
63
+ * over TOUCHED paths, not surviving ones: an op the net-change filter drops
64
+ * can still have changed what the stage reads (L-1). Members are listed by
65
+ * last touch; a family's slot is its last op.
66
+ */
67
+ function touchedFamilies(ops) {
68
+ const touches = new Map();
69
+ for (let i = 0; i < ops.length; i++) {
70
+ touches.delete(ops[i].path); // re-insert: the Map's order is last-touch order
71
+ touches.set(ops[i].path, { last: i, readKeys: ops[i].readKeys });
72
+ }
73
+ const rootOf = new Map();
74
+ const families = new Map();
75
+ for (const [path, touch] of touches) {
76
+ const root = shallowestIn(path, touches);
77
+ rootOf.set(path, root);
78
+ let family = families.get(root);
79
+ if (family === undefined) {
80
+ family = { root, rootSegments: root.split(utils_js_1.DELIM), members: [], slot: touch.last };
81
+ families.set(root, family);
82
+ }
83
+ family.members.push({ path, readKeys: touch.readKeys });
84
+ if (touch.last > family.slot)
85
+ family.slot = touch.last;
86
+ }
87
+ return { rootOf, families };
88
+ }
89
+ exports.touchedFamilies = touchedFamilies;
90
+ /**
91
+ * How many leading segments of `path` are the stage's `address` — its length
92
+ * when `path` lies strictly below it, else 0.
93
+ */
94
+ function addressDepthOf(path, address) {
95
+ const depth = address.length;
96
+ if (depth === 0 || path.length <= depth)
97
+ return 0;
98
+ for (let i = 0; i < depth; i++)
99
+ if (String(path[i]) !== address[i])
100
+ return 0;
101
+ return depth;
102
+ }
103
+ exports.addressDepthOf = addressDepthOf;
104
+ /**
105
+ * L-1: a nested op through an absent or primitive parent makes that parent a
106
+ * container in the working copy — and when the op itself changes nothing (a
107
+ * delete of a key that was never there) no row is left to make it in the
108
+ * fold, so the stage reads back a container the record never mentions. The
109
+ * same op on an ARRAY parent past its end grows it (`delete a.0` on `[]`
110
+ * reads back `[undefined]`). Every container the working copy holds on the
111
+ * way to `segments`, and every array slot each step lands in, must be one the
112
+ * fold holds too — below the stage's address, whose containers are where it
113
+ * writes, not a value it reads.
114
+ */
115
+ function spineHeld(fold, workingCopy, segments, address) {
116
+ const from = addressDepthOf(segments, address);
117
+ let f = fold;
118
+ let w = workingCopy;
119
+ for (let i = 0; i < segments.length; i++) {
120
+ if (i >= from && !slotHeld(f, w, segments[i]))
121
+ return false;
122
+ if (i === segments.length - 1)
123
+ return true;
124
+ f = (0, pathOps_js_1.ownChild)(f, segments[i]);
125
+ w = (0, pathOps_js_1.ownChild)(w, segments[i]);
126
+ if (!(0, pathOps_js_1.isContainer)(w))
127
+ return true;
128
+ if (i >= from && (!(0, pathOps_js_1.isContainer)(f) || Array.isArray(f) !== Array.isArray(w)))
129
+ return false;
130
+ }
131
+ return true;
132
+ }
133
+ /**
134
+ * Does the fold hold every array slot the working copy's container `w` holds
135
+ * at `key`? Only an array can be grown by a write that changes nothing else
136
+ * (an index past its end, set to `undefined`); an object's own `undefined`
137
+ * key is a deleted key to every reader, so objects always pass.
138
+ */
139
+ function slotHeld(f, w, key) {
140
+ if (!Array.isArray(w))
141
+ return true;
142
+ const index = Number(key);
143
+ if (!Number.isInteger(index) || index < 0 || String(index) !== key || index >= w.length)
144
+ return true;
145
+ return Array.isArray(f) && index < f.length;
146
+ }
147
+ /** The shallowest of `path` and its ancestors that `touched` holds — its family's root. */
148
+ function shallowestIn(path, touched) {
149
+ if (path.indexOf(utils_js_1.DELIM) === -1)
150
+ return path;
151
+ const segments = path.split(utils_js_1.DELIM);
152
+ for (let i = 1; i < segments.length; i++) {
153
+ const ancestor = segments.slice(0, i).join(utils_js_1.DELIM);
154
+ if (touched.has(ancestor))
155
+ return ancestor;
156
+ }
157
+ return path;
158
+ }
@@ -0,0 +1,462 @@
1
+ "use strict";
2
+ /**
3
+ * backtrack.ts — Backward causal chain analysis on the commit log.
4
+ *
5
+ * Implements **backward program slicing** (Weiser 1984, thin-slice variant):
6
+ * given a starting execution step, walk backwards through read→write
7
+ * dependencies to build the causal DAG that produced the data at that step.
8
+ *
9
+ * ## Algorithm
10
+ *
11
+ * BFS on the implicit dependency graph where edges run from reader → writer.
12
+ *
13
+ * 1. Locate startId in commitLog → root node
14
+ * 2. Get keysRead for root via `getKeysRead` callback
15
+ * 3. For each key read, find who last wrote it before this step → parent commit
16
+ * ("wrote" is the writer rule of `keyPaths.ts`, 9.33.0: a row on the key, inside it, or
17
+ * around it that changed it — so a stage that read `cfg` links to the subflow seed or
18
+ * merge-back that wrote `cfg␟b`)
19
+ * 4. Create parent CausalNode, link to root.parents
20
+ * 5. Enqueue parent. Repeat until queue empty or limits hit.
21
+ *
22
+ * Output is a **DAG** (not a linked list): a stage reading `creditScore` AND `dti`
23
+ * from different writers has two parents.
24
+ *
25
+ * ## Staged Optimization
26
+ *
27
+ * Two writer-lookup strategies, chosen automatically by commit log size:
28
+ *
29
+ * | Strategy | When | Complexity per lookup |
30
+ * |----------|------|----------------------|
31
+ * | Linear scan | N ≤ 256 | O(N) — simple backward scan |
32
+ * | Reverse index | N > 256 | O(K log N) — prebuilt key→[indices], binary search |
33
+ *
34
+ * The threshold (256) is chosen so the O(N) build cost of the reverse index
35
+ * is amortized over the BFS traversal. Below 256, linear scan wins because
36
+ * there's no index build overhead. The consumer never sees this — `causalChain()`
37
+ * picks the right strategy internally (like a query optimizer choosing between
38
+ * sequential scan vs index scan based on table size).
39
+ *
40
+ * ## Complexity
41
+ *
42
+ * - **Small logs (N ≤ 256):** O(V × K × N) total. V=visited, K=avg keys/node.
43
+ * - **Large logs (N > 256):** O(N × U) index build + O(V × K × log N) lookups.
44
+ * U = unique keys. Amortized over all BFS hops.
45
+ *
46
+ * ## References
47
+ *
48
+ * - Weiser, M. (1984). "Program Slicing." IEEE TSE.
49
+ * - Sridharan, M. et al. (2007). "Thin Slicing." PLDI.
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * import { causalChain, flattenCausalDAG, formatCausalChain } from 'foottrace';
54
+ *
55
+ * const dag = causalChain(commitLog, 'decide#2', (id) => recorder.getKeysRead(id));
56
+ * const flat = flattenCausalDAG(dag); // BFS-ordered flat list
57
+ * console.log(formatCausalChain(dag)); // human-readable
58
+ * ```
59
+ */
60
+ Object.defineProperty(exports, "__esModule", { value: true });
61
+ exports._REVERSE_INDEX_THRESHOLD = exports.formatCausalChain = exports.flattenCausalDAG = exports.causalChain = void 0;
62
+ const commitLogUtils_js_1 = require("./commitLogUtils.js");
63
+ const keyPaths_js_1 = require("./keyPaths.js");
64
+ const logModel_js_1 = require("./logModel.js");
65
+ // ── Staged optimization: writer lookup strategies ──────────────────────
66
+ /**
67
+ * Threshold for switching from linear scan to reverse index.
68
+ * Below this, O(N) scan is faster (no index build cost).
69
+ * Above this, O(log N) binary search wins.
70
+ */
71
+ const REVERSE_INDEX_THRESHOLD = 256;
72
+ /** Strategy 1: Linear scan — O(N) per lookup, zero setup cost. */
73
+ function linearScanLookup(commitLog) {
74
+ return (key, beforeIdx) => (0, commitLogUtils_js_1.findLastWriter)(commitLog, key, beforeIdx);
75
+ }
76
+ /**
77
+ * Strategy 2: Reverse index — one O(rows) build, O(depth + log N) per lookup.
78
+ * The log's read model (`logModel.ts`: a path trie, each top-level key folded at
79
+ * most once) answers the writer rule — rows on, inside or around the key — by
80
+ * binary search for the last write on or inside it, then a verdict only for the
81
+ * around-candidates after it. The same answer `findLastWriter` gives, so the
82
+ * strategy switch below never changes a slice.
83
+ */
84
+ function reverseIndexLookup(commitLog) {
85
+ const model = (0, logModel_js_1.logModel)(commitLog);
86
+ return (key, beforeIdx) => {
87
+ const at = model.lastWriterBefore(key, beforeIdx);
88
+ return at >= 0 ? commitLog[at] : undefined;
89
+ };
90
+ }
91
+ /**
92
+ * The trace entries of `commit` that make it a writer of `linkKey` — the entries whose read
93
+ * prefix (`TraceEntry.readKeys`) a per-write edge expands through. The FIRST entry on the exact
94
+ * path when there is one (the #P1 behaviour, unchanged); otherwise every entry inside or around
95
+ * the key (9.33.0: the commit wrote the key through a nested row — a subflow seed, an
96
+ * outputMapper merge-back — and all of those rows wrote it).
97
+ */
98
+ function linkingEntries(commit, linkKey) {
99
+ const exact = commit.trace.find((t) => t.path === linkKey);
100
+ if (exact !== undefined)
101
+ return [exact];
102
+ return commit.trace.filter((t) => (0, keyPaths_js_1.relation)(t.path, linkKey) !== undefined);
103
+ }
104
+ /**
105
+ * Staged optimization: pick the right writer-lookup strategy based on data size.
106
+ *
107
+ * Like a database query optimizer choosing between sequential scan and index scan:
108
+ *
109
+ * - **Small log (≤ 256):** Linear scan wins. Zero setup cost, good cache locality.
110
+ * The overhead of building a reverse index isn't worth it for short logs.
111
+ *
112
+ * - **Large log (> 256):** Reverse index wins. O(N×U) upfront build cost is amortized
113
+ * across all BFS hops. Each lookup becomes O(log N) via binary search instead of O(N).
114
+ * For an agent loop with 500 iterations and 5 keys per hop, this is 500×5×log(500)≈22K ops
115
+ * vs 500×5×500=1.25M ops with linear scan.
116
+ *
117
+ * The caller never sees this — `causalChain()` picks automatically.
118
+ */
119
+ function createWriterLookup(commitLog) {
120
+ if (commitLog.length <= REVERSE_INDEX_THRESHOLD) {
121
+ return linearScanLookup(commitLog);
122
+ }
123
+ return reverseIndexLookup(commitLog);
124
+ }
125
+ // ── Core algorithm ─────────────────────────────────────────────────────
126
+ /**
127
+ * RFC-003 D2: the `incompleteSources` node fragment for a commit — `{}`
128
+ * when the stage consumed no untracked read paths, keeping the field
129
+ * ABSENT (not empty-array-valued) for fully-tracked stages.
130
+ */
131
+ function incompleteSourcesFragment(commit) {
132
+ if (!commit.untrackedSources || commit.untrackedSources.length === 0)
133
+ return {};
134
+ return { incompleteSources: commit.untrackedSources };
135
+ }
136
+ /**
137
+ * Build the causal DAG rooted at `startId` by walking backwards
138
+ * through read→write dependencies in the commit log.
139
+ *
140
+ * Automatically selects the optimal writer lookup strategy:
141
+ * - Linear scan for small logs (≤ 256 commits)
142
+ * - Reverse index with binary search for large logs (> 256 commits)
143
+ *
144
+ * Produces a DAG (not a tree): if two children both read from the same
145
+ * parent, the parent node is shared (deduped by runtimeStageId).
146
+ *
147
+ * @param commitLog Ordered commit bundles from executor.getSnapshot().commitLog
148
+ * @param startId runtimeStageId to start backtracking from
149
+ * @param getKeysRead Callback returning keys read by a given execution step
150
+ * @param options Depth and node limits
151
+ * @returns Root CausalNode with .parents forming the DAG, or undefined if startId not found
152
+ */
153
+ function causalChain(commitLog, startId, getKeysRead, options) {
154
+ const maxDepth = options?.maxDepth ?? 20;
155
+ const maxNodes = options?.maxNodes ?? 100;
156
+ const controlDeps = options?.controlDeps;
157
+ const weigh = options?.weigh;
158
+ const perWrite = options?.edgeAttribution === 'per-write';
159
+ // RFC-003 D4 — truncation visibility. Set only when a limit actually
160
+ // cuts the slice; surfaced on the root as `truncated` so a consumer can
161
+ // never mistake a truncated slice for a complete one.
162
+ let truncatedByDepth = false;
163
+ let truncatedByNodes = false;
164
+ // Build position index: runtimeStageId → array position (O(n) once)
165
+ const idxMap = new Map();
166
+ for (let i = 0; i < commitLog.length; i++) {
167
+ idxMap.set(commitLog[i].runtimeStageId, i);
168
+ }
169
+ keyPaths_js_1.queryWork.units += commitLog.length;
170
+ const startIdx = idxMap.get(startId);
171
+ if (startIdx === undefined)
172
+ return undefined;
173
+ const startCommit = commitLog[startIdx];
174
+ // Pick writer lookup strategy based on log size
175
+ const findWriter = createWriterLookup(commitLog);
176
+ // Node dedup map: runtimeStageId → CausalNode (ensures DAG, not tree)
177
+ const nodeMap = new Map();
178
+ const root = {
179
+ runtimeStageId: startId,
180
+ stageId: startCommit.stageId,
181
+ stageName: startCommit.stage,
182
+ keysWritten: startCommit.trace.map((t) => t.path),
183
+ linkedBy: '',
184
+ depth: 0,
185
+ parents: [],
186
+ parentEdges: [],
187
+ ...incompleteSourcesFragment(startCommit),
188
+ };
189
+ nodeMap.set(startId, root);
190
+ // ── #P1 per-write attribution machinery (inert under 'stage') ──────────
191
+ // expandedReads: per node, the reads already queued for expansion — late
192
+ // links via additional keys re-enqueue only the DELTA, so the slice grows
193
+ // monotonically toward the stage-level ceiling and terminates (each key
194
+ // expands at most once per node). fullyExpanded: nodes that fell back to
195
+ // stage level (no readKeys on a linking entry) — nothing left to add.
196
+ const expandedReads = new Map();
197
+ const fullyExpanded = new Set();
198
+ /**
199
+ * Resolve a node's expansion read-set for the given linking WRITTEN keys.
200
+ * Per-write mode with provenance present → union of the linking writes'
201
+ * temporal-prefix `readKeys`. Any linking entry WITHOUT `readKeys` (or no
202
+ * linkKeys at all) → honest stage-level fallback, flagged via the second
203
+ * tuple member so the caller marks the node fully expanded.
204
+ */
205
+ function readsForLinks(commit, linkKeys) {
206
+ if (!perWrite || !linkKeys || linkKeys.length === 0) {
207
+ return [getKeysRead(commit.runtimeStageId), true];
208
+ }
209
+ const union = new Set();
210
+ for (const linkKey of linkKeys) {
211
+ const entries = linkingEntries(commit, linkKey);
212
+ if (entries.length === 0 || entries.some((e) => e.readKeys === undefined)) {
213
+ // Mixed/dial-off log — degrade THIS node to stage level, honestly.
214
+ return [getKeysRead(commit.runtimeStageId), true];
215
+ }
216
+ for (const entry of entries)
217
+ for (const rk of entry.readKeys)
218
+ union.add(rk);
219
+ }
220
+ return [[...union], false];
221
+ }
222
+ // BFS/worklist queue: [node, commitIdx, depth, keysToExpand]
223
+ const [rootReads, rootIsFull] = perWrite
224
+ ? readsForLinks(startCommit, options?.rootLinkKeys)
225
+ : [getKeysRead(startId), true];
226
+ expandedReads.set(startId, new Set(rootReads));
227
+ if (rootIsFull)
228
+ fullyExpanded.add(startId);
229
+ const queue = [[root, startIdx, 0, rootReads]];
230
+ let visited = 1;
231
+ /**
232
+ * Link `node → parent` (creating + enqueueing the parent when new).
233
+ * Shared by data-edge expansion (read→write) and control-edge expansion
234
+ * (D3). One CausalEdge per distinct (parent, kind, key) link; `parents`
235
+ * keeps its historical one-entry-per-parent dedup.
236
+ */
237
+ function linkParent(node, parentCommit, kind, key, depth) {
238
+ const parentId = parentCommit.runtimeStageId;
239
+ // the dedup scans below, and the parent's trace when it is new — counted for the scaling test
240
+ keyPaths_js_1.queryWork.units += 1 + node.parents.length + node.parentEdges.length;
241
+ // #P1: the parent's expansion reads, resolved LAZILY (only for new nodes
242
+ // or per-write re-expansion — duplicate links under 'stage' pay nothing).
243
+ // Data links expand through the reads that fed the parent's write of
244
+ // `key`; control links expand the decider at stage level (a decision
245
+ // depends on everything it read).
246
+ const resolveLinkReads = () => kind === 'data'
247
+ ? readsForLinks(parentCommit, key !== undefined ? [key] : undefined)
248
+ : [getKeysRead(parentId), true];
249
+ let parentNode = nodeMap.get(parentId);
250
+ if (!parentNode) {
251
+ // New node — create and enqueue (respecting the node budget)
252
+ if (visited >= maxNodes) {
253
+ truncatedByNodes = true; // D4: a discovered parent was dropped
254
+ return;
255
+ }
256
+ const parentIdx = idxMap.get(parentId);
257
+ if (parentIdx === undefined)
258
+ return;
259
+ keyPaths_js_1.queryWork.units += parentCommit.trace.length;
260
+ parentNode = {
261
+ runtimeStageId: parentId,
262
+ stageId: parentCommit.stageId,
263
+ stageName: parentCommit.stage,
264
+ keysWritten: parentCommit.trace.map((t) => t.path),
265
+ // linkedBy stays a DATA-key concept (back-compat) — control-linked
266
+ // nodes carry their label on the edge instead.
267
+ linkedBy: kind === 'data' ? key ?? '' : '',
268
+ depth: depth + 1,
269
+ parents: [],
270
+ parentEdges: [],
271
+ ...incompleteSourcesFragment(parentCommit),
272
+ };
273
+ nodeMap.set(parentId, parentNode);
274
+ visited++;
275
+ const [linkReads, linkIsFull] = resolveLinkReads();
276
+ expandedReads.set(parentId, new Set(linkReads));
277
+ if (linkIsFull)
278
+ fullyExpanded.add(parentId);
279
+ queue.push([parentNode, parentIdx, depth + 1, linkReads]);
280
+ }
281
+ else if (perWrite && !fullyExpanded.has(parentId)) {
282
+ // #P1 worklist: an EXISTING node linked via another key may owe more
283
+ // expansion — enqueue only the reads not yet expanded (monotone; the
284
+ // node budget is untouched, no node is created). Re-expansion keeps
285
+ // the node's ORIGINAL depth so its parents get a consistent depth+1.
286
+ const [linkReads, linkIsFull] = resolveLinkReads();
287
+ const expanded = expandedReads.get(parentId);
288
+ const delta = linkReads.filter((k) => !expanded.has(k));
289
+ if (linkIsFull)
290
+ fullyExpanded.add(parentId);
291
+ if (delta.length > 0) {
292
+ for (const k of delta)
293
+ expanded.add(k);
294
+ const parentIdx = idxMap.get(parentId);
295
+ if (parentIdx !== undefined)
296
+ queue.push([parentNode, parentIdx, parentNode.depth, delta]);
297
+ }
298
+ }
299
+ // DAG merge: one parents[] entry per distinct parent (historical shape)
300
+ if (!node.parents.some((p) => p.runtimeStageId === parentId)) {
301
+ node.parents.push(parentNode);
302
+ }
303
+ // One edge per distinct (parent, kind, key) link. The weigher (D4)
304
+ // stamps the weight at creation; `undefined` → 1.0 — the engine never
305
+ // computes weights itself.
306
+ if (!node.parentEdges.some((e) => e.parent.runtimeStageId === parentId && e.kind === kind && e.key === key)) {
307
+ // Error isolation (review finding): a consumer weigher that throws
308
+ // must degrade to the default weight, never crash the slice — the
309
+ // same contract every other consumer callback in the library gets.
310
+ let weight = 1.0;
311
+ if (weigh) {
312
+ try {
313
+ weight = weigh(node, parentNode, key, kind) ?? 1.0;
314
+ }
315
+ catch {
316
+ /* weigher threw — keep 1.0, the slice stays usable */
317
+ }
318
+ }
319
+ const nested = kind === 'data' && key !== undefined && (0, keyPaths_js_1.writesOnlyInside)(parentCommit, key);
320
+ node.parentEdges.push({
321
+ parent: parentNode,
322
+ kind,
323
+ key,
324
+ weight,
325
+ ...(nested && { basis: 'nested-rows' }),
326
+ });
327
+ }
328
+ }
329
+ while (queue.length > 0) {
330
+ const [node, commitIdx, depth, keysToExpand] = queue.shift();
331
+ keyPaths_js_1.queryWork.units += 1 + keysToExpand.length;
332
+ if (depth >= maxDepth) {
333
+ // D4: only a node that still HAD something to expand counts as a cut
334
+ // (a leaf at the horizon truncates nothing).
335
+ if (keysToExpand.length > 0 || controlDeps?.(node.runtimeStageId) !== undefined) {
336
+ truncatedByDepth = true;
337
+ }
338
+ continue;
339
+ }
340
+ // Data edges: for each key in this expansion's read-set, find who wrote
341
+ // it. Under 'stage' this is the node's full read-set (historical
342
+ // behavior); under 'per-write' it is the linking writes' temporal prefix
343
+ // (or a worklist delta on re-expansion).
344
+ const keysRead = keysToExpand;
345
+ for (const key of keysRead) {
346
+ const writer = findWriter(key, commitIdx);
347
+ if (!writer) {
348
+ // F4b: say why there is no edge — no commit before this node wrote the key.
349
+ node.preRunReads ??= { code: 'pre-run-origin', keys: [] };
350
+ if (!node.preRunReads.keys.includes(key))
351
+ node.preRunReads.keys.push(key);
352
+ continue;
353
+ }
354
+ linkParent(node, writer, 'data', key, depth);
355
+ }
356
+ // Control edge (RFC-003 D3): link the governing decider, labeled by the
357
+ // decide() rule label when present. The decider node then expands
358
+ // normally through its own data reads (and its own control parent).
359
+ if (controlDeps) {
360
+ const dep = controlDeps(node.runtimeStageId);
361
+ if (dep) {
362
+ const deciderIdx = idxMap.get(dep.deciderId);
363
+ if (deciderIdx !== undefined) {
364
+ linkParent(node, commitLog[deciderIdx], 'control', dep.label, depth);
365
+ }
366
+ }
367
+ }
368
+ }
369
+ // RFC-003 D4 — truncation visibility on the root (absent when complete). It is DATA, and only
370
+ // data: a reader reads `root.truncated` (and `formatCausalChain` prints it); the record writes no
371
+ // console line (the dev-mode warning went in C6 — the record imports nothing outside itself).
372
+ if (truncatedByDepth || truncatedByNodes) {
373
+ root.truncated = { byDepth: truncatedByDepth, byNodes: truncatedByNodes };
374
+ }
375
+ return root;
376
+ }
377
+ exports.causalChain = causalChain;
378
+ // ── Utilities ──────────────────────────────────────────────────────────
379
+ /**
380
+ * Flatten the causal DAG into a BFS-ordered list of nodes.
381
+ * Each node appears exactly once (first occurrence by BFS order).
382
+ * Useful for linear display or iteration.
383
+ */
384
+ function flattenCausalDAG(root) {
385
+ const result = [];
386
+ const visited = new Set();
387
+ const queue = [root];
388
+ while (queue.length > 0) {
389
+ const node = queue.shift();
390
+ if (visited.has(node.runtimeStageId))
391
+ continue;
392
+ visited.add(node.runtimeStageId);
393
+ result.push(node);
394
+ for (const parent of node.parents) {
395
+ if (!visited.has(parent.runtimeStageId)) {
396
+ queue.push(parent);
397
+ }
398
+ }
399
+ }
400
+ return result;
401
+ }
402
+ exports.flattenCausalDAG = flattenCausalDAG;
403
+ /**
404
+ * Format a causal DAG as human-readable indented text.
405
+ * Shows the dependency chain with depth indentation and linked-by keys.
406
+ *
407
+ * RFC-003 D2: nodes that consumed untracked read paths render an extra
408
+ * `⚠ also consumed … — slice may be incomplete here` line, so a consumer
409
+ * (human or LLM) debugging from the slice is TOLD when it is incomplete.
410
+ *
411
+ * RFC-003 D3: control edges render as `← [control: <rule label>]`
412
+ * (label omitted when the decision carried none). Data rendering is
413
+ * byte-identical to the pre-D3 output — `← via <key>` from the node's
414
+ * discovery-time `linkedBy`.
415
+ *
416
+ * RFC-003 D4: edge weights from the `weigh` hook render as a suffix —
417
+ * `← via systemPrompt (0.18)` — only when ≠ 1.0, so unweighted output is
418
+ * unchanged. A truncated slice (root.truncated) appends a final
419
+ * `⚠ slice truncated …` line.
420
+ */
421
+ function formatCausalChain(root) {
422
+ const lines = [];
423
+ const visited = new Set();
424
+ const weightSuffix = (edge) => edge !== undefined && edge.weight !== 1 ? ` (${edge.weight})` : '';
425
+ function walk(node, indent, edgesFromChild) {
426
+ if (visited.has(node.runtimeStageId)) {
427
+ lines.push(`${' '.repeat(indent)}↳ ${node.runtimeStageId} (see above)`);
428
+ return;
429
+ }
430
+ visited.add(node.runtimeStageId);
431
+ const linkParts = [];
432
+ if (node.linkedBy) {
433
+ const dataEdge = edgesFromChild?.find((e) => e.kind === 'data');
434
+ linkParts.push(`via ${node.linkedBy}${weightSuffix(dataEdge)}`);
435
+ }
436
+ const controlEdge = edgesFromChild?.find((e) => e.kind === 'control');
437
+ if (controlEdge) {
438
+ linkParts.push(`[control${controlEdge.key ? `: ${controlEdge.key}` : ''}]${weightSuffix(controlEdge)}`);
439
+ }
440
+ const link = linkParts.length > 0 ? ` ← ${linkParts.join(' ← ')}` : '';
441
+ const writes = node.keysWritten.length > 0 ? ` [wrote: ${node.keysWritten.join(', ')}]` : '';
442
+ lines.push(`${' '.repeat(indent)}${node.stageName} (${node.runtimeStageId})${link}${writes}`);
443
+ if (node.incompleteSources && node.incompleteSources.length > 0) {
444
+ lines.push(`${' '.repeat(indent + 1)}⚠ also consumed ${node.incompleteSources.join('/')} — slice may be incomplete here`);
445
+ }
446
+ for (const parent of node.parents) {
447
+ walk(parent, indent + 1, node.parentEdges.filter((e) => e.parent === parent));
448
+ }
449
+ }
450
+ walk(root, 0);
451
+ if (root.truncated) {
452
+ const causes = [root.truncated.byDepth && 'maxDepth reached', root.truncated.byNodes && 'maxNodes reached']
453
+ .filter(Boolean)
454
+ .join(', ');
455
+ lines.push(`⚠ slice truncated (${causes}) — older causes exist beyond this horizon`);
456
+ }
457
+ return lines.join('\n');
458
+ }
459
+ exports.formatCausalChain = formatCausalChain;
460
+ // ── Exported for testing (internal) ────────────────────────────────────
461
+ /** @internal Exposed for testing the strategy selection. */
462
+ exports._REVERSE_INDEX_THRESHOLD = REVERSE_INDEX_THRESHOLD;