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,81 @@
1
+ /**
2
+ * admission.ts — the admitted record's check (9.30.0): does a commit's bundle
3
+ * fold back to what its stage read? (docs/design/2026-10-admitted-record.md)
4
+ *
5
+ * THE LAW: a commit is admitted only if its bundle, replayed onto the state
6
+ * the stage began from, gives back the stage's read-your-writes view at every
7
+ * path the stage touched — and at every container and array slot its writes
8
+ * made on the way there, below the stage's address.
9
+ *
10
+ * Pure: handed the candidate rows, the stage's diff base, its working copy,
11
+ * its op trace and its address, {@link lossyFamilies} answers which families
12
+ * of touched paths do not fold back. `TransactionBuffer · admit` asks once per
13
+ * commit that staged a `merge` or a nested op, and lays the rows out again
14
+ * with those families written as what the stage read. The fold is the one
15
+ * verb law ({@link dryFold}, no clone); the compare is {@link deepEqual}.
16
+ */
17
+ import type { MemoryPatch, TraceEntry } from './types.js';
18
+ /** The rows a payload is built from, before the commit names its redacted paths. */
19
+ export type Rows = {
20
+ overwrite: MemoryPatch;
21
+ updates: MemoryPatch;
22
+ trace: TraceEntry[];
23
+ };
24
+ /** One staged op, as `TransactionBuffer` records it. */
25
+ export type StagedOp = {
26
+ path: string;
27
+ verb: string;
28
+ readKeys?: string[];
29
+ };
30
+ /** A staged path in a {@link Family}, with its last op's read prefix. */
31
+ export type Member = {
32
+ path: string;
33
+ readKeys?: string[];
34
+ };
35
+ /**
36
+ * A family of TOUCHED paths: every staged path under its shallowest staged
37
+ * ancestor-or-self — the unit the check verifies and, when it does not fold
38
+ * back, the unit re-encoded. Two families never overlap.
39
+ */
40
+ export type Family = {
41
+ root: string;
42
+ rootSegments: string[];
43
+ /** Its staged paths, by last touch. */
44
+ members: Member[];
45
+ /** Index in the op trace of the family's last op: where its re-encoded rows go. */
46
+ slot: number;
47
+ /**
48
+ * The root's read-back as a record can hold it — its `structuredClone`,
49
+ * boxed — when the check took one ({@link foldsBack}); the re-encoding
50
+ * reuses it.
51
+ */
52
+ held?: {
53
+ value: unknown;
54
+ };
55
+ };
56
+ /** The families whose rows did not fold back, and which family every staged path is in. */
57
+ export type Lossy = {
58
+ rootOf: Map<string, string>;
59
+ families: Map<string, Family>;
60
+ };
61
+ /**
62
+ * The families of `ops` whose candidate rows do not fold back to what the
63
+ * stage read — `undefined` when every family does. Each family ROOT is
64
+ * compared ({@link foldsBack}): its value and the way down to it.
65
+ */
66
+ export declare function lossyFamilies(candidate: Rows, base: unknown, workingCopy: unknown, ops: readonly StagedOp[], address: readonly string[]): Lossy | undefined;
67
+ /**
68
+ * Every staged path grouped under its SHALLOWEST staged ancestor-or-self —
69
+ * over TOUCHED paths, not surviving ones: an op the net-change filter drops
70
+ * can still have changed what the stage reads (L-1). Members are listed by
71
+ * last touch; a family's slot is its last op.
72
+ */
73
+ export declare function touchedFamilies(ops: readonly StagedOp[]): {
74
+ rootOf: Map<string, string>;
75
+ families: Map<string, Family>;
76
+ };
77
+ /**
78
+ * How many leading segments of `path` are the stage's `address` — its length
79
+ * when `path` lies strictly below it, else 0.
80
+ */
81
+ export declare function addressDepthOf(path: readonly (string | number)[], address: readonly string[]): number;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * admission.ts — the admitted record's check (9.30.0): does a commit's bundle
3
+ * fold back to what its stage read? (docs/design/2026-10-admitted-record.md)
4
+ *
5
+ * THE LAW: a commit is admitted only if its bundle, replayed onto the state
6
+ * the stage began from, gives back the stage's read-your-writes view at every
7
+ * path the stage touched — and at every container and array slot its writes
8
+ * made on the way there, below the stage's address.
9
+ *
10
+ * Pure: handed the candidate rows, the stage's diff base, its working copy,
11
+ * its op trace and its address, {@link lossyFamilies} answers which families
12
+ * of touched paths do not fold back. `TransactionBuffer · admit` asks once per
13
+ * commit that staged a `merge` or a nested op, and lays the rows out again
14
+ * with those families written as what the stage read. The fold is the one
15
+ * verb law ({@link dryFold}, no clone); the compare is {@link deepEqual}.
16
+ */
17
+ import { isContainer, nativeGet as _get, ownChild } from './pathOps.js';
18
+ import { deepEqual, DELIM, dryFold } from './utils.js';
19
+ /**
20
+ * The families of `ops` whose candidate rows do not fold back to what the
21
+ * stage read — `undefined` when every family does. Each family ROOT is
22
+ * compared ({@link foldsBack}): its value and the way down to it.
23
+ */
24
+ export function lossyFamilies(candidate, base, workingCopy, ops, address) {
25
+ const fold = dryFold(base, candidate.updates, candidate.overwrite, candidate.trace);
26
+ const { rootOf, families } = touchedFamilies(ops);
27
+ let lossy;
28
+ for (const family of families.values()) {
29
+ if (!foldsBack(fold, workingCopy, family, address))
30
+ (lossy ??= new Map()).set(family.root, family);
31
+ }
32
+ return lossy === undefined ? undefined : { rootOf, families: lossy };
33
+ }
34
+ /**
35
+ * Does the fold give back what the stage read at a family root? The way down
36
+ * ({@link spineHeld}), then the value by {@link deepEqual} (a root covers every
37
+ * descendant; an own `undefined` is a deleted key) — against the read-back as
38
+ * a record can hold it. A record holds `structuredClone`s of what a stage
39
+ * wrote (state values must survive one), and a few values do not survive one
40
+ * whole: an `Error`'s own fields are dropped. When the fold equals the
41
+ * read-back's clone, no row could hold more, and the family is admitted as it
42
+ * is; the clone, taken only for a root the cheap compare refused, is kept for
43
+ * the re-encoding.
44
+ */
45
+ function foldsBack(fold, workingCopy, family, address) {
46
+ const segments = family.rootSegments;
47
+ if (!spineHeld(fold, workingCopy, segments, address))
48
+ return false;
49
+ const folded = _get(fold, segments);
50
+ const read = _get(workingCopy, segments);
51
+ // Both sides are the same values as a record holds them: an opaque one (a Blob) is never the same object.
52
+ if (deepEqual(folded, read, 'copies'))
53
+ return true;
54
+ family.held = { value: structuredClone(read) };
55
+ return deepEqual(folded, family.held.value, 'copies');
56
+ }
57
+ /**
58
+ * Every staged path grouped under its SHALLOWEST staged ancestor-or-self —
59
+ * over TOUCHED paths, not surviving ones: an op the net-change filter drops
60
+ * can still have changed what the stage reads (L-1). Members are listed by
61
+ * last touch; a family's slot is its last op.
62
+ */
63
+ export function touchedFamilies(ops) {
64
+ const touches = new Map();
65
+ for (let i = 0; i < ops.length; i++) {
66
+ touches.delete(ops[i].path); // re-insert: the Map's order is last-touch order
67
+ touches.set(ops[i].path, { last: i, readKeys: ops[i].readKeys });
68
+ }
69
+ const rootOf = new Map();
70
+ const families = new Map();
71
+ for (const [path, touch] of touches) {
72
+ const root = shallowestIn(path, touches);
73
+ rootOf.set(path, root);
74
+ let family = families.get(root);
75
+ if (family === undefined) {
76
+ family = { root, rootSegments: root.split(DELIM), members: [], slot: touch.last };
77
+ families.set(root, family);
78
+ }
79
+ family.members.push({ path, readKeys: touch.readKeys });
80
+ if (touch.last > family.slot)
81
+ family.slot = touch.last;
82
+ }
83
+ return { rootOf, families };
84
+ }
85
+ /**
86
+ * How many leading segments of `path` are the stage's `address` — its length
87
+ * when `path` lies strictly below it, else 0.
88
+ */
89
+ export function addressDepthOf(path, address) {
90
+ const depth = address.length;
91
+ if (depth === 0 || path.length <= depth)
92
+ return 0;
93
+ for (let i = 0; i < depth; i++)
94
+ if (String(path[i]) !== address[i])
95
+ return 0;
96
+ return depth;
97
+ }
98
+ /**
99
+ * L-1: a nested op through an absent or primitive parent makes that parent a
100
+ * container in the working copy — and when the op itself changes nothing (a
101
+ * delete of a key that was never there) no row is left to make it in the
102
+ * fold, so the stage reads back a container the record never mentions. The
103
+ * same op on an ARRAY parent past its end grows it (`delete a.0` on `[]`
104
+ * reads back `[undefined]`). Every container the working copy holds on the
105
+ * way to `segments`, and every array slot each step lands in, must be one the
106
+ * fold holds too — below the stage's address, whose containers are where it
107
+ * writes, not a value it reads.
108
+ */
109
+ function spineHeld(fold, workingCopy, segments, address) {
110
+ const from = addressDepthOf(segments, address);
111
+ let f = fold;
112
+ let w = workingCopy;
113
+ for (let i = 0; i < segments.length; i++) {
114
+ if (i >= from && !slotHeld(f, w, segments[i]))
115
+ return false;
116
+ if (i === segments.length - 1)
117
+ return true;
118
+ f = ownChild(f, segments[i]);
119
+ w = ownChild(w, segments[i]);
120
+ if (!isContainer(w))
121
+ return true;
122
+ if (i >= from && (!isContainer(f) || Array.isArray(f) !== Array.isArray(w)))
123
+ return false;
124
+ }
125
+ return true;
126
+ }
127
+ /**
128
+ * Does the fold hold every array slot the working copy's container `w` holds
129
+ * at `key`? Only an array can be grown by a write that changes nothing else
130
+ * (an index past its end, set to `undefined`); an object's own `undefined`
131
+ * key is a deleted key to every reader, so objects always pass.
132
+ */
133
+ function slotHeld(f, w, key) {
134
+ if (!Array.isArray(w))
135
+ return true;
136
+ const index = Number(key);
137
+ if (!Number.isInteger(index) || index < 0 || String(index) !== key || index >= w.length)
138
+ return true;
139
+ return Array.isArray(f) && index < f.length;
140
+ }
141
+ /** The shallowest of `path` and its ancestors that `touched` holds — its family's root. */
142
+ function shallowestIn(path, touched) {
143
+ if (path.indexOf(DELIM) === -1)
144
+ return path;
145
+ const segments = path.split(DELIM);
146
+ for (let i = 1; i < segments.length; i++) {
147
+ const ancestor = segments.slice(0, i).join(DELIM);
148
+ if (touched.has(ancestor))
149
+ return ancestor;
150
+ }
151
+ return path;
152
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * backtrack.ts — Backward causal chain analysis on the commit log.
3
+ *
4
+ * Implements **backward program slicing** (Weiser 1984, thin-slice variant):
5
+ * given a starting execution step, walk backwards through read→write
6
+ * dependencies to build the causal DAG that produced the data at that step.
7
+ *
8
+ * ## Algorithm
9
+ *
10
+ * BFS on the implicit dependency graph where edges run from reader → writer.
11
+ *
12
+ * 1. Locate startId in commitLog → root node
13
+ * 2. Get keysRead for root via `getKeysRead` callback
14
+ * 3. For each key read, find who last wrote it before this step → parent commit
15
+ * ("wrote" is the writer rule of `keyPaths.ts`, 9.33.0: a row on the key, inside it, or
16
+ * around it that changed it — so a stage that read `cfg` links to the subflow seed or
17
+ * merge-back that wrote `cfg␟b`)
18
+ * 4. Create parent CausalNode, link to root.parents
19
+ * 5. Enqueue parent. Repeat until queue empty or limits hit.
20
+ *
21
+ * Output is a **DAG** (not a linked list): a stage reading `creditScore` AND `dti`
22
+ * from different writers has two parents.
23
+ *
24
+ * ## Staged Optimization
25
+ *
26
+ * Two writer-lookup strategies, chosen automatically by commit log size:
27
+ *
28
+ * | Strategy | When | Complexity per lookup |
29
+ * |----------|------|----------------------|
30
+ * | Linear scan | N ≤ 256 | O(N) — simple backward scan |
31
+ * | Reverse index | N > 256 | O(K log N) — prebuilt key→[indices], binary search |
32
+ *
33
+ * The threshold (256) is chosen so the O(N) build cost of the reverse index
34
+ * is amortized over the BFS traversal. Below 256, linear scan wins because
35
+ * there's no index build overhead. The consumer never sees this — `causalChain()`
36
+ * picks the right strategy internally (like a query optimizer choosing between
37
+ * sequential scan vs index scan based on table size).
38
+ *
39
+ * ## Complexity
40
+ *
41
+ * - **Small logs (N ≤ 256):** O(V × K × N) total. V=visited, K=avg keys/node.
42
+ * - **Large logs (N > 256):** O(N × U) index build + O(V × K × log N) lookups.
43
+ * U = unique keys. Amortized over all BFS hops.
44
+ *
45
+ * ## References
46
+ *
47
+ * - Weiser, M. (1984). "Program Slicing." IEEE TSE.
48
+ * - Sridharan, M. et al. (2007). "Thin Slicing." PLDI.
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * import { causalChain, flattenCausalDAG, formatCausalChain } from 'foottrace';
53
+ *
54
+ * const dag = causalChain(commitLog, 'decide#2', (id) => recorder.getKeysRead(id));
55
+ * const flat = flattenCausalDAG(dag); // BFS-ordered flat list
56
+ * console.log(formatCausalChain(dag)); // human-readable
57
+ * ```
58
+ */
59
+ import type { RegisteredCode } from './honesty.js';
60
+ import type { CommitBundle, UntrackedSource } from './types.js';
61
+ /** A single node in the causal DAG. */
62
+ export interface CausalNode {
63
+ /** Unique execution step identifier. */
64
+ runtimeStageId: string;
65
+ /** Stable stage identifier. */
66
+ stageId: string;
67
+ /** Human-readable stage name. */
68
+ stageName: string;
69
+ /** Keys this stage wrote (from its CommitBundle.trace). */
70
+ keysWritten: string[];
71
+ /** The key whose read→write dependency linked this node to its child. Empty for the root. */
72
+ linkedBy: string;
73
+ /** BFS depth from the starting node (0 = start). */
74
+ depth: number;
75
+ /**
76
+ * Parent nodes — stages this node depends on. DAG: multiple parents
77
+ * possible. KEPT for compatibility; with the `controlDeps` option
78
+ * (RFC-003 D3) governing deciders appear here too. For typed/keyed/
79
+ * weighted detail use {@link parentEdges}.
80
+ */
81
+ parents: CausalNode[];
82
+ /**
83
+ * RFC-003 D3 — one edge per dependency LINK (not per parent): a node
84
+ * reading two keys from the same writer has ONE entry in {@link parents}
85
+ * but TWO `'data'` edges here. Control dependencies (via the
86
+ * `controlDeps` option) add a `'control'` edge to the governing decider.
87
+ */
88
+ parentEdges: CausalEdge[];
89
+ /**
90
+ * RFC-003 D2 honesty marker — stamped from the stage's
91
+ * `CommitBundle.untrackedSources`. Present when this stage ALSO consumed
92
+ * untracked read paths (`args` / `env` / unshadowed `silent` reads): the
93
+ * backward slice through this node may be incomplete, because those reads
94
+ * produce no read→write edge to follow. `formatCausalChain` renders this
95
+ * as a `⚠ … slice may be incomplete here` line. Absent when the stage's
96
+ * reads were fully tracked.
97
+ *
98
+ * Registry code: `'incomplete-sources'` (`memory/honesty.ts ·
99
+ * HONESTY_CODES`, served on `foottrace`) says what this field
100
+ * means. The field carries no `code` — its values are the
101
+ * {@link UntrackedSource}s.
102
+ */
103
+ incompleteSources?: ReadonlyArray<UntrackedSource>;
104
+ /**
105
+ * RFC-003 D4 truncation visibility — set on the ROOT node only, and only
106
+ * when a limit actually cut the slice: `byDepth` when a node at the
107
+ * `maxDepth` horizon still had edges to expand, `byNodes` when the
108
+ * `maxNodes` budget blocked creating a discovered parent. Absent when the
109
+ * slice is complete. `formatCausalChain` appends a `⚠ slice truncated …`
110
+ * line — a consumer must never mistake a truncated slice for a full one.
111
+ * (Until 9.48.0 dev mode also warned on the console; the field is the
112
+ * signal, read it.)
113
+ *
114
+ * Registry code: `'truncated'` (`memory/honesty.ts · HONESTY_CODES`, served
115
+ * on `foottrace`) says what this field means; the field carries no
116
+ * `code`.
117
+ */
118
+ truncated?: {
119
+ byDepth: boolean;
120
+ byNodes: boolean;
121
+ };
122
+ /**
123
+ * F4b (9.33.0) — the keys this node read that NO commit before it wrote (under the writer rule), so the
124
+ * walk has no edge to follow for them: their value came from the initial state, frozen run input or a
125
+ * closure — outside the commit log. The code says so (`'pre-run-origin'`, registered in
126
+ * `memory/honesty.ts · HONESTY_CODES`); `keys` are in the order the walk met them. ABSENT when every key
127
+ * the node expanded had a writer, so such a node keeps its 9.32.0 shape. Before 9.33.0 these reads were
128
+ * dropped without a word.
129
+ */
130
+ preRunReads?: {
131
+ code: RegisteredCode<'pre-run-origin'>;
132
+ keys: string[];
133
+ };
134
+ }
135
+ /**
136
+ * RFC-003 D3 — a typed dependency edge from a child node to one parent.
137
+ *
138
+ * - `kind: 'data'` — read→write dependency; `key` is the state key.
139
+ * - `kind: 'control'` — the parent is the decider/selector whose decision
140
+ * allowed the child to run; `key` is the decide() rule label when present.
141
+ *
142
+ * `weight` defaults to 1.0; the `weigh` hook (RFC-003 D4) can override it.
143
+ * The engine itself NEVER computes weights — semantics belong to the
144
+ * consumer-injected weigher.
145
+ */
146
+ export interface CausalEdge {
147
+ parent: CausalNode;
148
+ kind: 'data' | 'control';
149
+ key?: string;
150
+ weight: number;
151
+ /**
152
+ * F4b (9.33.0) — present only on a `'data'` edge whose parent wrote `key` ONLY through rows inside it
153
+ * (a subflow's input seed, an outputMapper merge-back, a fork child's namespace): that write made part
154
+ * of the value, and earlier writers may account for the rest. Registered as `'nested-rows'` in
155
+ * `memory/honesty.ts · HONESTY_CODES`. Absent for a write on or around the key, so an exact edge keeps
156
+ * its 9.32.0 shape.
157
+ */
158
+ basis?: RegisteredCode<'nested-rows'>;
159
+ }
160
+ /**
161
+ * RFC-003 D3 — a control dependency resolved for one execution step:
162
+ * which decider/selector execution allowed this stage to run.
163
+ */
164
+ export interface ControlDependency {
165
+ /** runtimeStageId of the governing decider/selector execution step. */
166
+ deciderId: string;
167
+ /** The decide() rule label for the chosen branch, when present. */
168
+ label?: string;
169
+ }
170
+ /**
171
+ * RFC-003 D3 — callback resolving the governing decider for an execution
172
+ * step. Return `undefined` when the step is not control-dependent on any
173
+ * recorded decision. Build one with `controlDepRecorder()` from
174
+ * `footprintjs/trace`, or supply your own.
175
+ */
176
+ export type ControlDepLookup = (runtimeStageId: string) => ControlDependency | undefined;
177
+ /**
178
+ * RFC-003 D4 — consumer-injected edge weigher. Called once per created
179
+ * edge; return a weight, or `undefined` to keep the default 1.0. The
180
+ * ENGINE never computes weights (zero new dependencies — semantics like
181
+ * embedding similarity or FDL influence belong to downstream libraries,
182
+ * the same plug-in pattern as `NarrativeFormatter`). Weights render in
183
+ * `formatCausalChain` as `← via systemPrompt (0.18)`.
184
+ */
185
+ export type EdgeWeigher = (child: CausalNode, parent: CausalNode, key: string | undefined, kind: 'data' | 'control') => number | undefined;
186
+ /** Options for causalChain(). */
187
+ export interface CausalChainOptions {
188
+ /** Maximum BFS depth (default: 20). Prevents runaway traversal. */
189
+ maxDepth?: number;
190
+ /** Maximum total nodes to visit (default: 100). Hard cap for safety. */
191
+ maxNodes?: number;
192
+ /**
193
+ * RFC-003 D3 — control-dependence lookup. When provided, expanding a node
194
+ * ALSO links a `'control'` edge to its governing decider (labeled by the
195
+ * decide() rule label when present); the decider node then expands
196
+ * normally through its own data reads, so chains like
197
+ * `status ← [control] ClassifyRisk ← [data: creditScore] PullBureau`
198
+ * resolve end-to-end. Without this option behavior is unchanged.
199
+ */
200
+ controlDeps?: ControlDepLookup;
201
+ /**
202
+ * RFC-003 D4 — edge weigher. Stamps `CausalEdge.weight` at edge creation;
203
+ * `undefined` (or no weigher) → 1.0. See {@link EdgeWeigher}.
204
+ */
205
+ weigh?: EdgeWeigher;
206
+ /**
207
+ * #P1 — how a node's expansion read-set is derived:
208
+ *
209
+ * - `'stage'` (default, historical) — every visited node expands through
210
+ * ALL of its stage's reads (`getKeysRead`). A stage reading `a,b` and
211
+ * writing `x,y` links both reads as causes of both writes — a sound but
212
+ * coarse over-approximation.
213
+ * - `'per-write'` — when the commit log carries per-write read provenance
214
+ * (`TraceEntry.readKeys`, recorded under the executor's
215
+ * `writeProvenance: 'reads-prefix'` dial), a node reached via key `k`
216
+ * expands through ONLY the reads that preceded its write of `k`
217
+ * (temporal prefix). Nodes linked later via additional keys are
218
+ * incrementally re-expanded with just the new reads (worklist — the
219
+ * slice only ever GROWS toward the stage-level ceiling). HONEST
220
+ * FALLBACK: any link whose trace entry lacks `readKeys` expands that
221
+ * node at stage level — mixed or dial-off logs degrade to `'stage'`
222
+ * behavior exactly, never to silence.
223
+ */
224
+ edgeAttribution?: 'stage' | 'per-write';
225
+ /**
226
+ * #P1 — the written keys that anchor the ROOT's expansion under
227
+ * `'per-write'` (e.g. `sliceForKey` passes the sliced key, so the root
228
+ * expands through the reads that fed THAT write, not the whole stage).
229
+ * Ignored under `'stage'`. Unset → root expands at stage level.
230
+ */
231
+ rootLinkKeys?: string[];
232
+ }
233
+ /**
234
+ * Callback that returns the keys a stage read during execution.
235
+ * The backtracker calls this for each visited node to determine
236
+ * which read→write edges to follow.
237
+ *
238
+ * Implementors: QualityRecorder tracks keysRead per step,
239
+ * or build a Map<runtimeStageId, string[]> from ScopeRecorder.onRead events.
240
+ */
241
+ export type KeysReadLookup = (runtimeStageId: string) => string[];
242
+ /**
243
+ * Build the causal DAG rooted at `startId` by walking backwards
244
+ * through read→write dependencies in the commit log.
245
+ *
246
+ * Automatically selects the optimal writer lookup strategy:
247
+ * - Linear scan for small logs (≤ 256 commits)
248
+ * - Reverse index with binary search for large logs (> 256 commits)
249
+ *
250
+ * Produces a DAG (not a tree): if two children both read from the same
251
+ * parent, the parent node is shared (deduped by runtimeStageId).
252
+ *
253
+ * @param commitLog Ordered commit bundles from executor.getSnapshot().commitLog
254
+ * @param startId runtimeStageId to start backtracking from
255
+ * @param getKeysRead Callback returning keys read by a given execution step
256
+ * @param options Depth and node limits
257
+ * @returns Root CausalNode with .parents forming the DAG, or undefined if startId not found
258
+ */
259
+ export declare function causalChain(commitLog: CommitBundle[], startId: string, getKeysRead: KeysReadLookup, options?: CausalChainOptions): CausalNode | undefined;
260
+ /**
261
+ * Flatten the causal DAG into a BFS-ordered list of nodes.
262
+ * Each node appears exactly once (first occurrence by BFS order).
263
+ * Useful for linear display or iteration.
264
+ */
265
+ export declare function flattenCausalDAG(root: CausalNode): CausalNode[];
266
+ /**
267
+ * Format a causal DAG as human-readable indented text.
268
+ * Shows the dependency chain with depth indentation and linked-by keys.
269
+ *
270
+ * RFC-003 D2: nodes that consumed untracked read paths render an extra
271
+ * `⚠ also consumed … — slice may be incomplete here` line, so a consumer
272
+ * (human or LLM) debugging from the slice is TOLD when it is incomplete.
273
+ *
274
+ * RFC-003 D3: control edges render as `← [control: <rule label>]`
275
+ * (label omitted when the decision carried none). Data rendering is
276
+ * byte-identical to the pre-D3 output — `← via <key>` from the node's
277
+ * discovery-time `linkedBy`.
278
+ *
279
+ * RFC-003 D4: edge weights from the `weigh` hook render as a suffix —
280
+ * `← via systemPrompt (0.18)` — only when ≠ 1.0, so unweighted output is
281
+ * unchanged. A truncated slice (root.truncated) appends a final
282
+ * `⚠ slice truncated …` line.
283
+ */
284
+ export declare function formatCausalChain(root: CausalNode): string;
285
+ /** @internal Exposed for testing the strategy selection. */
286
+ export declare const _REVERSE_INDEX_THRESHOLD = 256;