@variance-authority/core 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +454 -0
  2. package/README.md +13 -10
  3. package/dist/attribute/component-hash.d.ts +3 -2
  4. package/dist/attribute/component-hash.js +3 -2
  5. package/dist/attribute/component-hash.js.map +1 -1
  6. package/dist/attribute/composition.d.ts +16 -0
  7. package/dist/attribute/composition.js +4 -6
  8. package/dist/attribute/composition.js.map +1 -1
  9. package/dist/attribute/index.d.ts +3 -1
  10. package/dist/attribute/index.js +2 -1
  11. package/dist/attribute/index.js.map +1 -1
  12. package/dist/attribute/region.js +1 -5
  13. package/dist/attribute/region.js.map +1 -1
  14. package/dist/attribute/source.d.ts +10 -24
  15. package/dist/attribute/source.js +4 -38
  16. package/dist/attribute/source.js.map +1 -1
  17. package/dist/attribute/subject-pieces.d.ts +37 -0
  18. package/dist/attribute/subject-pieces.js +122 -0
  19. package/dist/attribute/subject-pieces.js.map +1 -0
  20. package/dist/format/capture.d.ts +25 -0
  21. package/dist/format/capture.js +6 -0
  22. package/dist/format/capture.js.map +1 -1
  23. package/dist/format/document.d.ts +2 -2
  24. package/dist/format/document.js +8 -2
  25. package/dist/format/document.js.map +1 -1
  26. package/dist/format/drive.d.ts +191 -0
  27. package/dist/format/drive.js +343 -0
  28. package/dist/format/drive.js.map +1 -0
  29. package/dist/format/environment.d.ts +20 -2
  30. package/dist/format/environment.js +7 -0
  31. package/dist/format/environment.js.map +1 -1
  32. package/dist/format/index.d.ts +6 -3
  33. package/dist/format/index.js +4 -2
  34. package/dist/format/index.js.map +1 -1
  35. package/dist/format/intervention.d.ts +79 -0
  36. package/dist/format/intervention.js.map +1 -1
  37. package/dist/format/origin.d.ts +32 -0
  38. package/dist/format/origin.js +54 -0
  39. package/dist/format/origin.js.map +1 -0
  40. package/dist/format/snapshot.d.ts +2 -0
  41. package/dist/format/snapshot.js.map +1 -1
  42. package/dist/format/stabilize.d.ts +28 -5
  43. package/dist/format/stabilize.js +48 -100
  44. package/dist/format/stabilize.js.map +1 -1
  45. package/dist/format/waits.d.ts +12 -0
  46. package/dist/format/waits.js +156 -0
  47. package/dist/format/waits.js.map +1 -0
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +2 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/nearest.d.ts +46 -0
  52. package/dist/nearest.js +102 -0
  53. package/dist/nearest.js.map +1 -0
  54. package/dist/relate/before.d.ts +19 -24
  55. package/dist/relate/before.js +35 -37
  56. package/dist/relate/before.js.map +1 -1
  57. package/dist/relate/beyond.d.ts +59 -0
  58. package/dist/relate/beyond.js +98 -0
  59. package/dist/relate/beyond.js.map +1 -0
  60. package/dist/relate/carried.d.ts +122 -0
  61. package/dist/relate/carried.js +279 -0
  62. package/dist/relate/carried.js.map +1 -0
  63. package/dist/relate/dominate.d.ts +19 -0
  64. package/dist/relate/dominate.js +77 -0
  65. package/dist/relate/dominate.js.map +1 -0
  66. package/dist/relate/index.d.ts +3 -0
  67. package/dist/relate/index.js +3 -0
  68. package/dist/relate/index.js.map +1 -1
  69. package/dist/relate/merkle.js +2 -11
  70. package/dist/relate/merkle.js.map +1 -1
  71. package/dist/relate/narrow.d.ts +2 -2
  72. package/dist/relate/narrow.js +26 -73
  73. package/dist/relate/narrow.js.map +1 -1
  74. package/dist/relate/reach.d.ts +1 -1
  75. package/dist/relate/reach.js +10 -53
  76. package/dist/relate/reach.js.map +1 -1
  77. package/dist/relate/records.d.ts +1 -1
  78. package/dist/relate/records.js.map +1 -1
  79. package/dist/rules/index.d.ts +3 -3
  80. package/dist/rules/index.js +3 -3
  81. package/dist/rules/index.js.map +1 -1
  82. package/dist/rules/normalize/cascade.d.ts +12 -0
  83. package/dist/rules/normalize/cascade.js +34 -16
  84. package/dist/rules/normalize/cascade.js.map +1 -1
  85. package/dist/rules/normalize/fixture.d.ts +1 -0
  86. package/dist/rules/normalize/fixture.js +1 -0
  87. package/dist/rules/normalize/fixture.js.map +1 -1
  88. package/dist/rules/normalize/index.d.ts +2 -2
  89. package/dist/rules/normalize/index.js +9 -2
  90. package/dist/rules/normalize/index.js.map +1 -1
  91. package/dist/rules/normalize/value.d.ts +13 -2
  92. package/dist/rules/normalize/value.js +42 -8
  93. package/dist/rules/normalize/value.js.map +1 -1
  94. package/dist/rules/normalize/wrapper.js +5 -0
  95. package/dist/rules/normalize/wrapper.js.map +1 -1
  96. package/dist/rules/ruleset.d.ts +24 -4
  97. package/dist/rules/ruleset.js +24 -4
  98. package/dist/rules/ruleset.js.map +1 -1
  99. package/dist/segment/index.d.ts +20 -4
  100. package/dist/segment/index.js +28 -7
  101. package/dist/segment/index.js.map +1 -1
  102. package/dist/shard/index.d.ts +11 -0
  103. package/dist/shard/index.js +11 -0
  104. package/dist/shard/index.js.map +1 -0
  105. package/dist/shard/placement.d.ts +117 -0
  106. package/dist/shard/placement.js +170 -0
  107. package/dist/shard/placement.js.map +1 -0
  108. package/package.json +8 -1
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Which shard runs which group, and how many shards a suite is worth.
3
+ *
4
+ * A group is placed whole: every story of one file, every width of one route,
5
+ * every case of one test file loads the same modules, and split across
6
+ * machines each machine pays for them. Three rules decide where it goes:
7
+ *
8
+ * - **With recorded times, the time decides.** Groups go longest first to the
9
+ * shard with the least time so far (LPT, within 4/3 of the best split). Ties
10
+ * are broken by key in code-unit order, so the placement is a function of
11
+ * the groups and their times and never of the order they were found in.
12
+ * - **A member nothing recorded is priced at the median of the recorded ones.**
13
+ * A new file is a file, and pricing it at zero would stack every new one on
14
+ * one shard.
15
+ * - **With nothing recorded, a checksum decides.** Each group goes to the shard
16
+ * whose `sha256(key, shard)` is highest, which depends on the key and the
17
+ * shard count alone, so adding a group moves no other.
18
+ *
19
+ * Every shard computes the whole placement and keeps its own part. Nothing is
20
+ * exchanged between shards, so two shards agree exactly when they were handed
21
+ * the same groups and the same times.
22
+ */
23
+ /** One group placed whole, and the milliseconds recorded for each of its members. */
24
+ export interface Placeable {
25
+ readonly key: string;
26
+ /** `undefined` for a member nothing recorded a time for. */
27
+ readonly costs: readonly (number | undefined)[];
28
+ }
29
+ export interface Placement {
30
+ /** The shard, from 1, of each group in the order they were handed in. */
31
+ readonly owner: readonly number[];
32
+ readonly by: 'checksum' | 'recorded cost';
33
+ /** Milliseconds each group is priced at; absent when placed by checksum. */
34
+ readonly cost?: readonly number[];
35
+ /** Milliseconds each shard was given, shard 1 first; absent when placed by checksum. */
36
+ readonly load?: readonly number[];
37
+ }
38
+ /**
39
+ * Each group's price: its members' recorded milliseconds, with the median of
40
+ * every recorded member standing in for one nothing recorded. Absent when no
41
+ * member of any group is recorded, because then there is no price to give.
42
+ */
43
+ export declare function priced(groups: readonly Placeable[]): number[] | undefined;
44
+ /** Place every group on one of `total` shards. */
45
+ export declare function place(groups: readonly Placeable[], total: number): Placement;
46
+ /**
47
+ * Group indices, longest first, ties by key in code-unit order: the order a
48
+ * shard's workers take its groups in, so one that finishes early takes the
49
+ * short ones at the end.
50
+ */
51
+ export declare function longestFirst(keys: readonly string[], cost: readonly number[]): number[];
52
+ /** Why a shard count is the one {@link shardCount} chose. */
53
+ export type ShardCountReason =
54
+ /** No group to run, so no shard to start. */
55
+ 'nothing to run'
56
+ /** The fewest shards whose setup and slowest share fit the budget. */
57
+ | 'within budget'
58
+ /** One more shard would save less waiting than the setup it spends. */
59
+ | 'setup'
60
+ /** The slowest group is the longest share already, and no shard finishes before it. */
61
+ | 'slowest group'
62
+ /**
63
+ * The budget is no longer than the setup, which every shard spends before its
64
+ * first group, so no count meets it; the count is the one that finishes soonest.
65
+ */
66
+ | 'setup over budget'
67
+ /** The most shards allowed. */
68
+ | 'max';
69
+ export interface ShardCountOptions {
70
+ /** Milliseconds a shard spends before its first test: start, checkout, install, build. */
71
+ readonly setup: number;
72
+ /** Milliseconds one shard may take, setup included. */
73
+ readonly budget?: number;
74
+ /** The most shards to start. */
75
+ readonly max?: number;
76
+ /**
77
+ * How many groups one shard runs at once, its runner's workers: a shard is
78
+ * done when its share, divided among them, is, and never before its longest
79
+ * group. One when absent, a shard that runs its groups in turn.
80
+ */
81
+ readonly workers?: number;
82
+ }
83
+ export interface ShardCount {
84
+ readonly shards: number;
85
+ /** Milliseconds of groups each shard runs, shard 1 first, as {@link place} would place them. */
86
+ readonly load: readonly number[];
87
+ /** Milliseconds until the last shard finishes: the setup and the longest share, divided among its workers. */
88
+ readonly wall: number;
89
+ readonly why: ShardCountReason;
90
+ /** What one more shard would finish in, when the setup it spends is why it was not added. */
91
+ readonly next?: {
92
+ readonly wall: number;
93
+ };
94
+ }
95
+ /**
96
+ * GitHub Actions starts at most 256 jobs from one matrix, the largest limit
97
+ * any CI this tool is run on documents, so a count past it could not be used.
98
+ */
99
+ export declare const MATRIX_LIMIT = 256;
100
+ /**
101
+ * How many shards to split groups priced at `costs` milliseconds across.
102
+ *
103
+ * Every shard pays `setup` before it runs a group, so the wait is the setup
104
+ * plus the longest share. With a budget, the count is the fewest shards whose
105
+ * wait fits it. Without one, each shard is charged one setup, and the count is
106
+ * the one whose wait and setups come to least, the fewer of two that tie: a
107
+ * shard is worth starting when it shortens the wait by more than it spends.
108
+ * Every count is weighed, not only the next, because whole groups split
109
+ * unevenly: a third shard can save nothing where a fourth saves a lot. Either
110
+ * way the count stops where the longest share is the slowest group, because
111
+ * past that no shard finishes sooner. A budget no longer than the setup is met
112
+ * by no count, and the setup, not the slowest group, is why.
113
+ */
114
+ export declare function shardCount(costs: readonly number[], options: ShardCountOptions): ShardCount;
115
+ /** Milliseconds as a person reads them in a shard's line: `340 ms`, `9.0 s`. */
116
+ export declare function durationText(milliseconds: number): string;
117
+ //# sourceMappingURL=placement.d.ts.map
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Which shard runs which group, and how many shards a suite is worth.
3
+ *
4
+ * A group is placed whole: every story of one file, every width of one route,
5
+ * every case of one test file loads the same modules, and split across
6
+ * machines each machine pays for them. Three rules decide where it goes:
7
+ *
8
+ * - **With recorded times, the time decides.** Groups go longest first to the
9
+ * shard with the least time so far (LPT, within 4/3 of the best split). Ties
10
+ * are broken by key in code-unit order, so the placement is a function of
11
+ * the groups and their times and never of the order they were found in.
12
+ * - **A member nothing recorded is priced at the median of the recorded ones.**
13
+ * A new file is a file, and pricing it at zero would stack every new one on
14
+ * one shard.
15
+ * - **With nothing recorded, a checksum decides.** Each group goes to the shard
16
+ * whose `sha256(key, shard)` is highest, which depends on the key and the
17
+ * shard count alone, so adding a group moves no other.
18
+ *
19
+ * Every shard computes the whole placement and keeps its own part. Nothing is
20
+ * exchanged between shards, so two shards agree exactly when they were handed
21
+ * the same groups and the same times.
22
+ */
23
+ import { sha256Hex } from '../format/sha256.js';
24
+ import { codeUnitOrder } from '../segment/index.js';
25
+ /**
26
+ * Each group's price: its members' recorded milliseconds, with the median of
27
+ * every recorded member standing in for one nothing recorded. Absent when no
28
+ * member of any group is recorded, because then there is no price to give.
29
+ */
30
+ export function priced(groups) {
31
+ const known = groups.flatMap((group) => group.costs.filter((cost) => cost !== undefined));
32
+ if (known.length === 0)
33
+ return undefined;
34
+ const median = known.sort((a, b) => a - b)[Math.floor(known.length / 2)];
35
+ return groups.map((group) => group.costs.reduce((sum, cost) => sum + (cost ?? median), 0));
36
+ }
37
+ /** Place every group on one of `total` shards. */
38
+ export function place(groups, total) {
39
+ const cost = priced(groups);
40
+ if (cost === undefined) {
41
+ return { owner: groups.map((group) => (total === 1 ? 1 : rendezvous(group.key, total))), by: 'checksum' };
42
+ }
43
+ const order = longestFirst(groups.map((group) => group.key), cost);
44
+ const { owner, load } = packed(order.map((index) => cost[index]), total);
45
+ const byGroup = Array.from({ length: groups.length }, () => 0);
46
+ order.forEach((index, at) => (byGroup[index] = owner[at]));
47
+ return { owner: byGroup, by: 'recorded cost', cost, load };
48
+ }
49
+ /**
50
+ * Group indices, longest first, ties by key in code-unit order: the order a
51
+ * shard's workers take its groups in, so one that finishes early takes the
52
+ * short ones at the end.
53
+ */
54
+ export function longestFirst(keys, cost) {
55
+ return keys.map((_, index) => index).sort((a, b) => cost[b] - cost[a] || codeUnitOrder(keys[a], keys[b]));
56
+ }
57
+ /**
58
+ * GitHub Actions starts at most 256 jobs from one matrix, the largest limit
59
+ * any CI this tool is run on documents, so a count past it could not be used.
60
+ */
61
+ export const MATRIX_LIMIT = 256;
62
+ /**
63
+ * How many shards to split groups priced at `costs` milliseconds across.
64
+ *
65
+ * Every shard pays `setup` before it runs a group, so the wait is the setup
66
+ * plus the longest share. With a budget, the count is the fewest shards whose
67
+ * wait fits it. Without one, each shard is charged one setup, and the count is
68
+ * the one whose wait and setups come to least, the fewer of two that tie: a
69
+ * shard is worth starting when it shortens the wait by more than it spends.
70
+ * Every count is weighed, not only the next, because whole groups split
71
+ * unevenly: a third shard can save nothing where a fourth saves a lot. Either
72
+ * way the count stops where the longest share is the slowest group, because
73
+ * past that no shard finishes sooner. A budget no longer than the setup is met
74
+ * by no count, and the setup, not the slowest group, is why.
75
+ */
76
+ export function shardCount(costs, options) {
77
+ if (costs.length === 0)
78
+ return { shards: 0, load: [], wall: 0, why: 'nothing to run' };
79
+ const sorted = [...costs].sort((a, b) => b - a);
80
+ const slowest = sorted[0];
81
+ const total = sorted.reduce((sum, cost) => sum + cost, 0);
82
+ const limit = Math.max(1, Math.min(options.max ?? MATRIX_LIMIT, sorted.length));
83
+ const workers = options.workers ?? 1;
84
+ const at = (shards) => {
85
+ const { load, first } = packed(sorted, shards);
86
+ const share = Math.max(...load.map((part, shard) => Math.max(part / workers, first[shard])));
87
+ return { shards, load, wall: options.setup + share };
88
+ };
89
+ const counted = (shards, why, next) => ({ ...shards, why, ...(next === undefined ? {} : { next: { wall: next.wall } }) });
90
+ const reached = (shards) => shards.wall - options.setup <= slowest;
91
+ if (options.budget !== undefined) {
92
+ // With no setup, the slowest group is what no count gets under, not the setup.
93
+ const unmet = options.setup > 0 && options.budget <= options.setup;
94
+ for (let here = at(1);; here = at(here.shards + 1)) {
95
+ if (here.wall <= options.budget)
96
+ return counted(here, 'within budget');
97
+ if (reached(here))
98
+ return counted(here, unmet ? 'setup over budget' : 'slowest group');
99
+ if (here.shards === limit)
100
+ return counted(here, unmet ? 'setup over budget' : 'max');
101
+ }
102
+ }
103
+ const spent = (shards) => shards.wall + options.setup * shards.shards;
104
+ let best = at(1);
105
+ for (let shards = 2; shards <= limit && !reached(best); shards++) {
106
+ // No split of `shards` waits less than the slowest group or an even share,
107
+ // and every count past this one spends more setup, so none can do better.
108
+ if (options.setup + Math.max(slowest, total / shards / workers) + options.setup * shards >= spent(best))
109
+ break;
110
+ const here = at(shards);
111
+ if (spent(here) < spent(best))
112
+ best = here;
113
+ }
114
+ if (reached(best))
115
+ return counted(best, 'slowest group');
116
+ if (best.shards === limit)
117
+ return counted(best, 'max');
118
+ return counted(best, 'setup', at(best.shards + 1));
119
+ }
120
+ /** Milliseconds as a person reads them in a shard's line: `340 ms`, `9.0 s`. */
121
+ export function durationText(milliseconds) {
122
+ return milliseconds < 1000 ? `${Math.round(milliseconds)} ms` : `${(milliseconds / 1000).toFixed(1)} s`;
123
+ }
124
+ /**
125
+ * LPT over costs already sorted longest first: each to the shard with the
126
+ * least so far, the lowest-numbered of equals. `first` is each shard's
127
+ * longest group, the first it was given.
128
+ */
129
+ function packed(sorted, total) {
130
+ const load = Array.from({ length: total }, () => 0);
131
+ const first = Array.from({ length: total }, () => 0);
132
+ // A binary heap of shard indices by (load, index), so a suite of tens of
133
+ // thousands of files is placed in n log k rather than n·k.
134
+ const heap = Array.from({ length: total }, (_, index) => index);
135
+ const before = (a, b) => load[a] < load[b] || (load[a] === load[b] && a < b);
136
+ const owner = sorted.map((cost) => {
137
+ const least = heap[0];
138
+ if (load[least] === 0)
139
+ first[least] = cost;
140
+ load[least] = load[least] + cost;
141
+ for (let at = 0;;) {
142
+ const left = 2 * at + 1;
143
+ const right = left + 1;
144
+ let next = at;
145
+ if (left < total && before(heap[left], heap[next]))
146
+ next = left;
147
+ if (right < total && before(heap[right], heap[next]))
148
+ next = right;
149
+ if (next === at)
150
+ break;
151
+ [heap[at], heap[next]] = [heap[next], heap[at]];
152
+ at = next;
153
+ }
154
+ return least + 1;
155
+ });
156
+ return { owner, load, first };
157
+ }
158
+ function rendezvous(key, total) {
159
+ let best = 1;
160
+ let bestScore = '';
161
+ for (let shard = 1; shard <= total; shard++) {
162
+ const score = sha256Hex(`${key}\0${shard}`);
163
+ if (score > bestScore) {
164
+ best = shard;
165
+ bestScore = score;
166
+ }
167
+ }
168
+ return best;
169
+ }
170
+ //# sourceMappingURL=placement.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"placement.js","sourceRoot":"","sources":["../../src/shard/placement.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Which shard runs which group, and how many shards a suite is worth.\n *\n * A group is placed whole: every story of one file, every width of one route,\n * every case of one test file loads the same modules, and split across\n * machines each machine pays for them. Three rules decide where it goes:\n *\n * - **With recorded times, the time decides.** Groups go longest first to the\n * shard with the least time so far (LPT, within 4/3 of the best split). Ties\n * are broken by key in code-unit order, so the placement is a function of\n * the groups and their times and never of the order they were found in.\n * - **A member nothing recorded is priced at the median of the recorded ones.**\n * A new file is a file, and pricing it at zero would stack every new one on\n * one shard.\n * - **With nothing recorded, a checksum decides.** Each group goes to the shard\n * whose `sha256(key, shard)` is highest, which depends on the key and the\n * shard count alone, so adding a group moves no other.\n *\n * Every shard computes the whole placement and keeps its own part. Nothing is\n * exchanged between shards, so two shards agree exactly when they were handed\n * the same groups and the same times.\n */\n\nimport { sha256Hex } from '../format/sha256.js';\nimport { codeUnitOrder } from '../segment/index.js';\n\n/** One group placed whole, and the milliseconds recorded for each of its members. */\nexport interface Placeable {\n readonly key: string;\n /** `undefined` for a member nothing recorded a time for. */\n readonly costs: readonly (number | undefined)[];\n}\n\nexport interface Placement {\n /** The shard, from 1, of each group in the order they were handed in. */\n readonly owner: readonly number[];\n readonly by: 'checksum' | 'recorded cost';\n /** Milliseconds each group is priced at; absent when placed by checksum. */\n readonly cost?: readonly number[];\n /** Milliseconds each shard was given, shard 1 first; absent when placed by checksum. */\n readonly load?: readonly number[];\n}\n\n/**\n * Each group's price: its members' recorded milliseconds, with the median of\n * every recorded member standing in for one nothing recorded. Absent when no\n * member of any group is recorded, because then there is no price to give.\n */\nexport function priced(groups: readonly Placeable[]): number[] | undefined {\n const known = groups.flatMap((group) => group.costs.filter((cost): cost is number => cost !== undefined));\n if (known.length === 0) return undefined;\n const median = known.sort((a, b) => a - b)[Math.floor(known.length / 2)]!;\n return groups.map((group) => group.costs.reduce<number>((sum, cost) => sum + (cost ?? median), 0));\n}\n\n/** Place every group on one of `total` shards. */\nexport function place(groups: readonly Placeable[], total: number): Placement {\n const cost = priced(groups);\n if (cost === undefined) {\n return { owner: groups.map((group) => (total === 1 ? 1 : rendezvous(group.key, total))), by: 'checksum' };\n }\n const order = longestFirst(groups.map((group) => group.key), cost);\n const { owner, load } = packed(order.map((index) => cost[index]!), total);\n const byGroup: number[] = Array.from({ length: groups.length }, () => 0);\n order.forEach((index, at) => (byGroup[index] = owner[at]!));\n return { owner: byGroup, by: 'recorded cost', cost, load };\n}\n\n/**\n * Group indices, longest first, ties by key in code-unit order: the order a\n * shard's workers take its groups in, so one that finishes early takes the\n * short ones at the end.\n */\nexport function longestFirst(keys: readonly string[], cost: readonly number[]): number[] {\n return keys.map((_, index) => index).sort((a, b) => cost[b]! - cost[a]! || codeUnitOrder(keys[a]!, keys[b]!));\n}\n\n/** Why a shard count is the one {@link shardCount} chose. */\nexport type ShardCountReason =\n /** No group to run, so no shard to start. */\n | 'nothing to run'\n /** The fewest shards whose setup and slowest share fit the budget. */\n | 'within budget'\n /** One more shard would save less waiting than the setup it spends. */\n | 'setup'\n /** The slowest group is the longest share already, and no shard finishes before it. */\n | 'slowest group'\n /**\n * The budget is no longer than the setup, which every shard spends before its\n * first group, so no count meets it; the count is the one that finishes soonest.\n */\n | 'setup over budget'\n /** The most shards allowed. */\n | 'max';\n\nexport interface ShardCountOptions {\n /** Milliseconds a shard spends before its first test: start, checkout, install, build. */\n readonly setup: number;\n /** Milliseconds one shard may take, setup included. */\n readonly budget?: number;\n /** The most shards to start. */\n readonly max?: number;\n /**\n * How many groups one shard runs at once, its runner's workers: a shard is\n * done when its share, divided among them, is, and never before its longest\n * group. One when absent, a shard that runs its groups in turn.\n */\n readonly workers?: number;\n}\n\nexport interface ShardCount {\n readonly shards: number;\n /** Milliseconds of groups each shard runs, shard 1 first, as {@link place} would place them. */\n readonly load: readonly number[];\n /** Milliseconds until the last shard finishes: the setup and the longest share, divided among its workers. */\n readonly wall: number;\n readonly why: ShardCountReason;\n /** What one more shard would finish in, when the setup it spends is why it was not added. */\n readonly next?: { readonly wall: number };\n}\n\n/**\n * GitHub Actions starts at most 256 jobs from one matrix, the largest limit\n * any CI this tool is run on documents, so a count past it could not be used.\n */\nexport const MATRIX_LIMIT = 256;\n\n/**\n * How many shards to split groups priced at `costs` milliseconds across.\n *\n * Every shard pays `setup` before it runs a group, so the wait is the setup\n * plus the longest share. With a budget, the count is the fewest shards whose\n * wait fits it. Without one, each shard is charged one setup, and the count is\n * the one whose wait and setups come to least, the fewer of two that tie: a\n * shard is worth starting when it shortens the wait by more than it spends.\n * Every count is weighed, not only the next, because whole groups split\n * unevenly: a third shard can save nothing where a fourth saves a lot. Either\n * way the count stops where the longest share is the slowest group, because\n * past that no shard finishes sooner. A budget no longer than the setup is met\n * by no count, and the setup, not the slowest group, is why.\n */\nexport function shardCount(costs: readonly number[], options: ShardCountOptions): ShardCount {\n if (costs.length === 0) return { shards: 0, load: [], wall: 0, why: 'nothing to run' };\n const sorted = [...costs].sort((a, b) => b - a);\n const slowest = sorted[0]!;\n const total = sorted.reduce((sum, cost) => sum + cost, 0);\n const limit = Math.max(1, Math.min(options.max ?? MATRIX_LIMIT, sorted.length));\n const workers = options.workers ?? 1;\n const at = (shards: number) => {\n const { load, first } = packed(sorted, shards);\n const share = Math.max(...load.map((part, shard) => Math.max(part / workers, first[shard]!)));\n return { shards, load, wall: options.setup + share };\n };\n const counted = (shards: ReturnType<typeof at>, why: ShardCountReason, next?: ReturnType<typeof at>): ShardCount =>\n ({ ...shards, why, ...(next === undefined ? {} : { next: { wall: next.wall } }) });\n const reached = (shards: ReturnType<typeof at>) => shards.wall - options.setup <= slowest;\n\n if (options.budget !== undefined) {\n // With no setup, the slowest group is what no count gets under, not the setup.\n const unmet = options.setup > 0 && options.budget <= options.setup;\n for (let here = at(1); ; here = at(here.shards + 1)) {\n if (here.wall <= options.budget) return counted(here, 'within budget');\n if (reached(here)) return counted(here, unmet ? 'setup over budget' : 'slowest group');\n if (here.shards === limit) return counted(here, unmet ? 'setup over budget' : 'max');\n }\n }\n\n const spent = (shards: ReturnType<typeof at>) => shards.wall + options.setup * shards.shards;\n let best = at(1);\n for (let shards = 2; shards <= limit && !reached(best); shards++) {\n // No split of `shards` waits less than the slowest group or an even share,\n // and every count past this one spends more setup, so none can do better.\n if (options.setup + Math.max(slowest, total / shards / workers) + options.setup * shards >= spent(best)) break;\n const here = at(shards);\n if (spent(here) < spent(best)) best = here;\n }\n if (reached(best)) return counted(best, 'slowest group');\n if (best.shards === limit) return counted(best, 'max');\n return counted(best, 'setup', at(best.shards + 1));\n}\n\n/** Milliseconds as a person reads them in a shard's line: `340 ms`, `9.0 s`. */\nexport function durationText(milliseconds: number): string {\n return milliseconds < 1000 ? `${Math.round(milliseconds)} ms` : `${(milliseconds / 1000).toFixed(1)} s`;\n}\n\n/**\n * LPT over costs already sorted longest first: each to the shard with the\n * least so far, the lowest-numbered of equals. `first` is each shard's\n * longest group, the first it was given.\n */\nfunction packed(sorted: readonly number[], total: number): { owner: number[]; load: number[]; first: number[] } {\n const load: number[] = Array.from({ length: total }, () => 0);\n const first: number[] = Array.from({ length: total }, () => 0);\n // A binary heap of shard indices by (load, index), so a suite of tens of\n // thousands of files is placed in n log k rather than n·k.\n const heap = Array.from({ length: total }, (_, index) => index);\n const before = (a: number, b: number) => load[a]! < load[b]! || (load[a] === load[b] && a < b);\n const owner = sorted.map((cost) => {\n const least = heap[0]!;\n if (load[least] === 0) first[least] = cost;\n load[least] = load[least]! + cost;\n for (let at = 0; ; ) {\n const left = 2 * at + 1;\n const right = left + 1;\n let next = at;\n if (left < total && before(heap[left]!, heap[next]!)) next = left;\n if (right < total && before(heap[right]!, heap[next]!)) next = right;\n if (next === at) break;\n [heap[at], heap[next]] = [heap[next]!, heap[at]!];\n at = next;\n }\n return least + 1;\n });\n return { owner, load, first };\n}\n\nfunction rendezvous(key: string, total: number): number {\n let best = 1;\n let bestScore = '';\n for (let shard = 1; shard <= total; shard++) {\n const score = sha256Hex(`${key}\\0${shard}`);\n if (score > bestScore) {\n best = shard;\n bestScore = score;\n }\n }\n return best;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@variance-authority/core",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "The Variance Authority format, rules, comparison, attribution and verdicts. Pure data in, pure data out, no DOM and no I/O.",
5
5
  "keywords": [
6
6
  "variance-authority",
@@ -62,6 +62,10 @@
62
62
  "./plan": {
63
63
  "types": "./dist/plan/index.d.ts",
64
64
  "default": "./dist/plan/index.js"
65
+ },
66
+ "./shard": {
67
+ "types": "./dist/shard/index.d.ts",
68
+ "default": "./dist/shard/index.js"
65
69
  }
66
70
  },
67
71
  "main": "./dist/index.js",
@@ -73,5 +77,8 @@
73
77
  ],
74
78
  "scripts": {
75
79
  "build": "tsc --build"
80
+ },
81
+ "devDependencies": {
82
+ "@variance-authority/sense": "^0.15.0"
76
83
  }
77
84
  }