sliftutils 1.7.65 → 1.7.67
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/CLAUDE.md +1 -0
- package/dist/test.ts.cache +187 -36
- package/dist/treeSummary.ts.cache +359 -0
- package/index.d.ts +56 -18
- package/package.json +1 -1
- package/storage/ArchivesDisk.d.ts +2 -2
- package/storage/ArchivesDisk.ts +3 -2
- package/storage/IArchives.d.ts +19 -2
- package/storage/IArchives.ts +23 -5
- package/storage/backblaze.d.ts +2 -2
- package/storage/backblaze.ts +5 -4
- package/storage/dist/ArchivesDisk.ts.cache +6 -4
- package/storage/dist/IArchives.ts.cache +6 -6
- package/storage/dist/backblaze.ts.cache +7 -6
- package/storage/remoteStorage/ArchivesRemote.d.ts +2 -2
- package/storage/remoteStorage/ArchivesRemote.ts +5 -5
- package/storage/remoteStorage/ArchivesUrl.d.ts +2 -2
- package/storage/remoteStorage/ArchivesUrl.ts +8 -6
- package/storage/remoteStorage/blobStore.d.ts +21 -3
- package/storage/remoteStorage/blobStore.ts +43 -12
- package/storage/remoteStorage/createArchives.d.ts +2 -2
- package/storage/remoteStorage/createArchives.ts +5 -6
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +7 -7
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +12 -8
- package/storage/remoteStorage/dist/blobStore.ts.cache +43 -13
- package/storage/remoteStorage/dist/createArchives.ts.cache +6 -7
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +3 -4
- package/storage/remoteStorage/dist/storageController.ts.cache +11 -8
- package/storage/remoteStorage/dist/storageServerState.ts.cache +38 -9
- package/storage/remoteStorage/sourceWrapper.ts +2 -3
- package/storage/remoteStorage/storageController.d.ts +3 -3
- package/storage/remoteStorage/storageController.ts +8 -5
- package/storage/remoteStorage/storageServerState.d.ts +3 -0
- package/storage/remoteStorage/storageServerState.ts +34 -7
- package/test-files-cache.json +1 -0
- package/test.ts +127 -10
- package/treeSummary.ts +331 -54
package/test.ts
CHANGED
|
@@ -1,22 +1,42 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
+
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
4
|
+
import { sort } from "socket-function/src/misc";
|
|
3
5
|
import { TreeSummary } from "./treeSummary";
|
|
4
6
|
|
|
5
7
|
const SUMMARY_COUNT = 30;
|
|
8
|
+
const TIMING_RUNS = 20;
|
|
9
|
+
const SCAN_DIRS = [
|
|
10
|
+
"D:/repos/qs-cyoa",
|
|
11
|
+
"D:/repos/querysub",
|
|
12
|
+
"D:/repos/socket-function",
|
|
13
|
+
"D:/repos/sliftutils",
|
|
14
|
+
"D:/repos/inference-weights",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// Matches the gitignored "test*.json" pattern, so the cache never gets tracked.
|
|
18
|
+
const CACHE_FILE = "D:/repos/sliftutils/test-files-cache.json";
|
|
6
19
|
|
|
7
20
|
type FileInfo = { path: string; size: number };
|
|
8
21
|
type FileSummary = { count: number; totalSize: number };
|
|
9
22
|
|
|
10
|
-
async function collectFiles(
|
|
23
|
+
async function collectFiles(dirs: string[], output: FileInfo[]) {
|
|
24
|
+
for (let dir of dirs) {
|
|
25
|
+
let basePath = dir.endsWith("/") && dir || dir + "/";
|
|
26
|
+
await collectDir(dir, basePath, output);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function collectDir(dir: string, basePath: string, output: FileInfo[]) {
|
|
11
31
|
let entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
12
32
|
for (let entry of entries) {
|
|
13
33
|
let fullPath = path.join(dir, entry.name);
|
|
14
|
-
let
|
|
34
|
+
let entryPath = basePath + entry.name;
|
|
15
35
|
if (entry.isDirectory()) {
|
|
16
|
-
await
|
|
36
|
+
await collectDir(fullPath, entryPath + "/", output);
|
|
17
37
|
} else if (entry.isFile()) {
|
|
18
38
|
let stats = await fs.promises.stat(fullPath);
|
|
19
|
-
output.push({ path:
|
|
39
|
+
output.push({ path: entryPath, size: stats.size });
|
|
20
40
|
}
|
|
21
41
|
}
|
|
22
42
|
}
|
|
@@ -29,26 +49,123 @@ function summarize(files: FileInfo[], weightBySize: boolean) {
|
|
|
29
49
|
summary.count++;
|
|
30
50
|
summary.totalSize += file.size;
|
|
31
51
|
},
|
|
52
|
+
mergeSummaries: (target, source) => {
|
|
53
|
+
target.count += source.count;
|
|
54
|
+
target.totalSize += source.totalSize;
|
|
55
|
+
},
|
|
32
56
|
getWeight: summary => {
|
|
33
57
|
if (weightBySize) return summary.totalSize;
|
|
34
58
|
return summary.count;
|
|
35
59
|
},
|
|
60
|
+
expectedOutputCount: SUMMARY_COUNT,
|
|
36
61
|
});
|
|
62
|
+
let addStart = performance.now();
|
|
37
63
|
for (let file of files) {
|
|
38
64
|
tree.add(file);
|
|
39
65
|
}
|
|
66
|
+
let addTime = performance.now() - addStart;
|
|
67
|
+
let queryStart = performance.now();
|
|
68
|
+
let summaries = tree.getSummaries(SUMMARY_COUNT);
|
|
69
|
+
for (let i = 1; i < TIMING_RUNS; i++) {
|
|
70
|
+
summaries = tree.getSummaries(SUMMARY_COUNT);
|
|
71
|
+
}
|
|
72
|
+
let queryTime = (performance.now() - queryStart) / TIMING_RUNS;
|
|
40
73
|
let label = weightBySize && "size" || "count";
|
|
41
74
|
console.log(`\n=== weighted by ${label}, ${SUMMARY_COUNT} entries ===`);
|
|
42
|
-
for (
|
|
43
|
-
|
|
44
|
-
|
|
75
|
+
console.log(`add: ${formatTime(addTime)} total for ${formatNumber(files.length)} files, tracking ${formatNumber(tree.getTrackedNodeCount())} nodes, getSummaries: ${formatTime(queryTime)} avg over ${TIMING_RUNS} runs`);
|
|
76
|
+
let totalWeight = 0;
|
|
77
|
+
for (let entry of summaries) {
|
|
78
|
+
totalWeight += entry.weight;
|
|
79
|
+
}
|
|
80
|
+
sort(summaries, entry => -entry.weight);
|
|
81
|
+
// Verify each entry against the raw file list: assign every file to its most specific displayed entry (exact paths beat prefixes, longer prefixes beat shorter), which is the partition the entries claim to represent.
|
|
82
|
+
let actuals = summaries.map(() => ({ count: 0, totalSize: 0 }));
|
|
83
|
+
let unmatchedCount = 0;
|
|
84
|
+
let unmatchedSize = 0;
|
|
85
|
+
for (let file of files) {
|
|
86
|
+
let bestIndex = -1;
|
|
87
|
+
let bestLen = -1;
|
|
88
|
+
for (let i = 0; i < summaries.length; i++) {
|
|
89
|
+
let entryPath = summaries[i].path;
|
|
90
|
+
if (entryPath.endsWith("*")) {
|
|
91
|
+
let prefix = entryPath.slice(0, -1);
|
|
92
|
+
if (prefix.length > bestLen && file.path.startsWith(prefix)) {
|
|
93
|
+
bestIndex = i;
|
|
94
|
+
bestLen = prefix.length;
|
|
95
|
+
}
|
|
96
|
+
} else if (file.path === entryPath && entryPath.length + 1 > bestLen) {
|
|
97
|
+
bestIndex = i;
|
|
98
|
+
bestLen = entryPath.length + 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (bestIndex === -1) {
|
|
102
|
+
unmatchedCount++;
|
|
103
|
+
unmatchedSize += file.size;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
actuals[bestIndex].count++;
|
|
107
|
+
actuals[bestIndex].totalSize += file.size;
|
|
108
|
+
}
|
|
109
|
+
let shownPercent = 0;
|
|
110
|
+
let specificPercent = 0;
|
|
111
|
+
for (let i = 0; i < summaries.length; i++) {
|
|
112
|
+
let entry = summaries[i];
|
|
113
|
+
let percent = entry.weight / totalWeight * 100;
|
|
114
|
+
shownPercent += percent;
|
|
115
|
+
if (entry.kind === "self" || entry.kind === "subtree") {
|
|
116
|
+
specificPercent += percent;
|
|
117
|
+
}
|
|
118
|
+
let actual = actuals[i];
|
|
119
|
+
let diff = "";
|
|
120
|
+
if (actual.count !== entry.summary.count || actual.totalSize !== entry.summary.totalSize) {
|
|
121
|
+
diff = ` DIFF actual files=${formatNumber(actual.count)} size=${formatNumber(actual.totalSize)}B`;
|
|
122
|
+
}
|
|
123
|
+
console.log(`${percent.toFixed(1).padStart(5)}% ${entry.path.padEnd(60)} files=${formatNumber(entry.summary.count).padStart(6)} size=${(formatNumber(entry.summary.totalSize) + "B").padStart(8)}${diff}`);
|
|
124
|
+
}
|
|
125
|
+
if (unmatchedCount > 0) {
|
|
126
|
+
console.log(`UNMATCHED: ${formatNumber(unmatchedCount)} files (${formatNumber(unmatchedSize)}B) matched no displayed entry`);
|
|
127
|
+
}
|
|
128
|
+
console.log(`top ${summaries.length} entries cover ${shownPercent.toFixed(1)}% (specific paths ${specificPercent.toFixed(1)}%, combined leftovers ${(shownPercent - specificPercent).toFixed(1)}%)`);
|
|
129
|
+
// Entries are a strict partition (each file in exactly one entry), so these must match the real totals exactly.
|
|
130
|
+
let entryCount = 0;
|
|
131
|
+
let entrySize = 0;
|
|
132
|
+
for (let entry of summaries) {
|
|
133
|
+
entryCount += entry.summary.count;
|
|
134
|
+
entrySize += entry.summary.totalSize;
|
|
45
135
|
}
|
|
136
|
+
let actualSize = 0;
|
|
137
|
+
for (let file of files) {
|
|
138
|
+
actualSize += file.size;
|
|
139
|
+
}
|
|
140
|
+
console.log(`entry totals: files=${formatNumber(entryCount)} (actual ${formatNumber(files.length)}), size=${formatNumber(entrySize)}B (actual ${formatNumber(actualSize)}B)`);
|
|
46
141
|
}
|
|
47
142
|
|
|
48
|
-
async function
|
|
143
|
+
async function getFiles(): Promise<FileInfo[]> {
|
|
144
|
+
try {
|
|
145
|
+
let cached = JSON.parse(await fs.promises.readFile(CACHE_FILE, "utf8")) as { dirs: string[]; files: FileInfo[] };
|
|
146
|
+
if (JSON.stringify(cached.dirs) === JSON.stringify(SCAN_DIRS)) {
|
|
147
|
+
console.log(`loaded ${formatNumber(cached.files.length)} files from cache ${CACHE_FILE}`);
|
|
148
|
+
return cached.files;
|
|
149
|
+
}
|
|
150
|
+
console.log(`cache dirs changed (cached ${JSON.stringify(cached.dirs)}, now ${JSON.stringify(SCAN_DIRS)}), rescanning`);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if ((err as { code?: string }).code === "ENOENT") {
|
|
153
|
+
console.log(`no cache file at ${CACHE_FILE}, scanning`);
|
|
154
|
+
} else {
|
|
155
|
+
console.log(`cache read failed, rescanning:`, (err as Error).stack ?? err);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
49
158
|
let files: FileInfo[] = [];
|
|
50
|
-
|
|
51
|
-
|
|
159
|
+
let scanStart = performance.now();
|
|
160
|
+
await collectFiles(SCAN_DIRS, files);
|
|
161
|
+
await fs.promises.writeFile(CACHE_FILE, JSON.stringify({ dirs: SCAN_DIRS, files }));
|
|
162
|
+
console.log(`scanned ${formatNumber(files.length)} files in ${formatTime(performance.now() - scanStart)}, cached to ${CACHE_FILE}`);
|
|
163
|
+
return files;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function main() {
|
|
167
|
+
let files = await getFiles();
|
|
168
|
+
console.log(`collected ${formatNumber(files.length)} files from ${SCAN_DIRS.join(", ")}`);
|
|
52
169
|
summarize(files, true);
|
|
53
170
|
summarize(files, false);
|
|
54
171
|
}
|
package/treeSummary.ts
CHANGED
|
@@ -1,48 +1,96 @@
|
|
|
1
1
|
import { sort } from "socket-function/src/misc";
|
|
2
2
|
|
|
3
|
+
const DEFAULT_OUTPUT_COUNT = 100;
|
|
4
|
+
const TRACKED_NODES_PER_OUTPUT = 100;
|
|
5
|
+
const MIN_TRACKED_NODES = 1_000;
|
|
6
|
+
const MAX_TRACKED_NODES = 10_000;
|
|
7
|
+
const PRUNE_TARGET_DIVISOR = 2;
|
|
8
|
+
const PRUNE_SEARCH_ITERATIONS = 20;
|
|
9
|
+
|
|
3
10
|
type TreeNode<S> = {
|
|
11
|
+
// The path fragment this node covers, i.e. the edge from its parent. Children are keyed by the first character of their label.
|
|
12
|
+
label: string;
|
|
4
13
|
children: Map<string, TreeNode<S>>;
|
|
5
14
|
// Aggregate of every value whose path passes through (or ends at) this node.
|
|
6
15
|
summary: S;
|
|
7
16
|
// Aggregate of only the values whose path ends exactly at this node.
|
|
8
17
|
selfSummary?: S;
|
|
18
|
+
// Aggregate of values that were below this node before pruning collapsed their branches. Their path detail is gone, but their data is preserved.
|
|
19
|
+
truncatedSummary?: S;
|
|
9
20
|
};
|
|
10
21
|
|
|
11
|
-
type SummaryEntry<S> = {
|
|
22
|
+
export type SummaryEntry<S> = {
|
|
23
|
+
// Ends with "*" unless the entry is a single exact path that was heavy enough to stand on its own.
|
|
12
24
|
path: string;
|
|
25
|
+
// "self" = values whose path is exactly path. "subtree" = every value whose path starts with path. "group" = values under path kept combined (light sibling branches, chain leftovers, or both) because none was heavy enough to deserve its own entry. "truncated" = values under path whose detail was pruned away.
|
|
26
|
+
kind: "self" | "subtree" | "group" | "truncated";
|
|
13
27
|
summary: S;
|
|
14
28
|
weight: number;
|
|
15
29
|
};
|
|
16
30
|
|
|
17
|
-
type PendingEntry<S> =
|
|
18
|
-
|
|
19
|
-
|
|
31
|
+
type PendingEntry<S> = {
|
|
32
|
+
kind: "self" | "subtree" | "group" | "truncated";
|
|
33
|
+
path: string;
|
|
34
|
+
node?: TreeNode<S>;
|
|
35
|
+
// Where node actually sits when path compression descended a single-child chain past carried residues; equals path when no residues were carried.
|
|
36
|
+
nodePath?: string;
|
|
37
|
+
members?: Map<string, TreeNode<S>>;
|
|
38
|
+
// Self/truncated summaries that sit between path and node (for "subtree"), or the loose summaries of a combined entry (for "group"/"truncated").
|
|
39
|
+
extras?: S[];
|
|
40
|
+
weight: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type NodeStats = {
|
|
44
|
+
size: number;
|
|
45
|
+
weight: number;
|
|
20
46
|
};
|
|
21
47
|
|
|
22
48
|
export class TreeSummary<T, S> {
|
|
23
49
|
private root: TreeNode<S>;
|
|
50
|
+
private nodeCount = 1;
|
|
51
|
+
private maxTrackedNodes: number;
|
|
24
52
|
|
|
25
53
|
constructor(private config: {
|
|
26
54
|
getPath: (value: T) => string;
|
|
27
55
|
createSummary: () => S;
|
|
28
56
|
addToSummary: (value: T, summary: S) => void;
|
|
57
|
+
mergeSummaries: (target: S, source: S) => void;
|
|
29
58
|
getWeight: (summary: S) => number;
|
|
59
|
+
// Sizes the internal tree, which keeps roughly TRACKED_NODES_PER_OUTPUT times this many nodes so output granularity decisions have detail to work with.
|
|
60
|
+
expectedOutputCount?: number;
|
|
30
61
|
}) {
|
|
31
|
-
this.root = { children: new Map(), summary: config.createSummary() };
|
|
62
|
+
this.root = { label: "", children: new Map(), summary: config.createSummary() };
|
|
63
|
+
let outputCount = config.expectedOutputCount || DEFAULT_OUTPUT_COUNT;
|
|
64
|
+
this.maxTrackedNodes = Math.min(Math.max(outputCount * TRACKED_NODES_PER_OUTPUT, MIN_TRACKED_NODES), MAX_TRACKED_NODES);
|
|
32
65
|
}
|
|
33
66
|
|
|
34
67
|
public add(value: T) {
|
|
35
68
|
let path = this.config.getPath(value);
|
|
36
69
|
let node = this.root;
|
|
37
70
|
this.config.addToSummary(value, node.summary);
|
|
38
|
-
|
|
39
|
-
|
|
71
|
+
let pos = 0;
|
|
72
|
+
while (pos < path.length) {
|
|
73
|
+
let child = node.children.get(path[pos]);
|
|
40
74
|
if (!child) {
|
|
41
|
-
child = { children: new Map(), summary: this.config.createSummary() };
|
|
42
|
-
node.children.set(
|
|
75
|
+
child = { label: path.slice(pos), children: new Map(), summary: this.config.createSummary() };
|
|
76
|
+
node.children.set(path[pos], child);
|
|
77
|
+
this.nodeCount++;
|
|
78
|
+
this.config.addToSummary(value, child.summary);
|
|
79
|
+
node = child;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
let label = child.label;
|
|
83
|
+
let limit = Math.min(label.length, path.length - pos);
|
|
84
|
+
let common = 1;
|
|
85
|
+
while (common < limit && label.charCodeAt(common) === path.charCodeAt(pos + common)) {
|
|
86
|
+
common++;
|
|
87
|
+
}
|
|
88
|
+
if (common < label.length) {
|
|
89
|
+
this.splitNode(child, common);
|
|
43
90
|
}
|
|
44
91
|
this.config.addToSummary(value, child.summary);
|
|
45
92
|
node = child;
|
|
93
|
+
pos += common;
|
|
46
94
|
}
|
|
47
95
|
let selfSummary = node.selfSummary;
|
|
48
96
|
if (!selfSummary) {
|
|
@@ -50,78 +98,307 @@ export class TreeSummary<T, S> {
|
|
|
50
98
|
node.selfSummary = selfSummary;
|
|
51
99
|
}
|
|
52
100
|
this.config.addToSummary(value, selfSummary);
|
|
101
|
+
if (this.nodeCount > this.maxTrackedNodes) {
|
|
102
|
+
this.prune();
|
|
103
|
+
}
|
|
53
104
|
}
|
|
54
105
|
|
|
55
|
-
//
|
|
106
|
+
// Splits a node's edge label at labelPos, inserting a new parent covering the prefix. The prefix node's summary is a merge-clone of the original aggregate, since it covers the exact same set of values.
|
|
107
|
+
private splitNode(node: TreeNode<S>, labelPos: number) {
|
|
108
|
+
let suffix: TreeNode<S> = {
|
|
109
|
+
label: node.label.slice(labelPos),
|
|
110
|
+
children: node.children,
|
|
111
|
+
summary: node.summary,
|
|
112
|
+
selfSummary: node.selfSummary,
|
|
113
|
+
truncatedSummary: node.truncatedSummary,
|
|
114
|
+
};
|
|
115
|
+
let prefixSummary = this.config.createSummary();
|
|
116
|
+
this.config.mergeSummaries(prefixSummary, suffix.summary);
|
|
117
|
+
node.label = node.label.slice(0, labelPos);
|
|
118
|
+
node.summary = prefixSummary;
|
|
119
|
+
node.selfSummary = undefined;
|
|
120
|
+
node.truncatedSummary = undefined;
|
|
121
|
+
node.children = new Map([[suffix.label[0], suffix]]);
|
|
122
|
+
this.nodeCount++;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public getTrackedNodeCount(): number {
|
|
126
|
+
return this.nodeCount;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Starts fully collapsed (one entry for the whole tree) and repeatedly refines the heaviest entry, one split at a time. A split pulls the heaviest branch out as its own entry and leaves the remaining siblings combined in a group entry, so entries are never wasted on light branches. This recomputes from the live per-node summaries, so it always reflects everything added so far.
|
|
56
130
|
public getSummaries(maxCount: number): SummaryEntry<S>[] {
|
|
57
131
|
if (maxCount <= 0) {
|
|
58
132
|
throw new Error(`maxCount must be positive, was ${maxCount}`);
|
|
59
133
|
}
|
|
60
|
-
if (!this.root.selfSummary && this.root.children.size === 0) {
|
|
134
|
+
if (!this.root.selfSummary && !this.root.truncatedSummary && this.root.children.size === 0) {
|
|
61
135
|
return [];
|
|
62
136
|
}
|
|
63
|
-
let
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
let
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
let expandCount = entry.node.children.size + (entry.node.selfSummary && 1 || 0);
|
|
71
|
-
if (entries.length - 1 + expandCount > maxCount) {
|
|
72
|
-
entry.expandable = false;
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
if (!best || entry.weight > best.weight) {
|
|
76
|
-
best = entry;
|
|
137
|
+
let entries: PendingEntry<S>[] = [this.subtreeEntry(this.root, "")];
|
|
138
|
+
while (entries.length < maxCount) {
|
|
139
|
+
let bestIndex = -1;
|
|
140
|
+
for (let i = 0; i < entries.length; i++) {
|
|
141
|
+
if (!this.isRefinable(entries[i])) continue;
|
|
142
|
+
if (bestIndex === -1 || entries[i].weight > entries[bestIndex].weight) {
|
|
143
|
+
bestIndex = i;
|
|
77
144
|
}
|
|
78
145
|
}
|
|
79
|
-
if (
|
|
80
|
-
let
|
|
81
|
-
entries.splice(
|
|
146
|
+
if (bestIndex === -1) break;
|
|
147
|
+
let refined = this.refine(entries[bestIndex]);
|
|
148
|
+
entries.splice(bestIndex, 1, ...refined);
|
|
82
149
|
}
|
|
83
|
-
let output = entries.map(entry =>
|
|
150
|
+
let output = entries.map(entry => {
|
|
151
|
+
let path = entry.path;
|
|
152
|
+
if (entry.kind !== "self") {
|
|
153
|
+
path += "*";
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
path,
|
|
157
|
+
kind: entry.kind,
|
|
158
|
+
summary: this.buildOutputSummary(entry),
|
|
159
|
+
weight: entry.weight,
|
|
160
|
+
};
|
|
161
|
+
});
|
|
84
162
|
sort(output, entry => entry.path);
|
|
85
163
|
return output;
|
|
86
164
|
}
|
|
87
165
|
|
|
88
|
-
private
|
|
166
|
+
private isRefinable(entry: PendingEntry<S>): boolean {
|
|
167
|
+
if (entry.kind === "self" || entry.kind === "truncated") return false;
|
|
168
|
+
if (entry.kind === "group") {
|
|
169
|
+
let members = entry.members;
|
|
170
|
+
if (!members || members.size === 0) return false;
|
|
171
|
+
return members.size + (entry.extras && entry.extras.length || 0) >= 2;
|
|
172
|
+
}
|
|
173
|
+
let node = entry.node;
|
|
174
|
+
if (!node) return false;
|
|
175
|
+
if (entry.extras && entry.extras.length > 0) return true;
|
|
176
|
+
let parts = node.children.size + (node.selfSummary && 1 || 0) + (node.truncatedSummary && 1 || 0);
|
|
177
|
+
return parts >= 2;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Replaces one entry with exactly two, so each refinement costs exactly one output slot.
|
|
181
|
+
private refine(entry: PendingEntry<S>): PendingEntry<S>[] {
|
|
182
|
+
if (entry.kind === "group") {
|
|
183
|
+
let members = entry.members;
|
|
184
|
+
if (!members) {
|
|
185
|
+
throw new Error(`Group entry missing members at ${entry.path}`);
|
|
186
|
+
}
|
|
187
|
+
return this.splitParts(members, entry.extras || [], entry.path);
|
|
188
|
+
}
|
|
189
|
+
let node = entry.node;
|
|
190
|
+
if (entry.kind !== "subtree" || !node) {
|
|
191
|
+
throw new Error(`Entry is not refinable at ${entry.path}`);
|
|
192
|
+
}
|
|
193
|
+
let extras = entry.extras;
|
|
194
|
+
if (extras && extras.length > 0) {
|
|
195
|
+
// Split off the chain residues as one combined entry, so a long chain of pruning leftovers costs a single slot instead of one per character.
|
|
196
|
+
let weight = 0;
|
|
197
|
+
for (let extra of extras) {
|
|
198
|
+
weight += this.config.getWeight(extra);
|
|
199
|
+
}
|
|
200
|
+
return [
|
|
201
|
+
{ kind: "group", path: entry.path, extras, weight },
|
|
202
|
+
this.subtreeEntry(node, entry.nodePath || entry.path),
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
// Children live below the node, so their paths must extend nodePath, never the (possibly shorter) display path.
|
|
206
|
+
let nodePath = entry.nodePath || entry.path;
|
|
207
|
+
let truncatedList: S[] = [];
|
|
208
|
+
if (node.truncatedSummary) {
|
|
209
|
+
truncatedList.push(node.truncatedSummary);
|
|
210
|
+
}
|
|
211
|
+
let selfSummary = node.selfSummary;
|
|
212
|
+
if (selfSummary) {
|
|
213
|
+
return [
|
|
214
|
+
{ kind: "self", path: nodePath, node, weight: this.config.getWeight(selfSummary) },
|
|
215
|
+
this.entryForParts(node.children, truncatedList, nodePath),
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
return this.splitParts(node.children, truncatedList, nodePath);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private splitParts(children: Map<string, TreeNode<S>>, extras: S[], path: string): PendingEntry<S>[] {
|
|
222
|
+
let heaviestChar = "";
|
|
223
|
+
let heaviest: TreeNode<S> | undefined;
|
|
224
|
+
for (let [char, child] of children) {
|
|
225
|
+
if (!heaviest || this.config.getWeight(child.summary) > this.config.getWeight(heaviest.summary)) {
|
|
226
|
+
heaviestChar = char;
|
|
227
|
+
heaviest = child;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!heaviest) {
|
|
231
|
+
throw new Error(`Cannot split empty child set at ${path}`);
|
|
232
|
+
}
|
|
233
|
+
let rest = new Map(children);
|
|
234
|
+
rest.delete(heaviestChar);
|
|
235
|
+
return [
|
|
236
|
+
this.subtreeEntry(heaviest, path + heaviest.label),
|
|
237
|
+
this.entryForParts(rest, extras, path),
|
|
238
|
+
];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private entryForParts(children: Map<string, TreeNode<S>>, extras: S[], path: string): PendingEntry<S> {
|
|
242
|
+
if (children.size === 0) {
|
|
243
|
+
if (extras.length === 0) {
|
|
244
|
+
throw new Error(`No parts to make an entry from at ${path}`);
|
|
245
|
+
}
|
|
246
|
+
let weight = 0;
|
|
247
|
+
for (let extra of extras) {
|
|
248
|
+
weight += this.config.getWeight(extra);
|
|
249
|
+
}
|
|
250
|
+
return { kind: "truncated", path, extras, weight };
|
|
251
|
+
}
|
|
252
|
+
if (children.size === 1 && extras.length === 0) {
|
|
253
|
+
for (let child of children.values()) {
|
|
254
|
+
return this.subtreeEntry(child, path + child.label);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
let weight = 0;
|
|
258
|
+
for (let extra of extras) {
|
|
259
|
+
weight += this.config.getWeight(extra);
|
|
260
|
+
}
|
|
261
|
+
for (let child of children.values()) {
|
|
262
|
+
weight += this.config.getWeight(child.summary);
|
|
263
|
+
}
|
|
264
|
+
return { kind: "group", path, members: children, extras, weight };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private subtreeEntry(node: TreeNode<S>, path: string): PendingEntry<S> {
|
|
268
|
+
// Follow single-child chains so paths are maximal shared prefixes instead of single characters. Self/truncated summaries found along the chain are carried on the entry instead of stopping the descent, so pruning leftovers scattered along a chain end up combined instead of each forcing an entry.
|
|
269
|
+
let carried: S[] = [];
|
|
270
|
+
let carriedWeight = 0;
|
|
271
|
+
let nodePath = path;
|
|
272
|
+
while (node.children.size === 1) {
|
|
273
|
+
let selfSummary = node.selfSummary;
|
|
274
|
+
if (selfSummary) {
|
|
275
|
+
carried.push(selfSummary);
|
|
276
|
+
carriedWeight += this.config.getWeight(selfSummary);
|
|
277
|
+
}
|
|
278
|
+
let truncated = node.truncatedSummary;
|
|
279
|
+
if (truncated) {
|
|
280
|
+
carried.push(truncated);
|
|
281
|
+
carriedWeight += this.config.getWeight(truncated);
|
|
282
|
+
}
|
|
283
|
+
for (let child of node.children.values()) {
|
|
284
|
+
nodePath += child.label;
|
|
285
|
+
node = child;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// With nothing carried the entry is just the deep node, so display the full descended prefix.
|
|
289
|
+
if (carried.length === 0) {
|
|
290
|
+
path = nodePath;
|
|
291
|
+
}
|
|
89
292
|
return {
|
|
293
|
+
kind: "subtree",
|
|
90
294
|
path,
|
|
91
295
|
node,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
296
|
+
nodePath,
|
|
297
|
+
extras: carried,
|
|
298
|
+
weight: this.config.getWeight(node.summary) + carriedWeight,
|
|
95
299
|
};
|
|
96
300
|
}
|
|
97
301
|
|
|
98
|
-
private
|
|
99
|
-
let result
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
302
|
+
private buildOutputSummary(entry: PendingEntry<S>): S {
|
|
303
|
+
let result = this.config.createSummary();
|
|
304
|
+
if (entry.kind === "group" || entry.kind === "truncated") {
|
|
305
|
+
for (let extra of entry.extras || []) {
|
|
306
|
+
this.config.mergeSummaries(result, extra);
|
|
307
|
+
}
|
|
308
|
+
if (entry.members) {
|
|
309
|
+
for (let member of entry.members.values()) {
|
|
310
|
+
this.config.mergeSummaries(result, member.summary);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
let node = entry.node;
|
|
316
|
+
if (!node) {
|
|
317
|
+
throw new Error(`Entry missing node at ${entry.path}`);
|
|
318
|
+
}
|
|
319
|
+
if (entry.kind === "self") {
|
|
320
|
+
let selfSummary = node.selfSummary;
|
|
321
|
+
if (!selfSummary) {
|
|
322
|
+
throw new Error(`Self entry missing selfSummary at ${entry.path}`);
|
|
323
|
+
}
|
|
324
|
+
this.config.mergeSummaries(result, selfSummary);
|
|
325
|
+
return result;
|
|
109
326
|
}
|
|
110
|
-
for (let
|
|
111
|
-
|
|
112
|
-
result.push(this.makeEntry(compressed.node, compressed.path));
|
|
327
|
+
for (let extra of entry.extras || []) {
|
|
328
|
+
this.config.mergeSummaries(result, extra);
|
|
113
329
|
}
|
|
330
|
+
this.config.mergeSummaries(result, node.summary);
|
|
114
331
|
return result;
|
|
115
332
|
}
|
|
116
333
|
|
|
117
|
-
//
|
|
118
|
-
private
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
334
|
+
// Collapses the lightest subtrees until the node count is back under the target. Collapsing a node merges everything below it into its truncatedSummary, keeping the data but dropping the path detail. If those prefixes get heavy again later, new adds rebuild branches there and the next prune takes out whatever stayed light instead.
|
|
335
|
+
private prune() {
|
|
336
|
+
let target = Math.floor(this.maxTrackedNodes / PRUNE_TARGET_DIVISOR);
|
|
337
|
+
let need = this.nodeCount - target;
|
|
338
|
+
if (need <= 0) return;
|
|
339
|
+
let stats = new Map<TreeNode<S>, NodeStats>();
|
|
340
|
+
let measure = (node: TreeNode<S>): NodeStats => {
|
|
341
|
+
let size = 1;
|
|
342
|
+
for (let child of node.children.values()) {
|
|
343
|
+
size += measure(child).size;
|
|
344
|
+
}
|
|
345
|
+
let result = { size, weight: this.config.getWeight(node.summary) };
|
|
346
|
+
stats.set(node, result);
|
|
347
|
+
return result;
|
|
348
|
+
};
|
|
349
|
+
let rootStats = measure(this.root);
|
|
350
|
+
let getStats = (node: TreeNode<S>): NodeStats => {
|
|
351
|
+
let stat = stats.get(node);
|
|
352
|
+
if (!stat) {
|
|
353
|
+
throw new Error(`Missing node stats during prune`);
|
|
354
|
+
}
|
|
355
|
+
return stat;
|
|
356
|
+
};
|
|
357
|
+
// Collapsing every maximal subtree whose weight is under a limit frees a monotonically increasing number of nodes, so binary search for the smallest limit that frees enough.
|
|
358
|
+
let freedAt = (limit: number): number => {
|
|
359
|
+
let freed = 0;
|
|
360
|
+
let visit = (node: TreeNode<S>) => {
|
|
361
|
+
if (node.children.size === 0) return;
|
|
362
|
+
if (getStats(node).weight <= limit) {
|
|
363
|
+
freed += getStats(node).size - 1;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
for (let child of node.children.values()) {
|
|
367
|
+
visit(child);
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
visit(this.root);
|
|
371
|
+
return freed;
|
|
372
|
+
};
|
|
373
|
+
let low = 0;
|
|
374
|
+
let high = rootStats.weight;
|
|
375
|
+
for (let i = 0; i < PRUNE_SEARCH_ITERATIONS; i++) {
|
|
376
|
+
let mid = (low + high) / 2;
|
|
377
|
+
if (freedAt(mid) >= need) {
|
|
378
|
+
high = mid;
|
|
379
|
+
} else {
|
|
380
|
+
low = mid;
|
|
123
381
|
}
|
|
124
382
|
}
|
|
125
|
-
|
|
383
|
+
let collapseVisit = (node: TreeNode<S>) => {
|
|
384
|
+
if (node.children.size === 0) return;
|
|
385
|
+
if (getStats(node).weight <= high) {
|
|
386
|
+
let truncated = node.truncatedSummary;
|
|
387
|
+
if (!truncated) {
|
|
388
|
+
truncated = this.config.createSummary();
|
|
389
|
+
node.truncatedSummary = truncated;
|
|
390
|
+
}
|
|
391
|
+
for (let child of node.children.values()) {
|
|
392
|
+
this.config.mergeSummaries(truncated, child.summary);
|
|
393
|
+
}
|
|
394
|
+
this.nodeCount -= getStats(node).size - 1;
|
|
395
|
+
node.children.clear();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
for (let child of node.children.values()) {
|
|
399
|
+
collapseVisit(child);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
collapseVisit(this.root);
|
|
126
403
|
}
|
|
127
404
|
}
|