deterministicGrouping.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. /*
  2. MIT License http://www.opensource.org/licenses/mit-license.php
  3. Author Tobias Koppers @sokra
  4. */
  5. "use strict";
  6. // Simulations show these probabilities for a single change
  7. // 93.1% that one group is invalidated
  8. // 4.8% that two groups are invalidated
  9. // 1.1% that 3 groups are invalidated
  10. // 0.1% that 4 or more groups are invalidated
  11. //
  12. // And these for removing/adding 10 lexically adjacent files
  13. // 64.5% that one group is invalidated
  14. // 24.8% that two groups are invalidated
  15. // 7.8% that 3 groups are invalidated
  16. // 2.7% that 4 or more groups are invalidated
  17. //
  18. // And these for removing/adding 3 random files
  19. // 0% that one group is invalidated
  20. // 3.7% that two groups are invalidated
  21. // 80.8% that 3 groups are invalidated
  22. // 12.3% that 4 groups are invalidated
  23. // 3.2% that 5 or more groups are invalidated
  24. /**
  25. * Returns the similarity as number.
  26. * @param {string} a key
  27. * @param {string} b key
  28. * @returns {number} the similarity as number
  29. */
  30. const similarity = (a, b) => {
  31. const l = Math.min(a.length, b.length);
  32. let dist = 0;
  33. for (let i = 0; i < l; i++) {
  34. const ca = a.charCodeAt(i);
  35. const cb = b.charCodeAt(i);
  36. dist += Math.max(0, 10 - Math.abs(ca - cb));
  37. }
  38. return dist;
  39. };
  40. /**
  41. * Returns the common part and a single char for the difference.
  42. * @param {string} a key
  43. * @param {string} b key
  44. * @param {Set<string>} usedNames set of already used names
  45. * @returns {string} the common part and a single char for the difference
  46. */
  47. const getName = (a, b, usedNames) => {
  48. const l = Math.min(a.length, b.length);
  49. let i = 0;
  50. while (i < l) {
  51. if (a.charCodeAt(i) !== b.charCodeAt(i)) {
  52. i++;
  53. break;
  54. }
  55. i++;
  56. }
  57. while (i < l) {
  58. const name = a.slice(0, i);
  59. const lowerName = name.toLowerCase();
  60. if (!usedNames.has(lowerName)) {
  61. usedNames.add(lowerName);
  62. return name;
  63. }
  64. i++;
  65. }
  66. // names always contain a hash, so this is always unique
  67. // we don't need to check usedNames nor add it
  68. return a;
  69. };
  70. /** @typedef {Record<string, number>} Sizes */
  71. /**
  72. * Adds the provided total to this object.
  73. * @param {Sizes} total total size
  74. * @param {Sizes} size single size
  75. * @returns {void}
  76. */
  77. const addSizeTo = (total, size) => {
  78. for (const key in size) {
  79. total[key] = (total[key] || 0) + size[key];
  80. }
  81. };
  82. /**
  83. * Subtract size from.
  84. * @param {Sizes} total total size
  85. * @param {Sizes} size single size
  86. * @returns {void}
  87. */
  88. const subtractSizeFrom = (total, size) => {
  89. for (const key in size) {
  90. total[key] -= size[key];
  91. }
  92. };
  93. /**
  94. * Returns total size.
  95. * @template T
  96. * @param {Node<T>[]} nodes some nodes
  97. * @param {number=} start start index
  98. * @returns {Sizes} total size
  99. */
  100. const sumSize = (nodes, start) => {
  101. /** @type {Sizes} */
  102. const sum = Object.create(null);
  103. for (let i = start || 0; i < nodes.length; i++) {
  104. addSizeTo(sum, nodes[i].size);
  105. }
  106. return sum;
  107. };
  108. /**
  109. * Checks whether this object is too big.
  110. * @param {Sizes} size size
  111. * @param {Sizes} maxSize minimum size
  112. * @returns {boolean} true, when size is too big
  113. */
  114. const isTooBig = (size, maxSize) => {
  115. for (const key in size) {
  116. const s = size[key];
  117. if (s === 0) continue;
  118. const maxSizeValue = maxSize[key];
  119. if (typeof maxSizeValue === "number" && s > maxSizeValue) return true;
  120. }
  121. return false;
  122. };
  123. /**
  124. * Checks whether this object is too small.
  125. * @param {Sizes} size size
  126. * @param {Sizes} minSize minimum size
  127. * @returns {boolean} true, when size is too small
  128. */
  129. const isTooSmall = (size, minSize) => {
  130. for (const key in size) {
  131. const s = size[key];
  132. if (s === 0) continue;
  133. const minSizeValue = minSize[key];
  134. if (typeof minSizeValue === "number" && s < minSizeValue) return true;
  135. }
  136. return false;
  137. };
  138. /** @typedef {Set<string>} Types */
  139. /**
  140. * Gets too small types.
  141. * @param {Sizes} size size
  142. * @param {Sizes} minSize minimum size
  143. * @returns {Types} set of types that are too small
  144. */
  145. const getTooSmallTypes = (size, minSize) => {
  146. /** @type {Types} */
  147. const types = new Set();
  148. for (const key in size) {
  149. const s = size[key];
  150. if (s === 0) continue;
  151. const minSizeValue = minSize[key];
  152. if (typeof minSizeValue === "number" && s < minSizeValue) types.add(key);
  153. }
  154. return types;
  155. };
  156. /**
  157. * Gets number of matching size types.
  158. * @param {Sizes} size size
  159. * @param {Types} types types
  160. * @returns {number} number of matching size types
  161. */
  162. const getNumberOfMatchingSizeTypes = (size, types) => {
  163. let i = 0;
  164. for (const key in size) {
  165. if (size[key] !== 0 && types.has(key)) i++;
  166. }
  167. return i;
  168. };
  169. /**
  170. * Selective size sum.
  171. * @param {Sizes} size size
  172. * @param {Types} types types
  173. * @returns {number} selective size sum
  174. */
  175. const selectiveSizeSum = (size, types) => {
  176. let sum = 0;
  177. for (const key in size) {
  178. if (size[key] !== 0 && types.has(key)) sum += size[key];
  179. }
  180. return sum;
  181. };
  182. /**
  183. * Represents the node runtime component.
  184. * @template T
  185. */
  186. class Node {
  187. /**
  188. * Creates an instance of Node.
  189. * @param {T} item item
  190. * @param {string} key key
  191. * @param {Sizes} size size
  192. */
  193. constructor(item, key, size) {
  194. /** @type {T} */
  195. this.item = item;
  196. /** @type {string} */
  197. this.key = key;
  198. /** @type {Sizes} */
  199. this.size = size;
  200. }
  201. }
  202. /** @typedef {number[]} Similarities */
  203. /**
  204. * Represents the group runtime component.
  205. * @template T
  206. */
  207. class Group {
  208. /**
  209. * Creates an instance of Group.
  210. * @param {Node<T>[]} nodes nodes
  211. * @param {Similarities | null} similarities similarities between the nodes (length = nodes.length - 1)
  212. * @param {Sizes=} size size of the group
  213. */
  214. constructor(nodes, similarities, size) {
  215. /** @type {Node<T>[]} */
  216. this.nodes = nodes;
  217. /** @type {Similarities | null} */
  218. this.similarities = similarities;
  219. /** @type {Sizes} */
  220. this.size = size || sumSize(nodes);
  221. /** @type {string | undefined} */
  222. this.key = undefined;
  223. }
  224. /**
  225. * Returns removed nodes.
  226. * @param {(node: Node<T>) => boolean} filter filter function
  227. * @returns {Node<T>[] | undefined} removed nodes
  228. */
  229. popNodes(filter) {
  230. /** @type {Node<T>[]} */
  231. const newNodes = [];
  232. /** @type {Similarities} */
  233. const newSimilarities = [];
  234. /** @type {Node<T>[]} */
  235. const resultNodes = [];
  236. /** @type {undefined | Node<T>} */
  237. let lastNode;
  238. for (let i = 0; i < this.nodes.length; i++) {
  239. const node = this.nodes[i];
  240. if (filter(node)) {
  241. resultNodes.push(node);
  242. } else {
  243. if (newNodes.length > 0) {
  244. newSimilarities.push(
  245. lastNode === this.nodes[i - 1]
  246. ? /** @type {Similarities} */ (this.similarities)[i - 1]
  247. : similarity(/** @type {Node<T>} */ (lastNode).key, node.key)
  248. );
  249. }
  250. newNodes.push(node);
  251. lastNode = node;
  252. }
  253. }
  254. if (resultNodes.length === this.nodes.length) return;
  255. this.nodes = newNodes;
  256. this.similarities = newSimilarities;
  257. this.size = sumSize(newNodes);
  258. return resultNodes;
  259. }
  260. }
  261. /**
  262. * Returns similarities.
  263. * @template T
  264. * @param {Iterable<Node<T>>} nodes nodes
  265. * @returns {Similarities} similarities
  266. */
  267. const getSimilarities = (nodes) => {
  268. // calculate similarities between lexically adjacent nodes
  269. /** @type {Similarities} */
  270. const similarities = [];
  271. /** @type {undefined | Node<T>} */
  272. let last;
  273. for (const node of nodes) {
  274. if (last !== undefined) {
  275. similarities.push(similarity(last.key, node.key));
  276. }
  277. last = node;
  278. }
  279. return similarities;
  280. };
  281. /**
  282. * Defines the shared type used by this module.
  283. * @template T
  284. * @typedef {object} GroupedItems<T>
  285. * @property {string} key
  286. * @property {T[]} items
  287. * @property {Sizes} size
  288. */
  289. /**
  290. * Defines the options type used by this module.
  291. * @template T
  292. * @typedef {object} Options
  293. * @property {Sizes} maxSize maximum size of a group
  294. * @property {Sizes} minSize minimum size of a group (preferred over maximum size)
  295. * @property {Iterable<T>} items a list of items
  296. * @property {(item: T) => Sizes} getSize function to get size of an item
  297. * @property {(item: T) => string} getKey function to get the key of an item
  298. */
  299. /**
  300. * Returns grouped items.
  301. * @template T
  302. * @param {Options<T>} options options object
  303. * @returns {GroupedItems<T>[]} grouped items
  304. */
  305. module.exports = ({ maxSize, minSize, items, getSize, getKey }) => {
  306. /** @type {Group<T>[]} */
  307. const result = [];
  308. const nodes = Array.from(
  309. items,
  310. (item) => new Node(item, getKey(item), getSize(item))
  311. );
  312. /** @type {Node<T>[]} */
  313. const initialNodes = [];
  314. // lexically ordering of keys
  315. nodes.sort((a, b) => {
  316. if (a.key < b.key) return -1;
  317. if (a.key > b.key) return 1;
  318. return 0;
  319. });
  320. // return nodes bigger than maxSize directly as group
  321. // But make sure that minSize is not violated
  322. for (const node of nodes) {
  323. if (isTooBig(node.size, maxSize) && !isTooSmall(node.size, minSize)) {
  324. result.push(new Group([node], []));
  325. } else {
  326. initialNodes.push(node);
  327. }
  328. }
  329. if (initialNodes.length > 0) {
  330. const initialGroup = new Group(initialNodes, getSimilarities(initialNodes));
  331. /**
  332. * Removes problematic nodes.
  333. * @param {Group<T>} group group
  334. * @param {Sizes} consideredSize size of the group to consider
  335. * @returns {boolean} true, if the group was modified
  336. */
  337. const removeProblematicNodes = (group, consideredSize = group.size) => {
  338. const problemTypes = getTooSmallTypes(consideredSize, minSize);
  339. if (problemTypes.size > 0) {
  340. // We hit an edge case where the working set is already smaller than minSize
  341. // We merge problematic nodes with the smallest result node to keep minSize intact
  342. const problemNodes = group.popNodes(
  343. (n) => getNumberOfMatchingSizeTypes(n.size, problemTypes) > 0
  344. );
  345. if (problemNodes === undefined) return false;
  346. // Only merge it with result nodes that have the problematic size type
  347. const possibleResultGroups = result.filter(
  348. (n) => getNumberOfMatchingSizeTypes(n.size, problemTypes) > 0
  349. );
  350. if (possibleResultGroups.length > 0) {
  351. const bestGroup = possibleResultGroups.reduce((min, group) => {
  352. const minMatches = getNumberOfMatchingSizeTypes(
  353. min.size,
  354. problemTypes
  355. );
  356. const groupMatches = getNumberOfMatchingSizeTypes(
  357. group.size,
  358. problemTypes
  359. );
  360. if (minMatches !== groupMatches) {
  361. return minMatches < groupMatches ? group : min;
  362. }
  363. if (
  364. selectiveSizeSum(min.size, problemTypes) >
  365. selectiveSizeSum(group.size, problemTypes)
  366. ) {
  367. return group;
  368. }
  369. return min;
  370. });
  371. for (const node of problemNodes) bestGroup.nodes.push(node);
  372. bestGroup.nodes.sort((a, b) => {
  373. if (a.key < b.key) return -1;
  374. if (a.key > b.key) return 1;
  375. return 0;
  376. });
  377. } else {
  378. // There are no other nodes with the same size types
  379. // We create a new group and have to accept that it's smaller than minSize
  380. result.push(new Group(problemNodes, null));
  381. }
  382. return true;
  383. }
  384. return false;
  385. };
  386. if (initialGroup.nodes.length > 0) {
  387. const queue = [initialGroup];
  388. while (queue.length) {
  389. const group = /** @type {Group<T>} */ (queue.pop());
  390. // only groups bigger than maxSize need to be splitted
  391. if (!isTooBig(group.size, maxSize)) {
  392. result.push(group);
  393. continue;
  394. }
  395. // If the group is already too small
  396. // we try to work only with the unproblematic nodes
  397. if (removeProblematicNodes(group)) {
  398. // This changed something, so we try this group again
  399. queue.push(group);
  400. continue;
  401. }
  402. // find unsplittable area from left and right
  403. // going minSize from left and right
  404. // at least one node need to be included otherwise we get stuck
  405. let left = 1;
  406. /** @type {Sizes} */
  407. const leftSize = Object.create(null);
  408. addSizeTo(leftSize, group.nodes[0].size);
  409. while (left < group.nodes.length && isTooSmall(leftSize, minSize)) {
  410. addSizeTo(leftSize, group.nodes[left].size);
  411. left++;
  412. }
  413. let right = group.nodes.length - 2;
  414. /** @type {Sizes} */
  415. const rightSize = Object.create(null);
  416. addSizeTo(rightSize, group.nodes[group.nodes.length - 1].size);
  417. while (right >= 0 && isTooSmall(rightSize, minSize)) {
  418. addSizeTo(rightSize, group.nodes[right].size);
  419. right--;
  420. }
  421. // left v v right
  422. // [ O O O ] O O O [ O O O ]
  423. // ^^^^^^^^^ leftSize
  424. // rightSize ^^^^^^^^^
  425. // leftSize > minSize
  426. // rightSize > minSize
  427. // Perfect split: [ O O O ] [ O O O ]
  428. // right === left - 1
  429. if (left - 1 > right) {
  430. // We try to remove some problematic nodes to "fix" that
  431. /** @type {Sizes} */
  432. let prevSize;
  433. if (right < group.nodes.length - left) {
  434. subtractSizeFrom(rightSize, group.nodes[right + 1].size);
  435. prevSize = rightSize;
  436. } else {
  437. subtractSizeFrom(leftSize, group.nodes[left - 1].size);
  438. prevSize = leftSize;
  439. }
  440. if (removeProblematicNodes(group, prevSize)) {
  441. // This changed something, so we try this group again
  442. queue.push(group);
  443. continue;
  444. }
  445. // can't split group while holding minSize
  446. // because minSize is preferred of maxSize we return
  447. // the problematic nodes as result here even while it's too big
  448. // To avoid this make sure maxSize > minSize * 3
  449. result.push(group);
  450. continue;
  451. }
  452. if (left <= right) {
  453. // when there is a area between left and right
  454. // we look for best split point
  455. // we split at the minimum similarity
  456. // here key space is separated the most
  457. // But we also need to make sure to not create too small groups
  458. let best = -1;
  459. let bestSimilarity = Infinity;
  460. let pos = left;
  461. const rightSize = sumSize(group.nodes, pos);
  462. // pos v v right
  463. // [ O O O ] O O O [ O O O ]
  464. // ^^^^^^^^^ leftSize
  465. // rightSize ^^^^^^^^^^^^^^^
  466. while (pos <= right + 1) {
  467. const similarity =
  468. /** @type {Similarities} */
  469. (group.similarities)[pos - 1];
  470. if (
  471. similarity < bestSimilarity &&
  472. !isTooSmall(leftSize, minSize) &&
  473. !isTooSmall(rightSize, minSize)
  474. ) {
  475. best = pos;
  476. bestSimilarity = similarity;
  477. }
  478. addSizeTo(leftSize, group.nodes[pos].size);
  479. subtractSizeFrom(rightSize, group.nodes[pos].size);
  480. pos++;
  481. }
  482. if (best < 0) {
  483. // This can't happen
  484. // but if that assumption is wrong
  485. // fallback to a big group
  486. result.push(group);
  487. continue;
  488. }
  489. left = best;
  490. right = best - 1;
  491. }
  492. // create two new groups for left and right area
  493. // and queue them up
  494. /** @type {Node<T>[]} */
  495. const rightNodes = [group.nodes[right + 1]];
  496. /** @type {Similarities} */
  497. const rightSimilarities = [];
  498. for (let i = right + 2; i < group.nodes.length; i++) {
  499. rightSimilarities.push(
  500. /** @type {Similarities} */ (group.similarities)[i - 1]
  501. );
  502. rightNodes.push(group.nodes[i]);
  503. }
  504. queue.push(new Group(rightNodes, rightSimilarities));
  505. /** @type {Node<T>[]} */
  506. const leftNodes = [group.nodes[0]];
  507. /** @type {Similarities} */
  508. const leftSimilarities = [];
  509. for (let i = 1; i < left; i++) {
  510. leftSimilarities.push(
  511. /** @type {Similarities} */ (group.similarities)[i - 1]
  512. );
  513. leftNodes.push(group.nodes[i]);
  514. }
  515. queue.push(new Group(leftNodes, leftSimilarities));
  516. }
  517. }
  518. }
  519. // lexically ordering
  520. result.sort((a, b) => {
  521. if (a.nodes[0].key < b.nodes[0].key) return -1;
  522. if (a.nodes[0].key > b.nodes[0].key) return 1;
  523. return 0;
  524. });
  525. // give every group a name
  526. /** @type {Set<string>} */
  527. const usedNames = new Set();
  528. for (let i = 0; i < result.length; i++) {
  529. const group = result[i];
  530. if (group.nodes.length === 1) {
  531. group.key = group.nodes[0].key;
  532. } else {
  533. const first = group.nodes[0];
  534. const last = group.nodes[group.nodes.length - 1];
  535. const name = getName(first.key, last.key, usedNames);
  536. group.key = name;
  537. }
  538. }
  539. // return the results
  540. return result.map(
  541. (group) =>
  542. /** @type {GroupedItems<T>} */
  543. ({
  544. key: group.key,
  545. items: group.nodes.map((node) => node.item),
  546. size: group.size
  547. })
  548. );
  549. };