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,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;
|