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.
- package/LICENSE +21 -0
- package/README.md +73 -2
- package/dist/esm/index.d.ts +27 -0
- package/dist/esm/index.js +12 -0
- package/dist/esm/lib/capture/freeze.d.ts +91 -0
- package/dist/esm/lib/capture/freeze.js +313 -0
- package/dist/esm/lib/capture/ownData.d.ts +18 -0
- package/dist/esm/lib/capture/ownData.js +35 -0
- package/dist/esm/lib/capture/valueKinds.d.ts +57 -0
- package/dist/esm/lib/capture/valueKinds.js +141 -0
- package/dist/esm/lib/ids/runtimeStageId.d.ts +173 -0
- package/dist/esm/lib/ids/runtimeStageId.js +193 -0
- package/dist/esm/lib/memory/EventLog.d.ts +105 -0
- package/dist/esm/lib/memory/EventLog.js +147 -0
- package/dist/esm/lib/memory/RecordFrame.d.ts +232 -0
- package/dist/esm/lib/memory/RecordFrame.js +295 -0
- package/dist/esm/lib/memory/SharedMemory.d.ts +46 -0
- package/dist/esm/lib/memory/SharedMemory.js +87 -0
- package/dist/esm/lib/memory/TransactionBuffer.d.ts +521 -0
- package/dist/esm/lib/memory/TransactionBuffer.js +946 -0
- package/dist/esm/lib/memory/admission.d.ts +81 -0
- package/dist/esm/lib/memory/admission.js +152 -0
- package/dist/esm/lib/memory/backtrack.d.ts +286 -0
- package/dist/esm/lib/memory/backtrack.js +456 -0
- package/dist/esm/lib/memory/commitLogUtils.d.ts +217 -0
- package/dist/esm/lib/memory/commitLogUtils.js +383 -0
- package/dist/esm/lib/memory/deltaEncoding.d.ts +66 -0
- package/dist/esm/lib/memory/deltaEncoding.js +121 -0
- package/dist/esm/lib/memory/equality.d.ts +93 -0
- package/dist/esm/lib/memory/equality.js +261 -0
- package/dist/esm/lib/memory/eventPosition.d.ts +18 -0
- package/dist/esm/lib/memory/eventPosition.js +1 -0
- package/dist/esm/lib/memory/honesty.d.ts +93 -0
- package/dist/esm/lib/memory/honesty.js +91 -0
- package/dist/esm/lib/memory/keyPaths.d.ts +125 -0
- package/dist/esm/lib/memory/keyPaths.js +226 -0
- package/dist/esm/lib/memory/logModel.d.ts +76 -0
- package/dist/esm/lib/memory/logModel.js +363 -0
- package/dist/esm/lib/memory/merge.d.ts +26 -0
- package/dist/esm/lib/memory/merge.js +51 -0
- package/dist/esm/lib/memory/pathOps.d.ts +118 -0
- package/dist/esm/lib/memory/pathOps.js +292 -0
- package/dist/esm/lib/memory/paths.d.ts +56 -0
- package/dist/esm/lib/memory/paths.js +60 -0
- package/dist/esm/lib/memory/placeholders.d.ts +39 -0
- package/dist/esm/lib/memory/placeholders.js +39 -0
- package/dist/esm/lib/memory/recordCommit.d.ts +45 -0
- package/dist/esm/lib/memory/recordCommit.js +60 -0
- package/dist/esm/lib/memory/scrub.d.ts +52 -0
- package/dist/esm/lib/memory/scrub.js +72 -0
- package/dist/esm/lib/memory/types.d.ts +209 -0
- package/dist/esm/lib/memory/types.js +9 -0
- package/dist/esm/lib/memory/utils.d.ts +73 -0
- package/dist/esm/lib/memory/utils.js +150 -0
- package/dist/esm/lib/memory/verbs.d.ts +333 -0
- package/dist/esm/lib/memory/verbs.js +426 -0
- package/dist/esm/lib/recorder/CommitRangeIndex.d.ts +147 -0
- package/dist/esm/lib/recorder/CommitRangeIndex.js +206 -0
- package/dist/esm/lib/slice/elementProvenance.d.ts +81 -0
- package/dist/esm/lib/slice/elementProvenance.js +189 -0
- package/dist/esm/lib/slice/forwardSliceForKey.d.ts +88 -0
- package/dist/esm/lib/slice/forwardSliceForKey.js +242 -0
- package/dist/esm/lib/slice/index.d.ts +26 -0
- package/dist/esm/lib/slice/index.js +25 -0
- package/dist/esm/lib/slice/keyIndex.d.ts +137 -0
- package/dist/esm/lib/slice/keyIndex.js +292 -0
- package/dist/esm/lib/slice/keyTimeline.d.ts +49 -0
- package/dist/esm/lib/slice/keyTimeline.js +112 -0
- package/dist/esm/lib/slice/keysReadSources.d.ts +51 -0
- package/dist/esm/lib/slice/keysReadSources.js +94 -0
- package/dist/esm/lib/slice/serialize.d.ts +78 -0
- package/dist/esm/lib/slice/serialize.js +277 -0
- package/dist/esm/lib/slice/sliceForKey.d.ts +40 -0
- package/dist/esm/lib/slice/sliceForKey.js +78 -0
- package/dist/esm/lib/slice/types.d.ts +567 -0
- package/dist/esm/lib/slice/types.js +24 -0
- package/dist/esm/lib/time-travel/axis.d.ts +119 -0
- package/dist/esm/lib/time-travel/axis.js +178 -0
- package/dist/esm/lib/time-travel/bundles.d.ts +77 -0
- package/dist/esm/lib/time-travel/bundles.js +135 -0
- package/dist/esm/lib/time-travel/chain.d.ts +93 -0
- package/dist/esm/lib/time-travel/chain.js +187 -0
- package/dist/esm/lib/time-travel/commitStops.d.ts +55 -0
- package/dist/esm/lib/time-travel/commitStops.js +201 -0
- package/dist/esm/lib/time-travel/index.d.ts +22 -0
- package/dist/esm/lib/time-travel/index.js +20 -0
- package/dist/esm/lib/time-travel/stateAt.d.ts +102 -0
- package/dist/esm/lib/time-travel/stateAt.js +152 -0
- package/dist/esm/lib/time-travel/tagStops.d.ts +72 -0
- package/dist/esm/lib/time-travel/tagStops.js +101 -0
- package/dist/esm/lib/time-travel/timeTravel.d.ts +70 -0
- package/dist/esm/lib/time-travel/timeTravel.js +359 -0
- package/dist/esm/lib/time-travel/types.d.ts +470 -0
- package/dist/esm/lib/time-travel/types.js +22 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/paths.d.ts +4 -0
- package/dist/esm/paths.js +4 -0
- package/dist/esm/write.d.ts +49 -0
- package/dist/esm/write.js +47 -0
- package/dist/index.js +67 -0
- package/dist/lib/capture/freeze.js +319 -0
- package/dist/lib/capture/ownData.js +40 -0
- package/dist/lib/capture/valueKinds.js +145 -0
- package/dist/lib/ids/runtimeStageId.js +209 -0
- package/dist/lib/memory/EventLog.js +151 -0
- package/dist/lib/memory/RecordFrame.js +299 -0
- package/dist/lib/memory/SharedMemory.js +91 -0
- package/dist/lib/memory/TransactionBuffer.js +950 -0
- package/dist/lib/memory/admission.js +158 -0
- package/dist/lib/memory/backtrack.js +462 -0
- package/dist/lib/memory/commitLogUtils.js +398 -0
- package/dist/lib/memory/deltaEncoding.js +127 -0
- package/dist/lib/memory/equality.js +265 -0
- package/dist/lib/memory/eventPosition.js +2 -0
- package/dist/lib/memory/honesty.js +94 -0
- package/dist/lib/memory/keyPaths.js +242 -0
- package/dist/lib/memory/logModel.js +371 -0
- package/dist/lib/memory/merge.js +55 -0
- package/dist/lib/memory/pathOps.js +308 -0
- package/dist/lib/memory/paths.js +66 -0
- package/dist/lib/memory/placeholders.js +42 -0
- package/dist/lib/memory/recordCommit.js +64 -0
- package/dist/lib/memory/scrub.js +77 -0
- package/dist/lib/memory/types.js +10 -0
- package/dist/lib/memory/utils.js +169 -0
- package/dist/lib/memory/verbs.js +443 -0
- package/dist/lib/recorder/CommitRangeIndex.js +210 -0
- package/dist/lib/slice/elementProvenance.js +194 -0
- package/dist/lib/slice/forwardSliceForKey.js +246 -0
- package/dist/lib/slice/index.js +42 -0
- package/dist/lib/slice/keyIndex.js +312 -0
- package/dist/lib/slice/keyTimeline.js +116 -0
- package/dist/lib/slice/keysReadSources.js +100 -0
- package/dist/lib/slice/serialize.js +285 -0
- package/dist/lib/slice/sliceForKey.js +82 -0
- package/dist/lib/slice/types.js +25 -0
- package/dist/lib/time-travel/axis.js +183 -0
- package/dist/lib/time-travel/bundles.js +143 -0
- package/dist/lib/time-travel/chain.js +192 -0
- package/dist/lib/time-travel/commitStops.js +205 -0
- package/dist/lib/time-travel/index.js +31 -0
- package/dist/lib/time-travel/stateAt.js +159 -0
- package/dist/lib/time-travel/tagStops.js +105 -0
- package/dist/lib/time-travel/timeTravel.js +363 -0
- package/dist/lib/time-travel/types.js +23 -0
- package/dist/paths.js +14 -0
- package/dist/types/index.d.ts +27 -0
- package/dist/types/lib/capture/freeze.d.ts +91 -0
- package/dist/types/lib/capture/ownData.d.ts +18 -0
- package/dist/types/lib/capture/valueKinds.d.ts +57 -0
- package/dist/types/lib/ids/runtimeStageId.d.ts +173 -0
- package/dist/types/lib/memory/EventLog.d.ts +105 -0
- package/dist/types/lib/memory/RecordFrame.d.ts +232 -0
- package/dist/types/lib/memory/SharedMemory.d.ts +46 -0
- package/dist/types/lib/memory/TransactionBuffer.d.ts +521 -0
- package/dist/types/lib/memory/admission.d.ts +81 -0
- package/dist/types/lib/memory/backtrack.d.ts +286 -0
- package/dist/types/lib/memory/commitLogUtils.d.ts +217 -0
- package/dist/types/lib/memory/deltaEncoding.d.ts +66 -0
- package/dist/types/lib/memory/equality.d.ts +93 -0
- package/dist/types/lib/memory/eventPosition.d.ts +18 -0
- package/dist/types/lib/memory/honesty.d.ts +93 -0
- package/dist/types/lib/memory/keyPaths.d.ts +125 -0
- package/dist/types/lib/memory/logModel.d.ts +76 -0
- package/dist/types/lib/memory/merge.d.ts +26 -0
- package/dist/types/lib/memory/pathOps.d.ts +118 -0
- package/dist/types/lib/memory/paths.d.ts +56 -0
- package/dist/types/lib/memory/placeholders.d.ts +39 -0
- package/dist/types/lib/memory/recordCommit.d.ts +45 -0
- package/dist/types/lib/memory/scrub.d.ts +52 -0
- package/dist/types/lib/memory/types.d.ts +209 -0
- package/dist/types/lib/memory/utils.d.ts +73 -0
- package/dist/types/lib/memory/verbs.d.ts +333 -0
- package/dist/types/lib/recorder/CommitRangeIndex.d.ts +147 -0
- package/dist/types/lib/slice/elementProvenance.d.ts +81 -0
- package/dist/types/lib/slice/forwardSliceForKey.d.ts +88 -0
- package/dist/types/lib/slice/index.d.ts +26 -0
- package/dist/types/lib/slice/keyIndex.d.ts +137 -0
- package/dist/types/lib/slice/keyTimeline.d.ts +49 -0
- package/dist/types/lib/slice/keysReadSources.d.ts +51 -0
- package/dist/types/lib/slice/serialize.d.ts +78 -0
- package/dist/types/lib/slice/sliceForKey.d.ts +40 -0
- package/dist/types/lib/slice/types.d.ts +567 -0
- package/dist/types/lib/time-travel/axis.d.ts +119 -0
- package/dist/types/lib/time-travel/bundles.d.ts +77 -0
- package/dist/types/lib/time-travel/chain.d.ts +93 -0
- package/dist/types/lib/time-travel/commitStops.d.ts +55 -0
- package/dist/types/lib/time-travel/index.d.ts +22 -0
- package/dist/types/lib/time-travel/stateAt.d.ts +102 -0
- package/dist/types/lib/time-travel/tagStops.d.ts +72 -0
- package/dist/types/lib/time-travel/timeTravel.d.ts +70 -0
- package/dist/types/lib/time-travel/types.d.ts +470 -0
- package/dist/types/paths.d.ts +4 -0
- package/dist/types/write.d.ts +49 -0
- package/dist/write.js +56 -0
- package/package.json +48 -4
|
@@ -0,0 +1,456 @@
|
|
|
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 { findLastWriter } from './commitLogUtils.js';
|
|
60
|
+
import { queryWork, relation, writesOnlyInside } from './keyPaths.js';
|
|
61
|
+
import { logModel } from './logModel.js';
|
|
62
|
+
// ── Staged optimization: writer lookup strategies ──────────────────────
|
|
63
|
+
/**
|
|
64
|
+
* Threshold for switching from linear scan to reverse index.
|
|
65
|
+
* Below this, O(N) scan is faster (no index build cost).
|
|
66
|
+
* Above this, O(log N) binary search wins.
|
|
67
|
+
*/
|
|
68
|
+
const REVERSE_INDEX_THRESHOLD = 256;
|
|
69
|
+
/** Strategy 1: Linear scan — O(N) per lookup, zero setup cost. */
|
|
70
|
+
function linearScanLookup(commitLog) {
|
|
71
|
+
return (key, beforeIdx) => findLastWriter(commitLog, key, beforeIdx);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Strategy 2: Reverse index — one O(rows) build, O(depth + log N) per lookup.
|
|
75
|
+
* The log's read model (`logModel.ts`: a path trie, each top-level key folded at
|
|
76
|
+
* most once) answers the writer rule — rows on, inside or around the key — by
|
|
77
|
+
* binary search for the last write on or inside it, then a verdict only for the
|
|
78
|
+
* around-candidates after it. The same answer `findLastWriter` gives, so the
|
|
79
|
+
* strategy switch below never changes a slice.
|
|
80
|
+
*/
|
|
81
|
+
function reverseIndexLookup(commitLog) {
|
|
82
|
+
const model = logModel(commitLog);
|
|
83
|
+
return (key, beforeIdx) => {
|
|
84
|
+
const at = model.lastWriterBefore(key, beforeIdx);
|
|
85
|
+
return at >= 0 ? commitLog[at] : undefined;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The trace entries of `commit` that make it a writer of `linkKey` — the entries whose read
|
|
90
|
+
* prefix (`TraceEntry.readKeys`) a per-write edge expands through. The FIRST entry on the exact
|
|
91
|
+
* path when there is one (the #P1 behaviour, unchanged); otherwise every entry inside or around
|
|
92
|
+
* the key (9.33.0: the commit wrote the key through a nested row — a subflow seed, an
|
|
93
|
+
* outputMapper merge-back — and all of those rows wrote it).
|
|
94
|
+
*/
|
|
95
|
+
function linkingEntries(commit, linkKey) {
|
|
96
|
+
const exact = commit.trace.find((t) => t.path === linkKey);
|
|
97
|
+
if (exact !== undefined)
|
|
98
|
+
return [exact];
|
|
99
|
+
return commit.trace.filter((t) => relation(t.path, linkKey) !== undefined);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Staged optimization: pick the right writer-lookup strategy based on data size.
|
|
103
|
+
*
|
|
104
|
+
* Like a database query optimizer choosing between sequential scan and index scan:
|
|
105
|
+
*
|
|
106
|
+
* - **Small log (≤ 256):** Linear scan wins. Zero setup cost, good cache locality.
|
|
107
|
+
* The overhead of building a reverse index isn't worth it for short logs.
|
|
108
|
+
*
|
|
109
|
+
* - **Large log (> 256):** Reverse index wins. O(N×U) upfront build cost is amortized
|
|
110
|
+
* across all BFS hops. Each lookup becomes O(log N) via binary search instead of O(N).
|
|
111
|
+
* For an agent loop with 500 iterations and 5 keys per hop, this is 500×5×log(500)≈22K ops
|
|
112
|
+
* vs 500×5×500=1.25M ops with linear scan.
|
|
113
|
+
*
|
|
114
|
+
* The caller never sees this — `causalChain()` picks automatically.
|
|
115
|
+
*/
|
|
116
|
+
function createWriterLookup(commitLog) {
|
|
117
|
+
if (commitLog.length <= REVERSE_INDEX_THRESHOLD) {
|
|
118
|
+
return linearScanLookup(commitLog);
|
|
119
|
+
}
|
|
120
|
+
return reverseIndexLookup(commitLog);
|
|
121
|
+
}
|
|
122
|
+
// ── Core algorithm ─────────────────────────────────────────────────────
|
|
123
|
+
/**
|
|
124
|
+
* RFC-003 D2: the `incompleteSources` node fragment for a commit — `{}`
|
|
125
|
+
* when the stage consumed no untracked read paths, keeping the field
|
|
126
|
+
* ABSENT (not empty-array-valued) for fully-tracked stages.
|
|
127
|
+
*/
|
|
128
|
+
function incompleteSourcesFragment(commit) {
|
|
129
|
+
if (!commit.untrackedSources || commit.untrackedSources.length === 0)
|
|
130
|
+
return {};
|
|
131
|
+
return { incompleteSources: commit.untrackedSources };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Build the causal DAG rooted at `startId` by walking backwards
|
|
135
|
+
* through read→write dependencies in the commit log.
|
|
136
|
+
*
|
|
137
|
+
* Automatically selects the optimal writer lookup strategy:
|
|
138
|
+
* - Linear scan for small logs (≤ 256 commits)
|
|
139
|
+
* - Reverse index with binary search for large logs (> 256 commits)
|
|
140
|
+
*
|
|
141
|
+
* Produces a DAG (not a tree): if two children both read from the same
|
|
142
|
+
* parent, the parent node is shared (deduped by runtimeStageId).
|
|
143
|
+
*
|
|
144
|
+
* @param commitLog Ordered commit bundles from executor.getSnapshot().commitLog
|
|
145
|
+
* @param startId runtimeStageId to start backtracking from
|
|
146
|
+
* @param getKeysRead Callback returning keys read by a given execution step
|
|
147
|
+
* @param options Depth and node limits
|
|
148
|
+
* @returns Root CausalNode with .parents forming the DAG, or undefined if startId not found
|
|
149
|
+
*/
|
|
150
|
+
export function causalChain(commitLog, startId, getKeysRead, options) {
|
|
151
|
+
const maxDepth = options?.maxDepth ?? 20;
|
|
152
|
+
const maxNodes = options?.maxNodes ?? 100;
|
|
153
|
+
const controlDeps = options?.controlDeps;
|
|
154
|
+
const weigh = options?.weigh;
|
|
155
|
+
const perWrite = options?.edgeAttribution === 'per-write';
|
|
156
|
+
// RFC-003 D4 — truncation visibility. Set only when a limit actually
|
|
157
|
+
// cuts the slice; surfaced on the root as `truncated` so a consumer can
|
|
158
|
+
// never mistake a truncated slice for a complete one.
|
|
159
|
+
let truncatedByDepth = false;
|
|
160
|
+
let truncatedByNodes = false;
|
|
161
|
+
// Build position index: runtimeStageId → array position (O(n) once)
|
|
162
|
+
const idxMap = new Map();
|
|
163
|
+
for (let i = 0; i < commitLog.length; i++) {
|
|
164
|
+
idxMap.set(commitLog[i].runtimeStageId, i);
|
|
165
|
+
}
|
|
166
|
+
queryWork.units += commitLog.length;
|
|
167
|
+
const startIdx = idxMap.get(startId);
|
|
168
|
+
if (startIdx === undefined)
|
|
169
|
+
return undefined;
|
|
170
|
+
const startCommit = commitLog[startIdx];
|
|
171
|
+
// Pick writer lookup strategy based on log size
|
|
172
|
+
const findWriter = createWriterLookup(commitLog);
|
|
173
|
+
// Node dedup map: runtimeStageId → CausalNode (ensures DAG, not tree)
|
|
174
|
+
const nodeMap = new Map();
|
|
175
|
+
const root = {
|
|
176
|
+
runtimeStageId: startId,
|
|
177
|
+
stageId: startCommit.stageId,
|
|
178
|
+
stageName: startCommit.stage,
|
|
179
|
+
keysWritten: startCommit.trace.map((t) => t.path),
|
|
180
|
+
linkedBy: '',
|
|
181
|
+
depth: 0,
|
|
182
|
+
parents: [],
|
|
183
|
+
parentEdges: [],
|
|
184
|
+
...incompleteSourcesFragment(startCommit),
|
|
185
|
+
};
|
|
186
|
+
nodeMap.set(startId, root);
|
|
187
|
+
// ── #P1 per-write attribution machinery (inert under 'stage') ──────────
|
|
188
|
+
// expandedReads: per node, the reads already queued for expansion — late
|
|
189
|
+
// links via additional keys re-enqueue only the DELTA, so the slice grows
|
|
190
|
+
// monotonically toward the stage-level ceiling and terminates (each key
|
|
191
|
+
// expands at most once per node). fullyExpanded: nodes that fell back to
|
|
192
|
+
// stage level (no readKeys on a linking entry) — nothing left to add.
|
|
193
|
+
const expandedReads = new Map();
|
|
194
|
+
const fullyExpanded = new Set();
|
|
195
|
+
/**
|
|
196
|
+
* Resolve a node's expansion read-set for the given linking WRITTEN keys.
|
|
197
|
+
* Per-write mode with provenance present → union of the linking writes'
|
|
198
|
+
* temporal-prefix `readKeys`. Any linking entry WITHOUT `readKeys` (or no
|
|
199
|
+
* linkKeys at all) → honest stage-level fallback, flagged via the second
|
|
200
|
+
* tuple member so the caller marks the node fully expanded.
|
|
201
|
+
*/
|
|
202
|
+
function readsForLinks(commit, linkKeys) {
|
|
203
|
+
if (!perWrite || !linkKeys || linkKeys.length === 0) {
|
|
204
|
+
return [getKeysRead(commit.runtimeStageId), true];
|
|
205
|
+
}
|
|
206
|
+
const union = new Set();
|
|
207
|
+
for (const linkKey of linkKeys) {
|
|
208
|
+
const entries = linkingEntries(commit, linkKey);
|
|
209
|
+
if (entries.length === 0 || entries.some((e) => e.readKeys === undefined)) {
|
|
210
|
+
// Mixed/dial-off log — degrade THIS node to stage level, honestly.
|
|
211
|
+
return [getKeysRead(commit.runtimeStageId), true];
|
|
212
|
+
}
|
|
213
|
+
for (const entry of entries)
|
|
214
|
+
for (const rk of entry.readKeys)
|
|
215
|
+
union.add(rk);
|
|
216
|
+
}
|
|
217
|
+
return [[...union], false];
|
|
218
|
+
}
|
|
219
|
+
// BFS/worklist queue: [node, commitIdx, depth, keysToExpand]
|
|
220
|
+
const [rootReads, rootIsFull] = perWrite
|
|
221
|
+
? readsForLinks(startCommit, options?.rootLinkKeys)
|
|
222
|
+
: [getKeysRead(startId), true];
|
|
223
|
+
expandedReads.set(startId, new Set(rootReads));
|
|
224
|
+
if (rootIsFull)
|
|
225
|
+
fullyExpanded.add(startId);
|
|
226
|
+
const queue = [[root, startIdx, 0, rootReads]];
|
|
227
|
+
let visited = 1;
|
|
228
|
+
/**
|
|
229
|
+
* Link `node → parent` (creating + enqueueing the parent when new).
|
|
230
|
+
* Shared by data-edge expansion (read→write) and control-edge expansion
|
|
231
|
+
* (D3). One CausalEdge per distinct (parent, kind, key) link; `parents`
|
|
232
|
+
* keeps its historical one-entry-per-parent dedup.
|
|
233
|
+
*/
|
|
234
|
+
function linkParent(node, parentCommit, kind, key, depth) {
|
|
235
|
+
const parentId = parentCommit.runtimeStageId;
|
|
236
|
+
// the dedup scans below, and the parent's trace when it is new — counted for the scaling test
|
|
237
|
+
queryWork.units += 1 + node.parents.length + node.parentEdges.length;
|
|
238
|
+
// #P1: the parent's expansion reads, resolved LAZILY (only for new nodes
|
|
239
|
+
// or per-write re-expansion — duplicate links under 'stage' pay nothing).
|
|
240
|
+
// Data links expand through the reads that fed the parent's write of
|
|
241
|
+
// `key`; control links expand the decider at stage level (a decision
|
|
242
|
+
// depends on everything it read).
|
|
243
|
+
const resolveLinkReads = () => kind === 'data'
|
|
244
|
+
? readsForLinks(parentCommit, key !== undefined ? [key] : undefined)
|
|
245
|
+
: [getKeysRead(parentId), true];
|
|
246
|
+
let parentNode = nodeMap.get(parentId);
|
|
247
|
+
if (!parentNode) {
|
|
248
|
+
// New node — create and enqueue (respecting the node budget)
|
|
249
|
+
if (visited >= maxNodes) {
|
|
250
|
+
truncatedByNodes = true; // D4: a discovered parent was dropped
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const parentIdx = idxMap.get(parentId);
|
|
254
|
+
if (parentIdx === undefined)
|
|
255
|
+
return;
|
|
256
|
+
queryWork.units += parentCommit.trace.length;
|
|
257
|
+
parentNode = {
|
|
258
|
+
runtimeStageId: parentId,
|
|
259
|
+
stageId: parentCommit.stageId,
|
|
260
|
+
stageName: parentCommit.stage,
|
|
261
|
+
keysWritten: parentCommit.trace.map((t) => t.path),
|
|
262
|
+
// linkedBy stays a DATA-key concept (back-compat) — control-linked
|
|
263
|
+
// nodes carry their label on the edge instead.
|
|
264
|
+
linkedBy: kind === 'data' ? key ?? '' : '',
|
|
265
|
+
depth: depth + 1,
|
|
266
|
+
parents: [],
|
|
267
|
+
parentEdges: [],
|
|
268
|
+
...incompleteSourcesFragment(parentCommit),
|
|
269
|
+
};
|
|
270
|
+
nodeMap.set(parentId, parentNode);
|
|
271
|
+
visited++;
|
|
272
|
+
const [linkReads, linkIsFull] = resolveLinkReads();
|
|
273
|
+
expandedReads.set(parentId, new Set(linkReads));
|
|
274
|
+
if (linkIsFull)
|
|
275
|
+
fullyExpanded.add(parentId);
|
|
276
|
+
queue.push([parentNode, parentIdx, depth + 1, linkReads]);
|
|
277
|
+
}
|
|
278
|
+
else if (perWrite && !fullyExpanded.has(parentId)) {
|
|
279
|
+
// #P1 worklist: an EXISTING node linked via another key may owe more
|
|
280
|
+
// expansion — enqueue only the reads not yet expanded (monotone; the
|
|
281
|
+
// node budget is untouched, no node is created). Re-expansion keeps
|
|
282
|
+
// the node's ORIGINAL depth so its parents get a consistent depth+1.
|
|
283
|
+
const [linkReads, linkIsFull] = resolveLinkReads();
|
|
284
|
+
const expanded = expandedReads.get(parentId);
|
|
285
|
+
const delta = linkReads.filter((k) => !expanded.has(k));
|
|
286
|
+
if (linkIsFull)
|
|
287
|
+
fullyExpanded.add(parentId);
|
|
288
|
+
if (delta.length > 0) {
|
|
289
|
+
for (const k of delta)
|
|
290
|
+
expanded.add(k);
|
|
291
|
+
const parentIdx = idxMap.get(parentId);
|
|
292
|
+
if (parentIdx !== undefined)
|
|
293
|
+
queue.push([parentNode, parentIdx, parentNode.depth, delta]);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// DAG merge: one parents[] entry per distinct parent (historical shape)
|
|
297
|
+
if (!node.parents.some((p) => p.runtimeStageId === parentId)) {
|
|
298
|
+
node.parents.push(parentNode);
|
|
299
|
+
}
|
|
300
|
+
// One edge per distinct (parent, kind, key) link. The weigher (D4)
|
|
301
|
+
// stamps the weight at creation; `undefined` → 1.0 — the engine never
|
|
302
|
+
// computes weights itself.
|
|
303
|
+
if (!node.parentEdges.some((e) => e.parent.runtimeStageId === parentId && e.kind === kind && e.key === key)) {
|
|
304
|
+
// Error isolation (review finding): a consumer weigher that throws
|
|
305
|
+
// must degrade to the default weight, never crash the slice — the
|
|
306
|
+
// same contract every other consumer callback in the library gets.
|
|
307
|
+
let weight = 1.0;
|
|
308
|
+
if (weigh) {
|
|
309
|
+
try {
|
|
310
|
+
weight = weigh(node, parentNode, key, kind) ?? 1.0;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
/* weigher threw — keep 1.0, the slice stays usable */
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const nested = kind === 'data' && key !== undefined && writesOnlyInside(parentCommit, key);
|
|
317
|
+
node.parentEdges.push({
|
|
318
|
+
parent: parentNode,
|
|
319
|
+
kind,
|
|
320
|
+
key,
|
|
321
|
+
weight,
|
|
322
|
+
...(nested && { basis: 'nested-rows' }),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
while (queue.length > 0) {
|
|
327
|
+
const [node, commitIdx, depth, keysToExpand] = queue.shift();
|
|
328
|
+
queryWork.units += 1 + keysToExpand.length;
|
|
329
|
+
if (depth >= maxDepth) {
|
|
330
|
+
// D4: only a node that still HAD something to expand counts as a cut
|
|
331
|
+
// (a leaf at the horizon truncates nothing).
|
|
332
|
+
if (keysToExpand.length > 0 || controlDeps?.(node.runtimeStageId) !== undefined) {
|
|
333
|
+
truncatedByDepth = true;
|
|
334
|
+
}
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
// Data edges: for each key in this expansion's read-set, find who wrote
|
|
338
|
+
// it. Under 'stage' this is the node's full read-set (historical
|
|
339
|
+
// behavior); under 'per-write' it is the linking writes' temporal prefix
|
|
340
|
+
// (or a worklist delta on re-expansion).
|
|
341
|
+
const keysRead = keysToExpand;
|
|
342
|
+
for (const key of keysRead) {
|
|
343
|
+
const writer = findWriter(key, commitIdx);
|
|
344
|
+
if (!writer) {
|
|
345
|
+
// F4b: say why there is no edge — no commit before this node wrote the key.
|
|
346
|
+
node.preRunReads ??= { code: 'pre-run-origin', keys: [] };
|
|
347
|
+
if (!node.preRunReads.keys.includes(key))
|
|
348
|
+
node.preRunReads.keys.push(key);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
linkParent(node, writer, 'data', key, depth);
|
|
352
|
+
}
|
|
353
|
+
// Control edge (RFC-003 D3): link the governing decider, labeled by the
|
|
354
|
+
// decide() rule label when present. The decider node then expands
|
|
355
|
+
// normally through its own data reads (and its own control parent).
|
|
356
|
+
if (controlDeps) {
|
|
357
|
+
const dep = controlDeps(node.runtimeStageId);
|
|
358
|
+
if (dep) {
|
|
359
|
+
const deciderIdx = idxMap.get(dep.deciderId);
|
|
360
|
+
if (deciderIdx !== undefined) {
|
|
361
|
+
linkParent(node, commitLog[deciderIdx], 'control', dep.label, depth);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// RFC-003 D4 — truncation visibility on the root (absent when complete). It is DATA, and only
|
|
367
|
+
// data: a reader reads `root.truncated` (and `formatCausalChain` prints it); the record writes no
|
|
368
|
+
// console line (the dev-mode warning went in C6 — the record imports nothing outside itself).
|
|
369
|
+
if (truncatedByDepth || truncatedByNodes) {
|
|
370
|
+
root.truncated = { byDepth: truncatedByDepth, byNodes: truncatedByNodes };
|
|
371
|
+
}
|
|
372
|
+
return root;
|
|
373
|
+
}
|
|
374
|
+
// ── Utilities ──────────────────────────────────────────────────────────
|
|
375
|
+
/**
|
|
376
|
+
* Flatten the causal DAG into a BFS-ordered list of nodes.
|
|
377
|
+
* Each node appears exactly once (first occurrence by BFS order).
|
|
378
|
+
* Useful for linear display or iteration.
|
|
379
|
+
*/
|
|
380
|
+
export function flattenCausalDAG(root) {
|
|
381
|
+
const result = [];
|
|
382
|
+
const visited = new Set();
|
|
383
|
+
const queue = [root];
|
|
384
|
+
while (queue.length > 0) {
|
|
385
|
+
const node = queue.shift();
|
|
386
|
+
if (visited.has(node.runtimeStageId))
|
|
387
|
+
continue;
|
|
388
|
+
visited.add(node.runtimeStageId);
|
|
389
|
+
result.push(node);
|
|
390
|
+
for (const parent of node.parents) {
|
|
391
|
+
if (!visited.has(parent.runtimeStageId)) {
|
|
392
|
+
queue.push(parent);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return result;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Format a causal DAG as human-readable indented text.
|
|
400
|
+
* Shows the dependency chain with depth indentation and linked-by keys.
|
|
401
|
+
*
|
|
402
|
+
* RFC-003 D2: nodes that consumed untracked read paths render an extra
|
|
403
|
+
* `⚠ also consumed … — slice may be incomplete here` line, so a consumer
|
|
404
|
+
* (human or LLM) debugging from the slice is TOLD when it is incomplete.
|
|
405
|
+
*
|
|
406
|
+
* RFC-003 D3: control edges render as `← [control: <rule label>]`
|
|
407
|
+
* (label omitted when the decision carried none). Data rendering is
|
|
408
|
+
* byte-identical to the pre-D3 output — `← via <key>` from the node's
|
|
409
|
+
* discovery-time `linkedBy`.
|
|
410
|
+
*
|
|
411
|
+
* RFC-003 D4: edge weights from the `weigh` hook render as a suffix —
|
|
412
|
+
* `← via systemPrompt (0.18)` — only when ≠ 1.0, so unweighted output is
|
|
413
|
+
* unchanged. A truncated slice (root.truncated) appends a final
|
|
414
|
+
* `⚠ slice truncated …` line.
|
|
415
|
+
*/
|
|
416
|
+
export function formatCausalChain(root) {
|
|
417
|
+
const lines = [];
|
|
418
|
+
const visited = new Set();
|
|
419
|
+
const weightSuffix = (edge) => edge !== undefined && edge.weight !== 1 ? ` (${edge.weight})` : '';
|
|
420
|
+
function walk(node, indent, edgesFromChild) {
|
|
421
|
+
if (visited.has(node.runtimeStageId)) {
|
|
422
|
+
lines.push(`${' '.repeat(indent)}↳ ${node.runtimeStageId} (see above)`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
visited.add(node.runtimeStageId);
|
|
426
|
+
const linkParts = [];
|
|
427
|
+
if (node.linkedBy) {
|
|
428
|
+
const dataEdge = edgesFromChild?.find((e) => e.kind === 'data');
|
|
429
|
+
linkParts.push(`via ${node.linkedBy}${weightSuffix(dataEdge)}`);
|
|
430
|
+
}
|
|
431
|
+
const controlEdge = edgesFromChild?.find((e) => e.kind === 'control');
|
|
432
|
+
if (controlEdge) {
|
|
433
|
+
linkParts.push(`[control${controlEdge.key ? `: ${controlEdge.key}` : ''}]${weightSuffix(controlEdge)}`);
|
|
434
|
+
}
|
|
435
|
+
const link = linkParts.length > 0 ? ` ← ${linkParts.join(' ← ')}` : '';
|
|
436
|
+
const writes = node.keysWritten.length > 0 ? ` [wrote: ${node.keysWritten.join(', ')}]` : '';
|
|
437
|
+
lines.push(`${' '.repeat(indent)}${node.stageName} (${node.runtimeStageId})${link}${writes}`);
|
|
438
|
+
if (node.incompleteSources && node.incompleteSources.length > 0) {
|
|
439
|
+
lines.push(`${' '.repeat(indent + 1)}⚠ also consumed ${node.incompleteSources.join('/')} — slice may be incomplete here`);
|
|
440
|
+
}
|
|
441
|
+
for (const parent of node.parents) {
|
|
442
|
+
walk(parent, indent + 1, node.parentEdges.filter((e) => e.parent === parent));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
walk(root, 0);
|
|
446
|
+
if (root.truncated) {
|
|
447
|
+
const causes = [root.truncated.byDepth && 'maxDepth reached', root.truncated.byNodes && 'maxNodes reached']
|
|
448
|
+
.filter(Boolean)
|
|
449
|
+
.join(', ');
|
|
450
|
+
lines.push(`⚠ slice truncated (${causes}) — older causes exist beyond this horizon`);
|
|
451
|
+
}
|
|
452
|
+
return lines.join('\n');
|
|
453
|
+
}
|
|
454
|
+
// ── Exported for testing (internal) ────────────────────────────────────
|
|
455
|
+
/** @internal Exposed for testing the strategy selection. */
|
|
456
|
+
export const _REVERSE_INDEX_THRESHOLD = REVERSE_INDEX_THRESHOLD;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed utilities for querying the commit log.
|
|
3
|
+
*
|
|
4
|
+
* The commitLog is an ordered array of CommitBundle — one per stage commit.
|
|
5
|
+
* These helpers provide type-safe queries without (b: any) casts.
|
|
6
|
+
*/
|
|
7
|
+
import type { RegisteredCode } from './honesty.js';
|
|
8
|
+
import type { CommitBundle, CommitPhase } from './types.js';
|
|
9
|
+
import { type Touch } from './verbs.js';
|
|
10
|
+
/**
|
|
11
|
+
* Find the first commit by `stageId`, optionally the first one that WROTE `key` — under the writer
|
|
12
|
+
* rule: a row on the key, inside it, or around it that changed it (see {@link writersOf}).
|
|
13
|
+
*/
|
|
14
|
+
export declare function findCommit(commitLog: CommitBundle[], stageId: string, key?: string): CommitBundle | undefined;
|
|
15
|
+
/** Find all commits by stageId. */
|
|
16
|
+
export declare function findCommits(commitLog: CommitBundle[], stageId: string): CommitBundle[];
|
|
17
|
+
/**
|
|
18
|
+
* Find the last commit that WROTE `key` before `beforeIdx` (exclusive; default: the whole log) —
|
|
19
|
+
* the anchor of every backward question ("who made it this?").
|
|
20
|
+
*
|
|
21
|
+
* The writer rule (`keyPaths.ts`): a commit writes `key` when one of its rows is ON the key,
|
|
22
|
+
* INSIDE it (a subflow's input seed writes `cfg␟a`; an outputMapper merge-back writes `cfg␟b`;
|
|
23
|
+
* either is a write of `cfg`), or AROUND it with the value at the key different across the commit
|
|
24
|
+
* (a `set` of `cfg` writes `cfg␟a`; a `merge` of `cfg` that never reaches `a` does not). A write
|
|
25
|
+
* found through a row INSIDE the key wrote only PART of its value — the slice readers say so with
|
|
26
|
+
* the `'nested-rows'` note.
|
|
27
|
+
*
|
|
28
|
+
* @param key A DELIM-joined path (`normalisePath`); a top-level key is a one-segment path.
|
|
29
|
+
*/
|
|
30
|
+
export declare function findLastWriter(commitLog: CommitBundle[], key: string, beforeIdx?: number): CommitBundle | undefined;
|
|
31
|
+
/** What {@link writersOf} is asked. */
|
|
32
|
+
export interface WritersOptions {
|
|
33
|
+
/** The last commit ARRAY index considered, inclusive. Default: the whole log. */
|
|
34
|
+
readonly end?: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The ARRAY positions (ascending) of every commit in `commitLog[0..end]` that WROTE `key` — the
|
|
38
|
+
* writer rule of `keyPaths.ts`, the one definition every key query shares (`logModel.ts` applies it).
|
|
39
|
+
*/
|
|
40
|
+
export declare function writersOf(commitLog: readonly CommitBundle[], key: string, options?: WritersOptions): number[];
|
|
41
|
+
/**
|
|
42
|
+
* Every row under `key`'s TOP-LEVEL key in `commitLog[0..end]`, in commit order — the rows the value
|
|
43
|
+
* rule folds ({@link foldKey}), each with its {@link Touch.relation} to `key` (absent for a sibling
|
|
44
|
+
* under the same top-level key: applied, not observed) — found through the log's index. A row whose
|
|
45
|
+
* verb is not one of the four is refused with {@link UnknownVerbError} naming the row — whether or
|
|
46
|
+
* not the fold would have reached it: the answer must not depend on where an optimisation starts.
|
|
47
|
+
*/
|
|
48
|
+
export declare function rowsUnderRoot(commitLog: readonly CommitBundle[], key: string, end: number): Touch[];
|
|
49
|
+
/**
|
|
50
|
+
* Reconstruct the FULL value of `key` as of commit array index `idx`
|
|
51
|
+
* (inclusive) — the migration helper for the "read `bundle.overwrite[key]`
|
|
52
|
+
* as the full value written" pattern (#13c-B).
|
|
53
|
+
*
|
|
54
|
+
* The value rule (`keyPaths.ts`, 9.33.0): every row under the key's TOP-LEVEL
|
|
55
|
+
* key in `commitLog[0..idx]` is folded, anchored at that key's last `set` /
|
|
56
|
+
* `delete`, with the SAME step and clone discipline the replay uses
|
|
57
|
+
* (`verbs.ts` · `foldKey`), and the result is read at `key`. So the answer is
|
|
58
|
+
* the value `stateAt` gives at that path when it folds the log alone — rows
|
|
59
|
+
* INSIDE the key (a subflow seed, an outputMapper merge-back) and AROUND it (a
|
|
60
|
+
* `set` of the container) count; before 9.33.0 only rows on the exact path did,
|
|
61
|
+
* and a seeded `cfg` read as `undefined`. Under `commitValues: 'delta'`, an
|
|
62
|
+
* `append` row's `overwrite[key]` holds only the TAIL; the fold puts the array
|
|
63
|
+
* back together. Two `merge` rows of one bundle see one copy of the bundle's
|
|
64
|
+
* delta, as the replay's do.
|
|
65
|
+
*
|
|
66
|
+
* @param key A DELIM-joined path (`normalisePath`); a top-level key is a
|
|
67
|
+
* one-segment path.
|
|
68
|
+
* @param idx CommitBundle ARRAY index (the `bundle.idx` position),
|
|
69
|
+
* inclusive. NOT the executionIndex from a runtimeStageId.
|
|
70
|
+
* @returns The reconstructed value (a detached clone), or `undefined` when no
|
|
71
|
+
* row in `commitLog[0..idx]` is on, inside or around the key, or the fold
|
|
72
|
+
* leaves it absent (its last write was a delete). Caveat: a key that held a
|
|
73
|
+
* value in the run's INITIAL state folds from absent here — the log alone
|
|
74
|
+
* cannot see the pre-run base, so merges and nested writes onto a seeded key
|
|
75
|
+
* give only what they added. Since 9.17.0 the base TRAVELS with the log:
|
|
76
|
+
* `stateAt(snapshot, idx)` (foottrace) folds from
|
|
77
|
+
* `RuntimeSnapshot.initialState`, which this function never receives.
|
|
78
|
+
* @throws {@link UnknownVerbError} when a row under the key's top-level key
|
|
79
|
+
* carries a verb other than `set | merge | append | delete` — a foreign or
|
|
80
|
+
* corrupted log is refused, not folded as a `merge`. Engine-written logs
|
|
81
|
+
* never carry one.
|
|
82
|
+
* @see commitValueAtWithBasis — the same answer, with the codes that say WHY it is
|
|
83
|
+
* `undefined` or partial (and the `initialState` base this function never receives).
|
|
84
|
+
*/
|
|
85
|
+
export declare function commitValueAt(commitLog: CommitBundle[], idx: number, key: string): unknown;
|
|
86
|
+
/**
|
|
87
|
+
* Why {@link commitValueAtWithBasis} answered what it did. Each code is registered, with the one
|
|
88
|
+
* sentence that says what it means, in `memory/honesty.ts · HONESTY_CODES` (served on
|
|
89
|
+
* `foottrace`); the union declares its members through `RegisteredCode`.
|
|
90
|
+
*
|
|
91
|
+
* - `'never-written'` — no commit in range wrote the key (the writer rule — the answer
|
|
92
|
+
* `findLastWriter` and the slice layer give), unless the run removed a value
|
|
93
|
+
* the passed `initialState` held (then `'deleted'`), or a redaction at or
|
|
94
|
+
* around the key hid the write (then `'redacted'` alone).
|
|
95
|
+
* - `'deleted'` — the answer is `undefined` and the key WAS written: its last write left it
|
|
96
|
+
* absent.
|
|
97
|
+
* - `'nested-rows'` — the value rests on rows INSIDE the key (a subflow seed, an outputMapper
|
|
98
|
+
* merge-back, a fork child's namespace) with no `set`/`delete` of the key or
|
|
99
|
+
* of a container around it in range.
|
|
100
|
+
* - `'from-initial-state'` — no `set`/`delete` of the key or around it in range, so the value rests on
|
|
101
|
+
* the pre-run base: folded from `options.initialState` when it holds the
|
|
102
|
+
* key's top-level key, partial when no `initialState` was passed.
|
|
103
|
+
* - `'redacted'` — a path at, inside or around the key is in the `redactedPaths` of a commit
|
|
104
|
+
* the value rests on (from its last `set`/`delete` on, or the whole range).
|
|
105
|
+
*/
|
|
106
|
+
export type ValueBasis = RegisteredCode<'never-written' | 'deleted' | 'nested-rows' | 'from-initial-state' | 'redacted'>;
|
|
107
|
+
/** What {@link commitValueAtWithBasis} is asked besides the log, the index and the key. */
|
|
108
|
+
export interface ValueBasisOptions {
|
|
109
|
+
/**
|
|
110
|
+
* The state before the run — `RuntimeSnapshot.initialState` (or a subflow's
|
|
111
|
+
* `treeContext.initialState`). When given, a key with no `set`/`delete` in range folds from it, as
|
|
112
|
+
* `stateAt` does; when absent, such an answer is the log-only fold and carries `'from-initial-state'`.
|
|
113
|
+
*/
|
|
114
|
+
readonly initialState?: Readonly<Record<string, unknown>>;
|
|
115
|
+
}
|
|
116
|
+
/** A key's value read off the log, and the codes that say what it rests on — absent codes = an exact answer. */
|
|
117
|
+
export interface ValueWithBasis {
|
|
118
|
+
readonly value: unknown;
|
|
119
|
+
/** In the order of {@link ValueBasis}'s list; EMPTY when a `set`/`delete` of the key or around it anchors the value and no redaction touched it. */
|
|
120
|
+
readonly basis: ValueBasis[];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* {@link commitValueAt}, with the codes that say what the answer rests on. Without `options.initialState`
|
|
124
|
+
* the value IS `commitValueAt(commitLog, idx, key)` (pinned by a property); with it, a key that has no
|
|
125
|
+
* `set`/`delete` of itself or of a container around it in range folds from that base — the value
|
|
126
|
+
* `stateAt(snapshot, idx)` gives at the key. Refuses an unknown verb exactly as `commitValueAt` does.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* ```typescript
|
|
130
|
+
* const { value, basis } = commitValueAtWithBasis(snap.commitLog, i, 'cfg', { initialState: snap.initialState });
|
|
131
|
+
* for (const code of basis) console.log(code, HONESTY_CODES[code]);
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export declare function commitValueAtWithBasis(commitLog: CommitBundle[], idx: number, key: string, options?: ValueBasisOptions): ValueWithBasis;
|
|
135
|
+
/**
|
|
136
|
+
* Why {@link findLastWriterWithBasis} answered what it did — registered in `HONESTY_CODES`:
|
|
137
|
+
* `'never-written'` (no writer before the bound), `'nested-rows'` (the writer reached the key only
|
|
138
|
+
* through rows inside it, so it wrote part of the value), `'redacted'` (no writer the log can see, because a
|
|
139
|
+
* redaction at or around the key hid the write — the rule `commitValueAtWithBasis` follows).
|
|
140
|
+
*/
|
|
141
|
+
export type WriterBasis = RegisteredCode<'never-written' | 'nested-rows' | 'redacted'>;
|
|
142
|
+
/** The last writer of a key, and the codes that say what kind of write it was — absent codes = a write on or around the key. */
|
|
143
|
+
export interface WriterWithBasis {
|
|
144
|
+
/** Absent exactly when `basis` holds `'never-written'` or `'redacted'`. */
|
|
145
|
+
readonly writer?: CommitBundle;
|
|
146
|
+
readonly basis: WriterBasis[];
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* {@link findLastWriter}, with the code that says why: `writer` is `findLastWriter(commitLog, key, beforeIdx)`;
|
|
150
|
+
* `basis` is `['never-written']` when there is none and `['nested-rows']` when that commit wrote the key
|
|
151
|
+
* only through rows inside it.
|
|
152
|
+
*/
|
|
153
|
+
export declare function findLastWriterWithBasis(commitLog: CommitBundle[], key: string, beforeIdx?: number): WriterWithBasis;
|
|
154
|
+
/**
|
|
155
|
+
* Position index over a commit log: `runtimeStageId` → the ARRAY INDEX of the
|
|
156
|
+
* FIRST bundle that stage committed. The map's insertion order is the order
|
|
157
|
+
* the stages first committed — execution order.
|
|
158
|
+
*
|
|
159
|
+
* "First" is the contract, and it is load-bearing. A stage normally commits
|
|
160
|
+
* exactly one bundle, but some executions commit more under one
|
|
161
|
+
* `runtimeStageId`: a subflow MOUNT (the merge-back, then the exit), a fork
|
|
162
|
+
* child (its own commit, then the fan-out's). Since 9.39.0 the WRITER names
|
|
163
|
+
* each continuation on the bundle (`CommitBundle.phase`: `'exit'` /
|
|
164
|
+
* `'repeat'`), and every continuation is written after the execution's own
|
|
165
|
+
* bundle — so "where does this stage start?" is the first bundle, and the
|
|
166
|
+
* grouping every reader's axis uses (`commitStops`) is this index. No reader
|
|
167
|
+
* tells a continuation from the log's shape.
|
|
168
|
+
*
|
|
169
|
+
* Build this ONCE when resolving many ids; use {@link commitIndexOf} for a
|
|
170
|
+
* single lookup.
|
|
171
|
+
*
|
|
172
|
+
* @param commitLog Ordered commit bundles — `getSnapshot().commitLog`.
|
|
173
|
+
* @returns A fresh Map, owned by the caller.
|
|
174
|
+
*/
|
|
175
|
+
export declare function buildCommitIndex(commitLog: readonly CommitBundle[]): Map<string, number>;
|
|
176
|
+
/**
|
|
177
|
+
* Whether a log RECORDS phases — any bundle carries `phase` — i.e. it was
|
|
178
|
+
* written by 9.39.0 or later and holds at least one continuation. A log that
|
|
179
|
+
* does not either has no continuation at all, or predates the field (see
|
|
180
|
+
* {@link inferLegacyPhases}).
|
|
181
|
+
*/
|
|
182
|
+
export declare function recordsPhases(commitLog: readonly CommitBundle[]): boolean;
|
|
183
|
+
/**
|
|
184
|
+
* THE LEGACY READER — the one place a continuation's phase is INFERRED, for a
|
|
185
|
+
* log written before 9.39.0 (which carries no `phase` anywhere). Returns the
|
|
186
|
+
* phase each bundle would have been stamped with, by the rule every reader
|
|
187
|
+
* applied through 9.38.0: a stage's bundle that IMMEDIATELY follows its first
|
|
188
|
+
* bundle is a mount's exit; any later bundle of the same `runtimeStageId` is
|
|
189
|
+
* a fork child's repeat; a first bundle is the stage's own (`undefined`).
|
|
190
|
+
*
|
|
191
|
+
* The rule is a guess, and the guess has a known miss — a single-child fork
|
|
192
|
+
* whose child is a leaf looks exactly like a mount — which is why a reader
|
|
193
|
+
* asks it only when the log records no phase AND it has no execution tree to
|
|
194
|
+
* ask instead (`commitStops`). A log that records phases is never inferred.
|
|
195
|
+
*/
|
|
196
|
+
export declare function inferLegacyPhases(commitLog: readonly CommitBundle[]): (CommitPhase | undefined)[];
|
|
197
|
+
/**
|
|
198
|
+
* The ARRAY INDEX of the first commit a given `runtimeStageId` produced, or
|
|
199
|
+
* `-1` when that stage is not in this log (`indexOf` semantics — the name's
|
|
200
|
+
* promise).
|
|
201
|
+
*
|
|
202
|
+
* This is the address translation every reader needs and nobody exported: a
|
|
203
|
+
* `runtimeStageId` is the id an event carries, a commit index is what
|
|
204
|
+
* `commitValueAt` / `stateAt` / `CommitRangeIndex` speak. O(n) — for many
|
|
205
|
+
* lookups build the map once with {@link buildCommitIndex}.
|
|
206
|
+
*
|
|
207
|
+
* A stage that ran inside a SUBFLOW is not in the run-level log (subflows
|
|
208
|
+
* commit to their own isolated log); look it up in that subflow's own
|
|
209
|
+
* `history` instead — see `getSubtreeSnapshot`.
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* ```typescript
|
|
213
|
+
* const idx = commitIndexOf(snapshot.commitLog, 'score-risk#7');
|
|
214
|
+
* const before = idx > 0 ? commitValueAt(snapshot.commitLog, idx - 1, 'risk') : undefined;
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
217
|
+
export declare function commitIndexOf(commitLog: readonly CommitBundle[], runtimeStageId: string): number;
|