diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 532654fde..4a4e7427a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,6 +187,12 @@ jobs: scripts/check-external-schedule-artifacts.sh cargo test --release -p akita-config --test trusted_schedule_artifact - name: Regenerate schedule artifacts in temporary storage + # Planning measured about 18 minutes on CI with two workers, and runner + # variance alone has moved this step by about 1.3x. The bound stops a + # runaway search. Growth in search size (states, candidates, + # descriptors) fails the --planner-cost-baseline check instead; slower + # per-candidate pricing is caught only by this timeout. + timeout-minutes: 35 env: AKITA_SCHEDULE_GEN_JOBS: 2 run: | @@ -196,7 +202,8 @@ jobs: --bin gen_schedule_artifacts -- "$artifact_dir" --row-progress \ --catalog-baseline artifacts/schedule-catalog.tsv \ --require-catalog-baseline-match \ - --catalog-report "$RUNNER_TEMP/akita-schedule-baseline-comparison.tsv" + --catalog-report "$RUNNER_TEMP/akita-schedule-baseline-comparison.tsv" \ + --planner-cost-baseline artifacts/schedule-planner-cost.tsv diff -ru artifacts/schedules "$artifact_dir" - name: Direct modeled SIS cost for every schedule run: | diff --git a/artifacts/schedule-planner-cost.tsv b/artifacts/schedule-planner-cost.tsv new file mode 100644 index 000000000..3c154e953 --- /dev/null +++ b/artifacts/schedule-planner-cost.tsv @@ -0,0 +1,116 @@ +family logical_key lookup_key_digest suffix_calls completed_states generated_candidates descriptor_builds +fp128_dense final=30:1;precommitted= 0661c6ea7796b195873f4f0afc34e1f8ab8b773648907a24b0f5110247bb10d2 2247440 99989 2995371 1449197 +fp128_dense final=16:2;precommitted= 1d3119b577401489425a46a52a8368829ffee0287db544156fcb790c4c1190e1 657605 34247 913895 589085 +fp128_dense final=14:1;precommitted= 235fdc37cb4e83e243a17cafde8597967ef2316443a2656dee73a57dc55cac8e 247793 17002 371830 289633 +fp128_dense final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 2388372 108397 3176185 1490169 +fp128_dense final=16:1;precommitted=14:1 5e8cc820e837ba71e8dcecb767b2a2f326973198d8ffadfa57c546eaf2ebde57 819818 34149 1118513 595851 +fp128_dense final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 408255 25032 595250 436283 +fp128_dense final=24:1;precommitted= 94e7a14c6d0dc602c7ebc7e2d1dd26de309a4ccf1ba006bd7c284ecae724ca0d 1183584 58190 1619798 965784 +fp128_dense final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 718185 42933 1032342 657818 +fp128_dense final=17:4;precommitted= b996d72cefb7a0a3262ac30ce0fd4e4200b27eff277fd1922a3c219011e6383f 765827 40147 1063914 686046 +fp128_dense final=15:2;precommitted= c087e777d755574ba60cc3ed9d2768052d7510780d99c0fe235c0a00f0202e57 391927 24311 571368 423140 +fp128_dense final=26:1;precommitted= c93e949f727a2db762f7dbfe2fcdcdc29cf0a3b5fd10f76b3019de436810bc9b 1062504 55206 1474557 882136 +fp128_dense_bounded final=14:1;precommitted= 235fdc37cb4e83e243a17cafde8597967ef2316443a2656dee73a57dc55cac8e 198589 14077 301699 230767 +fp128_dense_bounded final=24:1;precommitted= 94e7a14c6d0dc602c7ebc7e2d1dd26de309a4ccf1ba006bd7c284ecae724ca0d 876199 46599 1221908 783691 +fp128_dense_bounded final=26:1;precommitted= c93e949f727a2db762f7dbfe2fcdcdc29cf0a3b5fd10f76b3019de436810bc9b 1572033 70071 2105995 1143987 +fp128_dense_multi_chunk final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 680239 36557 942400 693295 +fp128_dense_multi_chunk_w8r3 final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 469631 38977 770963 470308 +fp128_dense_recursive final=22:1;precommitted= 43c57a9c37004f9565033f2c8ae56fdc6891e2e8ee936abeb6267fa9a84e3696 79693 10043 654905 450703 +fp128_dense_recursive final=20:1;precommitted= 7e615f063166da9e18d6e9eacc305f7128e85099f64b8b48278e935e02e876fe 2220 1648 128062 21249 +fp128_dense_recursive final=24:1;precommitted= 94e7a14c6d0dc602c7ebc7e2d1dd26de309a4ccf1ba006bd7c284ecae724ca0d 130260 14194 894194 630959 +fp128_dense_recursive final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 174474 18989 1099269 773172 +fp128_dense_recursive final=26:1;precommitted= c93e949f727a2db762f7dbfe2fcdcdc29cf0a3b5fd10f76b3019de436810bc9b 190819 19897 1240596 795981 +fp128_onehot final=20:2;precommitted= 024daea0560fa394f9aaded7cdd706e307cff8e2828bed1c0d981aefbbbf6ef4 241974 17425 362573 296896 +fp128_onehot final=20:2;precommitted=14:1 04e3f11e898185d2fb813267b7354610ca97fb872364415877d77e1692abfdd4 248641 17713 371467 302515 +fp128_onehot final=30:1;precommitted= 0661c6ea7796b195873f4f0afc34e1f8ab8b773648907a24b0f5110247bb10d2 231868 19295 365793 303197 +fp128_onehot final=18:1;precommitted= 0a64fd27f49046793d7d00ee8c2bda3757f24e60e86752300778d44cd119e709 54394 6236 91392 91242 +fp128_onehot final=20:4;precommitted= 1af5783e63010bd55e2ba781c36f9bea6db9cbe9fa4389d166ef22c347deb309 171237 14257 269429 234151 +fp128_onehot final=16:2;precommitted= 1d3119b577401489425a46a52a8368829ffee0287db544156fcb790c4c1190e1 14380 2352 26399 30027 +fp128_onehot final=14:1;precommitted= 235fdc37cb4e83e243a17cafde8597967ef2316443a2656dee73a57dc55cac8e 546 175 1803 1323 +fp128_onehot final=50:1;precommitted= 270f16c2966b092473ac7c4025e71c8e4bb127f40148ad4d279309ce24502a77 550833 37516 815218 518057 +fp128_onehot final=20:4;precommitted=14:1,14:1,14:1 2b4186a8d8e57afc1f67bb27df7600e0f6551e07dc5c359c1cc3f7f61319405a 172569 14372 271160 234265 +fp128_onehot final=34:2;precommitted=16:1,16:1 30424e0ca54d42b83af9180851eb3c3bf76481bd1b3487fbbad1c7bcaaecb7c9 556650 32466 790728 488722 +fp128_onehot final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 255704 20568 397150 316347 +fp128_onehot final=16:1;precommitted=14:1 4a803519410706a55e7c34508587b356c40d472b50b203f3d9f7936a26005f47 34249 4279 58019 59969 +fp128_onehot final=44:1;precommitted= 59108197889e16b1363957b8a7c59570842f3b60502857dee9a4456586d7081e 411290 28248 610327 402711 +fp128_onehot final=20:1;precommitted=14:2 6130d294b9a75f2417eefbed23ea8deae124c1394f9e773c3f1dd956a2a9f901 135841 12291 219655 199365 +fp128_onehot final=30:4;precommitted= 619a78159ae3df6e54d9411603c43aba002307069fda024793c83803f33af61c 255704 20568 397090 316347 +fp128_onehot final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 6573 1239 12835 14245 +fp128_onehot final=15:4;precommitted= 68fb5e2e5571f333ccc18953a6f62b93350179816ef43b814506341ba44a8ef1 31587 4054 53987 56901 +fp128_onehot final=20:1;precommitted=14:1 69a43114e91993b4fff4b1e8f3ac3dd5684c65225ba7032276e0fefca119ae3a 131516 11739 210723 189673 +fp128_onehot final=32:2;precommitted=16:1,16:1 7af0b4d281bc91485cb708c45d09dbeff67604e5e932e20cdb84ee742250c355 365292 23737 534353 365659 +fp128_onehot final=12:1;precommitted= 7cea46be72a904b2bacfbccb7a070499ba20889044145649c3db3f45a73dc084 413 152 1446 1050 +fp128_onehot final=20:1;precommitted= 7e615f063166da9e18d6e9eacc305f7128e85099f64b8b48278e935e02e876fe 128319 11620 206659 186573 +fp128_onehot final=14:2;precommitted= 8a40a8595c14901ece876c6c020dbcdba59bed81270cf9431da74003d3a138aa 4490 899 8957 10084 +fp128_onehot final=32:4;precommitted= a2afe07e7869624cdd9ab4baf165bb0251f452bca51d8a64041fbe6d462a9864 422750 27864 618815 424599 +fp128_onehot final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 276902 20951 421631 337445 +fp128_onehot final=16:1;precommitted=14:1,15:2 a7edb74ec411b6db7215811c008a5e33f5c7d27b18a1564efe1eb407abac02f5 143813 12366 230287 199468 +fp128_onehot final=40:1;precommitted= b5096196e31d7c94b6e34c5a093fd4bb202586423c3e6d14f78920e3d5d954c3 414591 28932 613048 420697 +fp128_onehot final=36:1;precommitted= b9f19e5b22668a057b75ac8efda23eb70064baa0dde95e015463454c195a7713 410504 27581 602141 407473 +fp128_onehot final=16:1;precommitted=14:1 d64b0853a3c757d5b955880f6d01429c70590908506fb6eb13a7b59925b1e908 134006 11277 211779 180393 +fp128_onehot final=20:4;precommitted=14:1,14:1 e49edca76b971eac0a8dcf6f18334c902e0c9046fedc6b5b451165236b547d0d 174784 14438 274188 236609 +fp128_onehot final=15:1;precommitted= fe784ff5321ce50dc781655bbe7fc9a71f08d9527d5445702d0e8e2a2d41fc87 1363 350 3377 3176 +fp128_onehot_multi_chunk final=34:2;precommitted=16:1,16:1 36fb48fda0447fa6a9d3cc840e8c5103bca72812581d6d756707adc652196fec 733388 38403 1021044 659284 +fp128_onehot_multi_chunk final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 251764 20672 391278 346515 +fp128_onehot_multi_chunk final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 6062 1413 18105 12387 +fp128_onehot_multi_chunk final=32:2;precommitted=16:1,16:1 d9a96a56f457fc61c78e1fdae89d76daea9da85f7ee340dd1742a7f4925d9b8d 508138 30385 721253 499821 +fp128_onehot_multi_chunk_w2r2 final=14:1;precommitted= 235fdc37cb4e83e243a17cafde8597967ef2316443a2656dee73a57dc55cac8e 7384 1352 15956 14797 +fp128_onehot_multi_chunk_w2r2 final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 259392 20717 402707 368353 +fp128_onehot_multi_chunk_w2r2 final=14:1;precommitted=14:1 ee81d011f70c1a8be6740527e9b9a2229ad141fbbc72338db34d08fc7c2984c7 8697 1391 16100 17304 +fp128_onehot_multi_chunk_w2r3 final=14:1;precommitted= 235fdc37cb4e83e243a17cafde8597967ef2316443a2656dee73a57dc55cac8e 2524 761 8277 4679 +fp128_onehot_multi_chunk_w2r3 final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 232277 20760 376280 340788 +fp128_onehot_multi_chunk_w2r3 final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 2189 652 7645 3955 +fp128_onehot_multi_chunk_w4r2 final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 246679 19955 383201 343572 +fp128_onehot_multi_chunk_w4r3 final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 254075 21436 411279 346154 +fp128_onehot_multi_chunk_w4r3 final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 2582 780 9978 4661 +fp128_onehot_multi_chunk_w8r3 final=34:2;precommitted=16:1,16:1 36fb48fda0447fa6a9d3cc840e8c5103bca72812581d6d756707adc652196fec 626754 39611 945217 545057 +fp128_onehot_multi_chunk_w8r3 final=32:1;precommitted= 47272635f2438376271d1b13f43ae0fbb70effc54ee431f53fae213a90ff22bc 280044 22328 446053 358227 +fp128_onehot_multi_chunk_w8r3 final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 2290 787 11716 3779 +fp128_onehot_multi_chunk_w8r3 final=32:2;precommitted=16:1,16:1 d9a96a56f457fc61c78e1fdae89d76daea9da85f7ee340dd1742a7f4925d9b8d 475471 32661 734939 471523 +fp128_onehot_recursive final=34:2;precommitted=16:1,16:1 30424e0ca54d42b83af9180851eb3c3bf76481bd1b3487fbbad1c7bcaaecb7c9 272141 26881 1481695 1042115 +fp128_onehot_recursive final=32:2;precommitted=16:1,16:1 7af0b4d281bc91485cb708c45d09dbeff67604e5e932e20cdb84ee742250c355 265717 24960 1421054 1004071 +fp128_onehot_recursive final=36:1;precommitted= b9f19e5b22668a057b75ac8efda23eb70064baa0dde95e015463454c195a7713 569916 46503 2608572 1692037 +fp128_onehot_recursive_multi_chunk_w2r3 final=32:2;precommitted=16:1,16:1 476da39485af2590ec71abe1e36612662c9310b641229a42399c0e341ca1cf0d 226832 23463 1220640 909882 +fp128_onehot_recursive_multi_chunk_w2r3 final=34:2;precommitted=16:1,16:1 7dbc9ca4c3042bfff07849c2f82a20e5dc4faed1b7e14bdc3c66dca199c0aa46 344554 33204 1779499 1319078 +fp128_onehot_recursive_multi_chunk_w4r3 final=32:2;precommitted=16:1,16:1 7bacfcee37e74c80f7b1c900e1efd2da313d46aac18501aa058dc854f71c8cbf 216914 22487 1183928 841893 +fp128_onehot_recursive_multi_chunk_w4r3 final=34:2;precommitted=16:1,16:1 a566276dafd88ca52207ba1f4784f4d83086d9fad91b86e4018094ba450ead4c 234542 25088 1272026 934312 +fp128_onehot_recursive_multi_chunk_w8r2 final=34:2;precommitted=16:1,16:1 36fb48fda0447fa6a9d3cc840e8c5103bca72812581d6d756707adc652196fec 206734 22034 1185090 875189 +fp128_onehot_recursive_multi_chunk_w8r2 final=32:2;precommitted=16:1,16:1 d9a96a56f457fc61c78e1fdae89d76daea9da85f7ee340dd1742a7f4925d9b8d 198963 19244 1125957 868688 +fp128_onehot_recursive_multi_chunk_w8r3 final=34:2;precommitted=16:1,16:1 36fb48fda0447fa6a9d3cc840e8c5103bca72812581d6d756707adc652196fec 200907 21978 1131619 825709 +fp128_onehot_recursive_multi_chunk_w8r3 final=32:2;precommitted=16:1,16:1 d9a96a56f457fc61c78e1fdae89d76daea9da85f7ee340dd1742a7f4925d9b8d 161926 17265 959179 716412 +fp32_dense final=30:1;precommitted= 0661c6ea7796b195873f4f0afc34e1f8ab8b773648907a24b0f5110247bb10d2 699072 45704 1144764 1192934 +fp32_dense final=20:1;precommitted= 7e615f063166da9e18d6e9eacc305f7128e85099f64b8b48278e935e02e876fe 787653 43959 1221525 1321605 +fp32_dense final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 1019788 58356 1576813 1472353 +fp32_dense final=26:1;precommitted= c93e949f727a2db762f7dbfe2fcdcdc29cf0a3b5fd10f76b3019de436810bc9b 923794 58120 1474690 1520901 +fp32_dense final=20:1;precommitted=20:1 eb9006da2ba11329054b41effb1a570facd47539ad53c5a94d61c08461fd2e64 814538 40790 1242380 1163478 +fp32_dense_recursive final=30:1;precommitted= 0661c6ea7796b195873f4f0afc34e1f8ab8b773648907a24b0f5110247bb10d2 461847 45434 2629507 1947417 +fp32_dense_recursive final=22:1;precommitted= 43c57a9c37004f9565033f2c8ae56fdc6891e2e8ee936abeb6267fa9a84e3696 1354 367 52803 5232 +fp32_dense_recursive final=20:1;precommitted= 7e615f063166da9e18d6e9eacc305f7128e85099f64b8b48278e935e02e876fe 574 187 43193 3066 +fp32_dense_recursive final=24:1;precommitted= 94e7a14c6d0dc602c7ebc7e2d1dd26de309a4ccf1ba006bd7c284ecae724ca0d 1884 1034 63021 10144 +fp32_dense_recursive final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 617756 67339 3339947 1931437 +fp32_dense_recursive final=26:1;precommitted= c93e949f727a2db762f7dbfe2fcdcdc29cf0a3b5fd10f76b3019de436810bc9b 101173 12759 995437 746420 +fp32_onehot final=30:1;precommitted= 0661c6ea7796b195873f4f0afc34e1f8ab8b773648907a24b0f5110247bb10d2 461517 35611 810217 982547 +fp32_onehot final=16:2;precommitted= 1d3119b577401489425a46a52a8368829ffee0287db544156fcb790c4c1190e1 55194 6784 112815 173075 +fp32_onehot final=14:1;precommitted= 235fdc37cb4e83e243a17cafde8597967ef2316443a2656dee73a57dc55cac8e 57 29 1101 152 +fp32_onehot final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 12222 2256 27257 42998 +fp32_onehot final=34:1;precommitted= 66a207dd2ca7efff383d2cbfb09722aa71c0b3e44ba990a146833ebba9496679 422785 33508 753944 901578 +fp32_onehot final=20:1;precommitted= 7e615f063166da9e18d6e9eacc305f7128e85099f64b8b48278e935e02e876fe 198675 17211 371359 526395 +fp32_onehot final=20:1;precommitted=14:1 7ec38a4dfd9004990b8cc0ead2652f3ddf8a9f2525ff9b8630bba57c112c8232 258305 19661 456471 609862 +fp32_onehot final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 450542 33641 779101 959122 +fp64_dense final=30:1;precommitted= 0661c6ea7796b195873f4f0afc34e1f8ab8b773648907a24b0f5110247bb10d2 3253550 146969 4641847 3354348 +fp64_dense final=14:1;precommitted= 235fdc37cb4e83e243a17cafde8597967ef2316443a2656dee73a57dc55cac8e 688425 34926 1050553 978865 +fp64_dense final=16:1;precommitted= 619d5acd56307a5aff5911635e36969a167028482b33cc55f9f90598a55554be 1428231 58713 2028862 1676603 +fp64_dense final=20:1;precommitted= 7e615f063166da9e18d6e9eacc305f7128e85099f64b8b48278e935e02e876fe 2461415 102786 3488245 2745146 +fp64_dense final=29:1;precommitted= a082674574a73638bfa1560ac8173d4e86d4e1bc9f62665c28e7c58fcc4f0d9c 2183445 119661 3234233 2504898 +fp64_dense final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 2878891 129628 4117694 3041167 +fp64_dense final=26:1;precommitted= c93e949f727a2db762f7dbfe2fcdcdc29cf0a3b5fd10f76b3019de436810bc9b 3485342 146801 4879826 3456691 +fp64_dense final=20:1;precommitted=16:1 d57b503a3845f43d2227dc5e32aafbd3b24bbfb950a69fffc34820e0a8021c01 2517868 99072 3582658 2630391 +fp64_dense_recursive final=23:1;precommitted= 2fcfd5127478a426862f40c03ac86273c00151e8c0068bc578b1578281e1aee2 257519 24173 2432578 1678476 +fp64_dense_recursive final=21:1;precommitted= 4f021af291010debd97a561631f77ec725a00685e86c7a86cbbba0d0d21f2afa 15059 4446 447863 128866 +fp64_dense_recursive final=25:1;precommitted= 8b93e9363d0087a49725b9a1ca103902ab185fff9c1a43773c7025ac4bc11bc9 429804 33478 3221376 2444074 +fp64_dense_recursive final=29:1;precommitted= a082674574a73638bfa1560ac8173d4e86d4e1bc9f62665c28e7c58fcc4f0d9c 501342 42594 3529050 2689459 +fp64_dense_recursive final=27:1;precommitted= a6c2f122b8b925f416c9bbe519d0f2bedbd9063119ff80f478764499448c6dc8 184573 22116 1478364 1022950 +fp64_onehot final=30:1;precommitted= 0661c6ea7796b195873f4f0afc34e1f8ab8b773648907a24b0f5110247bb10d2 1007858 60998 1594193 1547162 +fp64_onehot final=34:1;precommitted= 66a207dd2ca7efff383d2cbfb09722aa71c0b3e44ba990a146833ebba9496679 1696051 87592 2564031 2212311 +fp64_onehot final=28:1;precommitted= a46de3bb37f42fa48a71b95f2322aad4c4e9c8626b40c631cd6537cb251072a1 964308 57537 1525602 1498979 +fp64_onehot final=35:1;precommitted= bfb5adcb7db566d2caa4ca8d06480c0b099cf7f3e72a2b1f762b149ecb03e830 1448921 75956 2200605 1908853 diff --git a/crates/akita-planner/README.md b/crates/akita-planner/README.md index e8d52bd42..3d2e1c974 100644 --- a/crates/akita-planner/README.md +++ b/crates/akita-planner/README.md @@ -321,6 +321,25 @@ repository permits catalog-breaking revisions, so baseline/current policy is reviewed from the checked evidence. Same-head drift remains an automatic failure under `--check-catalog`. +Planner search size is guarded the same way. `--planner-cost-snapshot ` +writes one row per regenerated row with four deterministic search-work +counters: suffix calls, completed states, generated fold candidates, and +descriptor builds. A reused preplanned row reports the work of the search that +produced it. The counts do not depend on timing or worker count. They measure +how large the search is, not how expensive each candidate is to price. A change +that makes edge, terminal, or grinding pricing slower without visiting more +states or building more candidates passes this check; the regeneration step's +CI timeout is the only backstop for that kind of slowdown. The tracked +baseline is `artifacts/schedule-planner-cost.tsv`; CI passes it through +`--planner-cost-baseline `, which fails when a row is added or +removed or when any counter grows by more than a quarter. When a planner change +intentionally moves rows or costs, regenerate the baseline with the complete +family set: + +```bash +scripts/generate-schedule-artifacts.sh --planner-cost-snapshot artifacts/schedule-planner-cost.tsv +``` + Targeted generation leaves the shared `mod.rs` wiring complete. Before a planner change is committed, run the unfiltered command above so every tracked family is regenerated. diff --git a/crates/akita-planner/src/bin/gen_schedule_artifacts.rs b/crates/akita-planner/src/bin/gen_schedule_artifacts.rs index 672ce08fe..3613a2169 100644 --- a/crates/akita-planner/src/bin/gen_schedule_artifacts.rs +++ b/crates/akita-planner/src/bin/gen_schedule_artifacts.rs @@ -3,6 +3,7 @@ mod catalog_policy_report; mod catalog_snapshot; mod generation_output_path; +mod planner_cost_snapshot; use catalog_policy_report::catalog_policy_signature; use generation_output_path::validate_explicit_output_isolation; @@ -40,6 +41,8 @@ struct ParsedArgs { catalog_snapshot: Option, catalog_baseline: Option, require_catalog_baseline_match: bool, + planner_cost_snapshot: Option, + planner_cost_baseline: Option, row_progress: bool, family_filter: Option>, explicit_rows: ExplicitRows, @@ -63,6 +66,7 @@ fn usage() -> &'static str { --bin gen_schedule_artifacts -- [--check-catalog] \ [--catalog-report ] [--catalog-snapshot ] \ [--catalog-baseline ] [--require-catalog-baseline-match] \ + [--planner-cost-snapshot ] [--planner-cost-baseline ] \ [--row-progress] \ [family_name ...]\n\ positional family names select only those generated families; omit them \ @@ -136,6 +140,8 @@ fn parse_args_from(raw_args: Vec) -> Result { let mut catalog_snapshot = None; let mut catalog_baseline = None; let mut require_catalog_baseline_match = false; + let mut planner_cost_snapshot = None; + let mut planner_cost_baseline = None; let mut row_progress = false; let mut family_args = Vec::new(); let mut explicit_rows = ExplicitRows::default(); @@ -184,6 +190,26 @@ fn parse_args_from(raw_args: Vec) -> Result { require_catalog_baseline_match = true; i += 1; } + "--planner-cost-snapshot" => { + let value = raw_args + .get(i + 1) + .ok_or_else(|| "--planner-cost-snapshot requires a path".to_string())?; + if planner_cost_snapshot.is_some() { + return Err("--planner-cost-snapshot may be supplied only once".to_string()); + } + planner_cost_snapshot = Some(PathBuf::from(value)); + i += 2; + } + "--planner-cost-baseline" => { + let value = raw_args + .get(i + 1) + .ok_or_else(|| "--planner-cost-baseline requires a path".to_string())?; + if planner_cost_baseline.is_some() { + return Err("--planner-cost-baseline may be supplied only once".to_string()); + } + planner_cost_baseline = Some(PathBuf::from(value)); + i += 2; + } "--final-group" => { let value = raw_args .get(i + 1) @@ -235,8 +261,11 @@ fn parse_args_from(raw_args: Vec) -> Result { } else { Some(family_args) }; - let catalog_rows_requested = - check_catalog || catalog_snapshot.is_some() || catalog_baseline.is_some(); + let catalog_rows_requested = check_catalog + || catalog_snapshot.is_some() + || catalog_baseline.is_some() + || planner_cost_snapshot.is_some() + || planner_cost_baseline.is_some(); if catalog_rows_requested && explicit_rows.final_group.is_some() { return Err("catalog checks and snapshots require the standard artifact rows".to_string()); } @@ -252,6 +281,11 @@ fn parse_args_from(raw_args: Vec) -> Result { if require_catalog_baseline_match && catalog_baseline.is_none() { return Err("--require-catalog-baseline-match requires --catalog-baseline".to_string()); } + if planner_cost_baseline.is_some() && family_filter.is_some() { + return Err( + "--planner-cost-baseline requires the complete generated family set".to_string(), + ); + } Ok(ParsedArgs { base_dir, check_catalog, @@ -259,6 +293,8 @@ fn parse_args_from(raw_args: Vec) -> Result { catalog_snapshot, catalog_baseline, require_catalog_baseline_match, + planner_cost_snapshot, + planner_cost_baseline, row_progress, family_filter, explicit_rows, @@ -485,6 +521,31 @@ fn materialized_snapshot_rows( .collect() } +fn planner_cost_rows( + spec: &EmitSpec, + entries: &[akita_planner::emit::MaterializedEntry], +) -> Result, String> { + entries + .iter() + .map(|entry| { + let key = entry.key(); + let cost = entry.planner_cost().ok_or_else(|| { + format!( + "{}: {} has no planner cost", + spec.family_name, + catalog_logical_key(&key) + ) + })?; + Ok(planner_cost_snapshot::PlannerCostRow { + family: spec.family_name.to_string(), + logical_key: catalog_logical_key(&key), + lookup_key_digest: catalog_lookup_key_digest(&key), + cost, + }) + }) + .collect() +} + impl ExplicitRows { fn has_family(&self, family: &GeneratedFamily) -> bool { self.final_group @@ -679,10 +740,14 @@ fn main() -> Result<(), String> { }; let mut changed_catalog_rows = 0usize; let mut current_catalog_rows = Vec::new(); + let collect_planner_cost = + args.planner_cost_snapshot.is_some() || args.planner_cost_baseline.is_some(); + let mut current_planner_cost_rows = Vec::new(); let outputs = render_schedule_artifact_outputs_with_validation( &specs, MaterializationDiagnostics { row_progress: args.row_progress, + planner_cost: collect_planner_cost, }, |spec, entries| { if check_catalog { @@ -695,6 +760,9 @@ fn main() -> Result<(), String> { if collect_catalog_snapshot { current_catalog_rows.extend(materialized_snapshot_rows(spec, entries)?); } + if collect_planner_cost { + current_planner_cost_rows.extend(planner_cost_rows(spec, entries)?); + } Ok(()) }, )?; @@ -754,6 +822,37 @@ fn main() -> Result<(), String> { )); } } + if let Some(path) = &args.planner_cost_snapshot { + let snapshot = planner_cost_snapshot::write_snapshot(current_planner_cost_rows.clone())?; + fs::write(path, snapshot).map_err(|error| format!("write {}: {error}", path.display()))?; + eprintln!("wrote planner cost snapshot {}", path.display()); + } + if let Some(path) = &args.planner_cost_baseline { + let baseline = fs::read_to_string(path) + .map_err(|error| format!("read {}: {error}", path.display()))?; + let comparison = planner_cost_snapshot::compare_snapshots( + planner_cost_snapshot::parse_snapshot(&baseline)?, + current_planner_cost_rows, + )?; + eprint!("{}", comparison.report); + for ((name, baseline_total), current_total) in planner_cost_snapshot::COUNTER_NAMES + .into_iter() + .zip(comparison.baseline_totals) + .zip(comparison.current_totals) + { + eprintln!( + "planner cost total {name}: baseline {baseline_total}, current {current_total}" + ); + } + if comparison.failures != 0 { + return Err(format!( + "planner search work differs from {} in {} places; \ + regenerate it with --planner-cost-snapshot if the change is intended", + path.display(), + comparison.failures, + )); + } + } let publish_started = args.row_progress.then(Instant::now); if args.row_progress { eprintln!( diff --git a/crates/akita-planner/src/bin/planner_cost_snapshot.rs b/crates/akita-planner/src/bin/planner_cost_snapshot.rs new file mode 100644 index 000000000..0f7c2820f --- /dev/null +++ b/crates/akita-planner/src/bin/planner_cost_snapshot.rs @@ -0,0 +1,250 @@ +//! Per-row planner search-work snapshot and its regression guard. + +use akita_planner::emit::PlannerCost; +use std::collections::BTreeMap; +use std::fmt::Write as _; + +const SNAPSHOT_HEADER: &str = "family\tlogical_key\tlookup_key_digest\tsuffix_calls\tcompleted_states\tgenerated_candidates\tdescriptor_builds\n"; + +/// A row fails the guard when one counter exceeds its baseline by more than +/// `ALLOWED_GROWTH_NUMERATOR / ALLOWED_GROWTH_DENOMINATOR`. +const ALLOWED_GROWTH_NUMERATOR: usize = 5; +const ALLOWED_GROWTH_DENOMINATOR: usize = 4; + +pub(super) const COUNTER_NAMES: [&str; 4] = [ + "suffix_calls", + "completed_states", + "generated_candidates", + "descriptor_builds", +]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct PlannerCostRow { + pub(super) family: String, + pub(super) logical_key: String, + pub(super) lookup_key_digest: String, + pub(super) cost: PlannerCost, +} + +impl PlannerCostRow { + fn map_key(&self) -> (String, String) { + (self.family.clone(), self.lookup_key_digest.clone()) + } + + const fn counters(&self) -> [usize; 4] { + [ + self.cost.suffix_calls, + self.cost.completed_states, + self.cost.generated_candidates, + self.cost.descriptor_builds, + ] + } +} + +fn indexed_rows( + rows: Vec, + context: &str, +) -> Result, String> { + let mut indexed = BTreeMap::new(); + for row in rows { + let key = row.map_key(); + if indexed.insert(key.clone(), row).is_some() { + return Err(format!( + "{context}: duplicate planner cost row {} / {}", + key.0, key.1 + )); + } + } + Ok(indexed) +} + +pub(super) fn write_snapshot(rows: Vec) -> Result { + let mut out = SNAPSHOT_HEADER.to_string(); + for row in indexed_rows(rows, "write planner cost snapshot")?.values() { + let [suffix_calls, completed_states, generated_candidates, descriptor_builds] = + row.counters(); + writeln!( + out, + "{}\t{}\t{}\t{suffix_calls}\t{completed_states}\t{generated_candidates}\t{descriptor_builds}", + row.family, row.logical_key, row.lookup_key_digest, + ) + .map_err(|error| format!("write planner cost snapshot: {error}"))?; + } + Ok(out) +} + +pub(super) fn parse_snapshot(input: &str) -> Result, String> { + let mut lines = input.lines(); + if lines.next().map(|header| format!("{header}\n")).as_deref() != Some(SNAPSHOT_HEADER) { + return Err("planner cost snapshot has an unsupported header".to_string()); + } + lines + .enumerate() + .map(|(offset, line)| { + let line_number = offset + 2; + let columns = line.split('\t').collect::>(); + let [family, logical_key, lookup_key_digest, counters @ ..] = columns.as_slice() else { + return Err(format!( + "planner cost snapshot line {line_number}: expected 7 TSV columns, got {}", + columns.len() + )); + }; + let counters = counters + .iter() + .map(|raw| { + raw.parse::().map_err(|error| { + format!( + "planner cost snapshot line {line_number}: invalid counter `{raw}`: {error}" + ) + }) + }) + .collect::, _>>()?; + let [suffix_calls, completed_states, generated_candidates, descriptor_builds] = + counters.as_slice() + else { + return Err(format!( + "planner cost snapshot line {line_number}: expected 7 TSV columns, got {}", + columns.len() + )); + }; + Ok(PlannerCostRow { + family: (*family).to_string(), + logical_key: (*logical_key).to_string(), + lookup_key_digest: (*lookup_key_digest).to_string(), + cost: PlannerCost { + suffix_calls: *suffix_calls, + completed_states: *completed_states, + generated_candidates: *generated_candidates, + descriptor_builds: *descriptor_builds, + }, + }) + }) + .collect() +} + +pub(super) struct PlannerCostComparison { + pub(super) report: String, + pub(super) failures: usize, + /// Counter sums in [`COUNTER_NAMES`] order. + pub(super) baseline_totals: [usize; 4], + pub(super) current_totals: [usize; 4], +} + +fn add_counters(totals: &mut [usize; 4], row: &PlannerCostRow) -> Result<(), String> { + for (total, value) in totals.iter_mut().zip(row.counters()) { + *total = total + .checked_add(value) + .ok_or_else(|| "planner cost total overflow".to_string())?; + } + Ok(()) +} + +/// Compare the current per-row search work with a checked-in baseline. +/// +/// Added and removed rows fail, as does any counter that grows by more than a +/// quarter. Either way the fix is to regenerate the baseline deliberately. +pub(super) fn compare_snapshots( + baseline: Vec, + current: Vec, +) -> Result { + let baseline = indexed_rows(baseline, "planner cost baseline")?; + let current = indexed_rows(current, "current planner cost")?; + let mut comparison = PlannerCostComparison { + report: String::new(), + failures: 0, + baseline_totals: [0; 4], + current_totals: [0; 4], + }; + for row in baseline.values() { + add_counters(&mut comparison.baseline_totals, row)?; + if !current.contains_key(&row.map_key()) { + comparison.failures += 1; + writeln!( + comparison.report, + "{}\t{}\tremoved", + row.family, row.logical_key + ) + .map_err(|error| format!("write planner cost report: {error}"))?; + } + } + for row in current.values() { + add_counters(&mut comparison.current_totals, row)?; + let Some(baseline_row) = baseline.get(&row.map_key()) else { + comparison.failures += 1; + writeln!( + comparison.report, + "{}\t{}\tadded", + row.family, row.logical_key + ) + .map_err(|error| format!("write planner cost report: {error}"))?; + continue; + }; + for ((name, current_value), baseline_value) in COUNTER_NAMES + .into_iter() + .zip(row.counters()) + .zip(baseline_row.counters()) + { + let scaled_current = current_value + .checked_mul(ALLOWED_GROWTH_DENOMINATOR) + .ok_or_else(|| "planner cost comparison overflow".to_string())?; + let allowed = baseline_value + .checked_mul(ALLOWED_GROWTH_NUMERATOR) + .ok_or_else(|| "planner cost comparison overflow".to_string())?; + if scaled_current > allowed { + comparison.failures += 1; + writeln!( + comparison.report, + "{}\t{}\tgrew\t{name}\t{baseline_value}\t{current_value}", + row.family, row.logical_key, + ) + .map_err(|error| format!("write planner cost report: {error}"))?; + } + } + } + Ok(comparison) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(digest: &str, suffix_calls: usize) -> PlannerCostRow { + PlannerCostRow { + family: "fp64_dense".to_string(), + logical_key: "final=20:1;precommitted=".to_string(), + lookup_key_digest: digest.to_string(), + cost: PlannerCost { + suffix_calls, + completed_states: 10, + generated_candidates: 100, + descriptor_builds: 7, + }, + } + } + + #[test] + fn snapshot_round_trips() { + let rows = vec![row("bb", 3), row("aa", 4)]; + let parsed = parse_snapshot(&write_snapshot(rows.clone()).unwrap()).unwrap(); + assert_eq!(parsed, vec![row("aa", 4), row("bb", 3)]); + } + + #[test] + fn growth_beyond_a_quarter_fails_and_shrinking_passes() { + let within = compare_snapshots(vec![row("aa", 100)], vec![row("aa", 125)]).unwrap(); + assert_eq!(within.failures, 0, "{}", within.report); + let shrink = compare_snapshots(vec![row("aa", 100)], vec![row("aa", 1)]).unwrap(); + assert_eq!(shrink.failures, 0, "{}", shrink.report); + let grew = compare_snapshots(vec![row("aa", 100)], vec![row("aa", 126)]).unwrap(); + assert_eq!(grew.failures, 1); + assert!(grew.report.contains("grew\tsuffix_calls\t100\t126")); + } + + #[test] + fn row_set_changes_fail() { + let changed = compare_snapshots(vec![row("aa", 1)], vec![row("bb", 1)]).unwrap(); + assert_eq!(changed.failures, 2); + assert!(changed.report.contains("removed")); + assert!(changed.report.contains("added")); + } +} diff --git a/crates/akita-planner/src/diagnostics.rs b/crates/akita-planner/src/diagnostics.rs index e157b9a80..32c28147d 100644 --- a/crates/akita-planner/src/diagnostics.rs +++ b/crates/akita-planner/src/diagnostics.rs @@ -56,6 +56,29 @@ pub(crate) struct SelectedScheduleDiagnostics { folds: Vec, } +/// Search work of one root search pass under one padded setup budget. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct SetupBudgetPass { + /// `None` for a pass without a setup budget. + pub(crate) budget: Option, + pub(crate) generated_candidates: usize, + pub(crate) descriptor_builds: usize, +} + +/// Deterministic search-work counters for one planned schedule row. +/// +/// The counts depend only on the planner code and its inputs, never on timing +/// or worker scheduling, so a checked-in baseline can guard search size. They +/// do not count per-candidate pricing work, so a slower edge, terminal, or +/// grinding pricing step leaves them unchanged. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PlannerCost { + pub suffix_calls: usize, + pub completed_states: usize, + pub generated_candidates: usize, + pub descriptor_builds: usize, +} + #[derive(Clone, Debug, Default)] pub(crate) struct PlannerDiagnosticsSnapshot { pub(crate) suffix_dp_time: Duration, @@ -81,9 +104,21 @@ pub(crate) struct PlannerDiagnosticsSnapshot { pub(crate) setup_prefix_cache_hits: usize, pub(crate) setup_prefix_cache_misses: usize, pub(crate) guided_direct_edge_prunes: usize, + pub(crate) setup_budget_passes: Vec, selected: Option, } +impl PlannerDiagnosticsSnapshot { + pub(crate) const fn planner_cost(&self) -> PlannerCost { + PlannerCost { + suffix_calls: self.suffix_calls, + completed_states: self.completed_states, + generated_candidates: self.generated_candidates, + descriptor_builds: self.descriptor_builds, + } + } +} + impl fmt::Display for PlannerDiagnosticsSnapshot { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let memo_total = self.memo_hits.saturating_add(self.memo_misses); @@ -129,6 +164,26 @@ impl fmt::Display for PlannerDiagnosticsSnapshot { self.setup_prefix_cache_hits .saturating_add(self.setup_prefix_cache_misses), )?; + write!( + formatter, + " setup_budget_passes={}[", + self.setup_budget_passes.len() + )?; + for (index, pass) in self.setup_budget_passes.iter().enumerate() { + if index != 0 { + formatter.write_str(",")?; + } + match pass.budget { + Some(budget) => write!(formatter, "{budget}")?, + None => formatter.write_str("unbounded")?, + } + write!( + formatter, + ":candidates={}/descriptors={}", + pass.generated_candidates, pass.descriptor_builds + )?; + } + formatter.write_str("]")?; if let Some(selected) = &self.selected { write!( formatter, @@ -198,6 +253,7 @@ pub(crate) struct PlannerDiagnostics { setup_prefix_cache_hits: Cell, setup_prefix_cache_misses: Cell, guided_direct_edge_prunes: Cell, + setup_budget_passes: RefCell>, selected: RefCell>, } @@ -288,8 +344,10 @@ impl PlannerDiagnostics { } pub(crate) fn record_setup_prefix_cache(&self, hits: usize, misses: usize) { - self.setup_prefix_cache_hits.set(hits); - self.setup_prefix_cache_misses.set(misses); + self.setup_prefix_cache_hits + .set(self.setup_prefix_cache_hits.get().saturating_add(hits)); + self.setup_prefix_cache_misses + .set(self.setup_prefix_cache_misses.get().saturating_add(misses)); } pub(crate) fn record_guided_direct_edge_prune(&self) { @@ -297,6 +355,24 @@ impl PlannerDiagnostics { .set(self.guided_direct_edge_prunes.get().saturating_add(1)); } + /// Candidates generated and descriptors built so far. + pub(crate) fn search_work(&self) -> [usize; 2] { + [ + self.generated_candidates.get(), + self.descriptor_builds.get(), + ] + } + + /// Record one root search pass that started at `search_work` `start`. + pub(crate) fn record_setup_budget_pass(&self, budget: Option, start: [usize; 2]) { + let [candidates, descriptors] = self.search_work(); + self.setup_budget_passes.borrow_mut().push(SetupBudgetPass { + budget, + generated_candidates: candidates.saturating_sub(start[0]), + descriptor_builds: descriptors.saturating_sub(start[1]), + }); + } + pub(crate) fn record_selected( &self, objective: SelectionPolicyId, @@ -349,6 +425,7 @@ impl PlannerDiagnostics { setup_prefix_cache_hits: self.setup_prefix_cache_hits.get(), setup_prefix_cache_misses: self.setup_prefix_cache_misses.get(), guided_direct_edge_prunes: self.guided_direct_edge_prunes.get(), + setup_budget_passes: self.setup_budget_passes.borrow().clone(), selected: self.selected.borrow().clone(), } } @@ -428,4 +505,39 @@ mod tests { assert_eq!(snapshot.guided_direct_edge_prunes, 1); assert!(active().is_none()); } + + #[test] + fn setup_budget_passes_record_work_since_their_start() { + let (_, snapshot) = capture(true, || { + let diagnostics = active().expect("diagnostics must be active inside capture"); + diagnostics.record_candidates(12, 4); + let start = diagnostics.search_work(); + diagnostics.record_candidates(5, 2); + diagnostics.record_descriptor(Duration::ZERO); + diagnostics.record_setup_budget_pass(Some(64), start); + let start = diagnostics.search_work(); + diagnostics.record_candidates(3, 1); + diagnostics.record_setup_budget_pass(None, start); + }); + let snapshot = snapshot.expect("enabled capture must return diagnostics"); + assert_eq!( + snapshot.setup_budget_passes, + [ + SetupBudgetPass { + budget: Some(64), + generated_candidates: 5, + descriptor_builds: 1, + }, + SetupBudgetPass { + budget: None, + generated_candidates: 3, + descriptor_builds: 0, + }, + ] + ); + let line = snapshot.to_string(); + assert!(line.contains( + " setup_budget_passes=2[64:candidates=5/descriptors=1,unbounded:candidates=3/descriptors=0]" + )); + } } diff --git a/crates/akita-planner/src/emit/materialize.rs b/crates/akita-planner/src/emit/materialize.rs index b84236fc0..43ebae4ca 100644 --- a/crates/akita-planner/src/emit/materialize.rs +++ b/crates/akita-planner/src/emit/materialize.rs @@ -19,6 +19,7 @@ struct IndexedPlanningRequest { pub struct MaterializedEntry { request: PlanningRequest, schedule: FoldSchedule, + planner_cost: Option, } impl MaterializedEntry { @@ -35,6 +36,13 @@ impl MaterializedEntry { &self.schedule } + /// Search work for this row, when [`MaterializationDiagnostics::planner_cost`] + /// was requested or the row reused a caller preplan. + #[must_use] + pub const fn planner_cost(&self) -> Option { + self.planner_cost + } + /// Producer declarations for the frozen groups in opening order. #[must_use] pub fn precommitted_producers(&self) -> &[PrecommittedProducer] { @@ -136,10 +144,15 @@ pub(crate) fn materialized_entries_for_specs( ); (Instant::now(), label) }); - let (outcome, planner_diagnostics) = - crate::diagnostics::capture(diagnostics.row_progress, || { - materialized_entry(spec, &indexed.request) - }); + let (mut outcome, planner_diagnostics) = crate::diagnostics::capture( + diagnostics.row_progress || diagnostics.planner_cost, + || materialized_entry(spec, &indexed.request), + ); + if let (Ok(MaterializedRequestOutcome::Planned(entry)), Some(planner_diagnostics)) = + (&mut outcome, &planner_diagnostics) + { + entry.planner_cost = Some(planner_diagnostics.planner_cost()); + } if let Some((started, label)) = progress { let counters = &counters.as_ref().expect("progress counters")[indexed.spec_index]; match &outcome { @@ -236,10 +249,10 @@ fn materialized_entry( let preplanned = spec .preplanned .iter() - .find(|(planned_key, _)| *planned_key == key); + .find(|preplanned| preplanned.key == key); let reused_preplan = preplanned.is_some(); let result = match preplanned { - Some((_, schedule)) => Ok(schedule.clone()), + Some(preplanned) => Ok(preplanned.schedule.clone()), None => crate::find_schedule( &key, spec.source_contract, @@ -252,6 +265,7 @@ fn materialized_entry( let entry = |schedule| MaterializedEntry { request: request.clone(), schedule, + planner_cost: preplanned.map(|preplanned| preplanned.planner_cost), }; match result { Ok(schedule) if reused_preplan => { @@ -378,7 +392,11 @@ mod tests { ) .unwrap(); spec.keys = vec![group]; - spec.preplanned = vec![(ScheduleLookupKey::single(group), row.schedule().clone())]; + spec.preplanned = vec![PreplannedSchedule { + key: ScheduleLookupKey::single(group), + schedule: row.schedule().clone(), + planner_cost: PlannerCost::default(), + }]; render_schedule_artifact_outputs_with_validation( std::slice::from_ref(&spec), MaterializationDiagnostics::default(), diff --git a/crates/akita-planner/src/emit/mod.rs b/crates/akita-planner/src/emit/mod.rs index b6f0b1f9b..3ae5cbb6e 100644 --- a/crates/akita-planner/src/emit/mod.rs +++ b/crates/akita-planner/src/emit/mod.rs @@ -18,6 +18,7 @@ mod materialize; mod publish; mod render; +pub use crate::diagnostics::PlannerCost; pub(super) use materialize::materialized_entries_for_specs; pub use materialize::MaterializedEntry; pub use publish::publish_artifact_outputs; @@ -27,6 +28,17 @@ pub use render::{render_schedule_artifact_outputs_with_validation, ArtifactOutpu #[derive(Clone, Copy, Debug, Default)] pub struct MaterializationDiagnostics { pub row_progress: bool, + /// Record [`PlannerCost`] counters on every planned row. + pub planner_cost: bool, +} + +/// One exact schedule planned by the caller before row materialization. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PreplannedSchedule { + pub key: ScheduleLookupKey, + pub schedule: FoldSchedule, + /// The search work spent when the caller planned this schedule. + pub planner_cost: PlannerCost, } /// One frozen precommit descriptor with its canonical producer facts. @@ -135,7 +147,7 @@ pub struct EmitSpec { pub keys: Vec, pub grouped_requests: Vec, /// Exact schedules already planned by the caller, audited against `policy` on emission. - pub preplanned: Vec<(ScheduleLookupKey, FoldSchedule)>, + pub preplanned: Vec, pub output_path: PathBuf, pub ring_challenge_config: fn(usize) -> Result, } diff --git a/crates/akita-planner/src/generated_families.rs b/crates/akita-planner/src/generated_families.rs index e860ce6e9..b7054bf1a 100644 --- a/crates/akita-planner/src/generated_families.rs +++ b/crates/akita-planner/src/generated_families.rs @@ -14,6 +14,7 @@ use std::any::TypeId; use std::sync::{Arc, Mutex, OnceLock}; pub use crate::emit::{GroupedGenerationRequest, PrecommittedProducer}; +use crate::emit::{PlannerCost, PreplannedSchedule}; use crate::{find_schedule, EmitSpec, PlannerPolicy}; use akita_challenges::SparseChallengeConfig; use akita_error::AkitaError; @@ -28,7 +29,7 @@ struct ScalarPreplan { source: TypeId, chunking: ChunkedWitnessCfg, key: PolynomialGroupLayout, - result: OnceLock>, + result: OnceLock>, } /// Exact scalar schedules already needed while preparing one generator run. @@ -75,16 +76,24 @@ impl GenerationPreplans { preplanned .result .get_or_init(|| { - find_schedule( - &ScheduleLookupKey::single(key), - Cfg::committed_source_contract()?, - &[], - &policy_of::(chunking), - Cfg::ring_challenge_config, - ) - .map(|planned| planned.schedule) + // Preplans run before row materialization, so they always + // record their search work for the row that reuses them. + let (planned, diagnostics) = crate::diagnostics::capture(true, || { + find_schedule( + &ScheduleLookupKey::single(key), + Cfg::committed_source_contract()?, + &[], + &policy_of::(chunking), + Cfg::ring_challenge_config, + ) + }); + let planner_cost = diagnostics + .map(|diagnostics| diagnostics.planner_cost()) + .unwrap_or_default(); + planned.map(|planned| (planned.schedule, planner_cost)) }) .clone() + .map(|(schedule, _)| schedule) } /// Return an exact scalar result previously planned for `family`. @@ -93,7 +102,7 @@ impl GenerationPreplans { family: &GeneratedFamily, key: PolynomialGroupLayout, chunking: ChunkedWitnessCfg, - ) -> Option { + ) -> Option { let source = (family.scalar_plan_source)(); self.scalar .lock() @@ -106,7 +115,11 @@ impl GenerationPreplans { }) .and_then(|preplanned| preplanned.result.get()) .and_then(|result| result.as_ref().ok()) - .cloned() + .map(|(schedule, planner_cost)| PreplannedSchedule { + key: ScheduleLookupKey::single(key), + schedule: schedule.clone(), + planner_cost: *planner_cost, + }) } /// Copy exact producer results into a completed spec before rendering. @@ -114,10 +127,7 @@ impl GenerationPreplans { spec.preplanned = spec .keys .iter() - .filter_map(|key| { - self.scalar_for_family(family, *key, spec.policy.witness_chunk) - .map(|schedule| (ScheduleLookupKey::single(*key), schedule)) - }) + .filter_map(|key| self.scalar_for_family(family, *key, spec.policy.witness_chunk)) .collect(); } } diff --git a/crates/akita-planner/src/generated_families/tests.rs b/crates/akita-planner/src/generated_families/tests.rs index 4565325a8..4c47f0a90 100644 --- a/crates/akita-planner/src/generated_families/tests.rs +++ b/crates/akita-planner/src/generated_families/tests.rs @@ -41,10 +41,10 @@ fn scalar_preplans_deduplicate_by_exact_producer_and_layout() { .expect("shipped families declare a valid producer contract"); spec.keys = vec![key]; preplans.attach_to_spec(family, &mut spec); - assert_eq!( - spec.preplanned, - vec![(ScheduleLookupKey::single(key), first)] - ); + assert_eq!(spec.preplanned.len(), 1); + assert_eq!(spec.preplanned[0].key, ScheduleLookupKey::single(key)); + assert_eq!(spec.preplanned[0].schedule, first); + assert_ne!(spec.preplanned[0].planner_cost, PlannerCost::default()); } #[test] @@ -90,13 +90,14 @@ fn generation_uses_requested_geometry_for_preplans_and_grouped_producers() { emit_spec_for_family(family, &preplans, chunking, std::path::PathBuf::new()).unwrap(); preplans.attach_to_spec(family, &mut spec); assert!(!spec.preplanned.is_empty()); - for (_, schedule) in &spec.preplanned { - assert_eq!(schedule.root.params.witness_chunk, chunking); + for preplanned in &spec.preplanned { + assert_eq!(preplanned.schedule.root.params.witness_chunk, chunking); } let producer_group = PolynomialGroupLayout::singleton(16); let planned = preplans .scalar_for_family(family, producer_group, chunking) - .unwrap(); + .unwrap() + .schedule; let producer = GroupCommitPhaseParams::try_from_params(producer_group, &planned.root.params).unwrap(); let request = spec diff --git a/crates/akita-planner/src/planner.rs b/crates/akita-planner/src/planner.rs index 32e443d23..3b405905a 100644 --- a/crates/akita-planner/src/planner.rs +++ b/crates/akita-planner/src/planner.rs @@ -22,7 +22,7 @@ use crate::schedule_params::{ derive_ab_commitment_candidate, derive_selected_suffix_schedule, materialize_candidate_schedule, recursive_split_search_domain, select_complete_candidate, AbCommitmentCandidateRequest, CandidateMaterializationCost, PlannerOpeningCandidate, - RingChallengeConfigFn, ScheduleMemo, SuffixCtx, SuffixState, + RingChallengeConfigFn, ScheduleMemo, SetupFieldBudget, SuffixCtx, SuffixState, }; use crate::PlannerPolicy; @@ -829,26 +829,9 @@ pub(crate) fn find_schedule_in_relation_order( )); } let ring_challenge_config: RingChallengeConfigFn<'_> = &ring_challenge_config; - let setup_field_budget = policy.setup_field_budget; let root_input_witness_len = checked::pow2(key.final_group.num_vars()).ok_or_else(|| { AkitaError::InvalidSetup("multi-group root-fold witness length overflow".to_string()) })?; - let suffix_ctx = SuffixCtx { - policy, - challenge_order: policy.transcript_grinding_order()?, - diagnostics, - ring_challenge_config, - key: PolynomialGroupLayout::singleton(key.final_group.num_vars()), - setup_field_budget, - root_lookup_key: Some(key), - root_main_constraint: options.root_main_constraint, - adaptation_guide: options.adaptation_guide, - root_source_contract: Some(final_source_contract), - precommitted_source_contracts, - level_zero_is_root: true, - relation_traversal_order: options.relation_traversal_order, - relation_mode_filter: options.relation_mode_filter, - }; let dimension_ceiling = super::schedule_params::initial_dimension_ceiling(policy)?; let initial_state = SuffixState { input_chunks: None, @@ -862,28 +845,82 @@ pub(crate) fn find_schedule_in_relation_order( relation_phase: super::schedule_params::RingRelationPhase::QuotientPrefix, }, }; - let mut memo = ScheduleMemo::new(); - let suffix_started = diagnostics.map(|_| Instant::now()); #[cfg(test)] let query_prefix_count = options.query_prefix_count; #[cfg(not(test))] let query_prefix_count = 0; - let suffix = derive_selected_suffix_schedule( - &suffix_ctx, - &mut memo, - initial_state, - 0, - super::schedule_params::QuerySearch::Root(query_prefix_count), - ); - if let (Some(diagnostics), Some(started)) = (diagnostics, suffix_started) { - diagnostics.add_suffix_dp_time(started.elapsed()); - let (hits, misses) = memo.setup_prefix_cache_diagnostics(); - diagnostics.record_setup_prefix_cache(hits, misses); - } - let suffix = suffix?; - let best = select_complete_candidate(policy, suffix.setup_candidates(), diagnostics)?; - - let Some(best) = best.cloned() else { + // V6 searches under widening power-of-two setup budgets. Exactness: + // - Admission under a power-of-two budget `B` is padded setup `<= B`, and + // every V6 dominance, bound and level prune keeps a side whose padded + // setup is no larger. A rejected candidate can therefore displace only + // candidates that are also over `B`. Frontiers are unbounded dominance + // sets, split domains are fixed by geometry, and each pass gets a fresh + // memo, so the in-budget part of every frontier is the unbudgeted one. + // - Setup is a maximum over levels, terminal and offloaded children, so + // rejecting a partial schedule never rejects a completion within `B`. + // - A pass that selects `W` returns the unbudgeted winner, which fits + // because its padded setup is at most `W`'s. A pass that rejects + // nothing is the unbudgeted search. Otherwise the next budget is the + // smallest rejected setup, padded; if that overflows, the last pass runs + // without a budget. The starting budget changes only the pass count. + let mut padded_setup_limit = (policy.setup_field_budget.is_none() + && policy.selection_policy + == crate::SelectionPolicyId::MinPaddedSetupEnvelopeThenFirstDirectThenExactProofAndWorkV6) + .then_some(1); + let best = loop { + let pass_budget = padded_setup_limit.or(policy.setup_field_budget); + let setup_field_budget = pass_budget.map(SetupFieldBudget::new); + let suffix_ctx = SuffixCtx { + policy, + challenge_order: policy.transcript_grinding_order()?, + diagnostics, + ring_challenge_config, + key: PolynomialGroupLayout::singleton(key.final_group.num_vars()), + setup_field_budget: setup_field_budget.as_ref(), + root_lookup_key: Some(key), + root_main_constraint: options.root_main_constraint, + adaptation_guide: options.adaptation_guide, + root_source_contract: Some(final_source_contract), + precommitted_source_contracts, + level_zero_is_root: true, + relation_traversal_order: options.relation_traversal_order, + relation_mode_filter: options.relation_mode_filter, + }; + let mut memo = ScheduleMemo::new(); + let suffix_started = diagnostics.map(|_| Instant::now()); + let pass_start = diagnostics.map(|diagnostics| diagnostics.search_work()); + let suffix = derive_selected_suffix_schedule( + &suffix_ctx, + &mut memo, + initial_state, + 0, + super::schedule_params::QuerySearch::Root(query_prefix_count), + ); + if let (Some(diagnostics), Some(started)) = (diagnostics, suffix_started) { + diagnostics.add_suffix_dp_time(started.elapsed()); + let (hits, misses) = memo.setup_prefix_cache_diagnostics(); + diagnostics.record_setup_prefix_cache(hits, misses); + } + let suffix = suffix?; + let best = select_complete_candidate(policy, suffix.setup_candidates(), diagnostics)?; + if let (Some(diagnostics), Some(start)) = (diagnostics, pass_start) { + diagnostics.record_setup_budget_pass(pass_budget, start); + } + // `Some(None)` asks for a final pass without a budget. + let widened = match (best, padded_setup_limit, setup_field_budget.as_ref()) { + (None, Some(limit), Some(budget)) => budget.min_rejected().map(|rejected| { + // This pass's budget is `limit`, so every recorded rejection exceeds it. + debug_assert!(rejected > limit); + rejected.checked_next_power_of_two() + }), + _ => None, + }; + match widened { + Some(next) => padded_setup_limit = next, + None => break best.cloned(), + } + }; + let Some(best) = best else { if key.precommitteds.is_empty() { return Err(AkitaError::UnsupportedSchedule(format!( "no mixed-D schedule in the audited fold domain for num_vars={}, num_polynomials={}", diff --git a/crates/akita-planner/src/schedule_params.rs b/crates/akita-planner/src/schedule_params.rs index 5ee62d13d..6c033c50b 100644 --- a/crates/akita-planner/src/schedule_params.rs +++ b/crates/akita-planner/src/schedule_params.rs @@ -80,8 +80,8 @@ pub(crate) use relation_transition::{ }; pub(crate) use setup_score::{level_setup_field_elements, terminal_setup_field_elements}; pub(crate) use suffix_dp::{ - derive_selected_suffix_schedule, QuerySearch, ScheduleMemo, SuffixCtx, SuffixState, - SuffixTopology, MAX_PRECOMMIT_OPENING_PRODUCTS, + derive_selected_suffix_schedule, QuerySearch, ScheduleMemo, SetupFieldBudget, SuffixCtx, + SuffixState, SuffixTopology, MAX_PRECOMMIT_OPENING_PRODUCTS, }; pub(crate) fn root_inner_basis_source( diff --git a/crates/akita-planner/src/schedule_params/candidate/recursive/tests.rs b/crates/akita-planner/src/schedule_params/candidate/recursive/tests.rs index 89c1f47a5..128afe839 100644 --- a/crates/akita-planner/src/schedule_params/candidate/recursive/tests.rs +++ b/crates/akita-planner/src/schedule_params/candidate/recursive/tests.rs @@ -8,17 +8,27 @@ fn combined_terminal_and_fold_views_match_independent_searches() { use akita_params::InnerCommitSecurityRoute; type Recursive = RecursiveCommitmentConfig; - let policy = policy_of::(akita_params::ChunkedWitnessCfg::default_non_chunked()); + let recursive_policy = + policy_of::(akita_params::ChunkedWitnessCfg::default_non_chunked()); + // The recursive selection rule returns the independent searches as they + // are. Only the direct rule runs the combined search, so test both. + let direct_policy = PlannerPolicy { + selection_policy: crate::SelectionPolicyId::MinFirstDirectSetupThenExactProofAndWorkV5, + ..recursive_policy + }; let opening = PlannerOpeningCandidate::evaluation_trace( Recursive::ring_challenge_config(64).expect("challenge config"), ); let dimensions = CommitmentRingDims::uniform(64); let source = crate::InnerBasisSource::BalancedDigits { log_basis: 4 }; let source_moment = crate::response_model::SourceMomentEstimate::new(1_000_000); - for retain_split_frontier in [false, true] { + for (policy, retain_split_frontier) in [&recursive_policy, &direct_policy] + .into_iter() + .flat_map(|policy| [(policy, false), (policy, true)]) + { let request = RecursiveCandidateRequest { input_chunks: None, - policy: &policy, + policy, payload_mode: akita_params::CommitmentPayloadMode::Compressed, opening, dimensions, diff --git a/crates/akita-planner/src/schedule_params/suffix_dp.rs b/crates/akita-planner/src/schedule_params/suffix_dp.rs index 75e27a228..37cd08f45 100644 --- a/crates/akita-planner/src/schedule_params/suffix_dp.rs +++ b/crates/akita-planner/src/schedule_params/suffix_dp.rs @@ -41,7 +41,7 @@ use frontier::{consider_child_suffixes, price_child_edge, ProjectedFrontier, Pro pub(crate) use search::derive_selected_suffix_schedule; use source::attach_source_moments; use state::*; -pub(crate) use state::{ScheduleMemo, SuffixCtx, SuffixState, SuffixTopology}; +pub(crate) use state::{ScheduleMemo, SetupFieldBudget, SuffixCtx, SuffixState, SuffixTopology}; pub(crate) use terminal::try_terminal_direct_suffix_cost; const SETUP_AND_PAYLOAD_PROJECTIONS: &[Projection] = @@ -87,7 +87,7 @@ struct ChildEdge<'a> { level_setup_field_elements: usize, offloaded: bool, require_child_fold: bool, - setup_field_budget: Option, + setup_field_budget: Option<&'a SetupFieldBudget>, } impl ChildEdge<'_> { @@ -490,7 +490,7 @@ fn child_choice( .max(suffix.setup_field_elements); if edge .setup_field_budget - .is_some_and(|budget| setup_field_elements > budget) + .is_some_and(|budget| !budget.admits(setup_field_elements)) { return Ok(None); } diff --git a/crates/akita-planner/src/schedule_params/suffix_dp/search.rs b/crates/akita-planner/src/schedule_params/suffix_dp/search.rs index a9a3035ee..fbfb02623 100644 --- a/crates/akita-planner/src/schedule_params/suffix_dp/search.rs +++ b/crates/akita-planner/src/schedule_params/suffix_dp/search.rs @@ -293,7 +293,20 @@ pub(super) fn process_candidate_batch( let natural_len = active_setup_field_len(&candidate, opening_layout)?; price_terminal_candidate(ctx, state, query_search, &candidate, natural_len, frontiers)?; } - let candidates_with_source = attach_source_moments(ctx, state, is_root_level, generated.folds)?; + let folds = match ctx.setup_field_budget { + // A fold whose own level setup exceeds the budget fits no schedule. + Some(budget) => { + let mut admitted = Vec::with_capacity(generated.folds.len()); + for candidate in generated.folds { + if budget.admits(level_setup_field_elements(&candidate.params)?) { + admitted.push(candidate); + } + } + admitted + } + None => generated.folds, + }; + let candidates_with_source = attach_source_moments(ctx, state, is_root_level, folds)?; // Complete-root candidates are traversed in exact lower-bound order. // Recursive states do not have that global admission rule and retain // local Pareto pruning. @@ -354,8 +367,11 @@ pub(crate) fn derive_selected_suffix_schedule( diagnostics.record_suffix_call(relation_phase); } let memo_key = state.memo_key(policy); - // Memo entries remain budget-independent objective optima. A restricted - // caller reuses that result when it fits its exact root prefix; otherwise + // Memo entries are objective optima under the context's setup budget, + // independent of any root query-prefix restriction. The setup budget is + // not part of the memo key, so a memo must not outlive its budget; the V6 + // planner builds a fresh memo for every budget pass. A restricted caller + // reuses the cached result when it fits its exact root prefix; otherwise // it recomputes this state locally and never replaces the primary entry. if let QuerySearch::Restricted(prefix) = &query_search { let cached = memo.get(&memo_key).cloned().map_or_else( diff --git a/crates/akita-planner/src/schedule_params/suffix_dp/state.rs b/crates/akita-planner/src/schedule_params/suffix_dp/state.rs index 43a1b4313..0ef845988 100644 --- a/crates/akita-planner/src/schedule_params/suffix_dp/state.rs +++ b/crates/akita-planner/src/schedule_params/suffix_dp/state.rs @@ -330,6 +330,42 @@ pub(super) fn empty_suffix_result() -> Arc { }) } +/// Upper bound on a schedule's total setup field elements for one search. +/// +/// Every rejection records its setup size, so a caller whose search found +/// nothing can learn the smallest size it excluded. +pub(crate) struct SetupFieldBudget { + limit: usize, + min_rejected: std::cell::Cell>, +} + +impl SetupFieldBudget { + pub(crate) const fn new(limit: usize) -> Self { + Self { + limit, + min_rejected: std::cell::Cell::new(None), + } + } + + /// Whether `setup_field_elements` fits, recording it when it does not. + pub(crate) fn admits(&self, setup_field_elements: usize) -> bool { + if setup_field_elements <= self.limit { + return true; + } + self.min_rejected.set(Some( + self.min_rejected + .get() + .map_or(setup_field_elements, |min| min.min(setup_field_elements)), + )); + false + } + + /// Smallest setup size this budget rejected, if any. + pub(crate) fn min_rejected(&self) -> Option { + self.min_rejected.get() + } +} + /// DP-invariant inputs for the suffix search. /// /// Values that remain constant across the whole recursion are carried in one @@ -342,7 +378,7 @@ pub(crate) struct SuffixCtx<'a> { pub(crate) ring_challenge_config: &'a dyn Fn(usize) -> Result, pub(crate) key: PolynomialGroupLayout, - pub(crate) setup_field_budget: Option, + pub(crate) setup_field_budget: Option<&'a SetupFieldBudget>, pub(crate) root_lookup_key: Option<&'a ScheduleLookupKey>, /// Optional exact main-group root selected by an earlier scalar plan. /// diff --git a/crates/akita-planner/src/test/adaptive_search.rs b/crates/akita-planner/src/test/adaptive_search.rs index 7d1f8ff5b..125695ef4 100644 --- a/crates/akita-planner/src/test/adaptive_search.rs +++ b/crates/akita-planner/src/test/adaptive_search.rs @@ -3,6 +3,9 @@ use super::*; mod relation_cutover; #[path = "adaptive_search/selective_l2.rs"] mod selective_l2; +#[cfg(feature = "catalog-gen")] +#[path = "adaptive_search/setup_budget.rs"] +mod setup_budget; fn onehot_group(num_vars: usize, num_polynomials: usize) -> PolynomialGroupLayout { PolynomialGroupLayout::new(num_vars, num_polynomials) } diff --git a/crates/akita-planner/src/test/adaptive_search/setup_budget.rs b/crates/akita-planner/src/test/adaptive_search/setup_budget.rs new file mode 100644 index 000000000..f11dbd45c --- /dev/null +++ b/crates/akita-planner/src/test/adaptive_search/setup_budget.rs @@ -0,0 +1,167 @@ +use super::*; +use crate::diagnostics::{capture, PlannerDiagnosticsSnapshot}; + +fn plan_with_diagnostics( + key: PolynomialGroupLayout, + policy: &PlannerPolicy, +) -> (PlannedFoldSchedule, PlannerDiagnosticsSnapshot) { + let (planned, snapshot) = capture(true, || { + find_schedule( + key, + policy, + C::committed_source_contract().unwrap(), + C::ring_challenge_config, + ) + }); + ( + planned.expect("setup search"), + snapshot.expect("enabled capture"), + ) +} + +/// Plan `key` with widening setup budgets and with one pass whose explicit +/// budget never binds, and require the same schedule. +fn assert_budgeted_search_matches_unbounded_pass( + policy: PlannerPolicy, + key: PolynomialGroupLayout, + label: &str, +) { + assert_eq!(policy.setup_field_budget, None, "{label}"); + assert_eq!( + policy.selection_policy, + crate::SelectionPolicyId::MinPaddedSetupEnvelopeThenFirstDirectThenExactProofAndWorkV6, + "{label}" + ); + let mut unbounded = policy; + unbounded.setup_field_budget = Some(usize::MAX); + let (budgeted, budgeted_passes) = plan_with_diagnostics::(key, &policy); + let (single, single_passes) = plan_with_diagnostics::(key, &unbounded); + + assert_eq!( + budgeted.schedule.canonical_descriptor_bytes(), + single.schedule.canonical_descriptor_bytes(), + "{label}" + ); + assert_eq!(budgeted.estimate, single.estimate, "{label}"); + + let budgets = budgeted_passes + .setup_budget_passes + .iter() + .map(|pass| pass.budget) + .collect::>(); + let padded_setup = + akita_params::padded_setup_prefix_len(budgeted.estimate.estimated_num_setup_field_elements); + let (last, earlier) = budgets.split_last().expect("at least one pass"); + assert_eq!(*last, Some(padded_setup), "{label}: {budgets:?}"); + assert!( + earlier.windows(2).all(|pair| pair[0] + .zip(pair[1]) + .is_some_and(|(left, right)| left < right)), + "{label}: {budgets:?}" + ); + assert!( + earlier + .iter() + .all(|budget| budget.is_some_and(|budget| budget < padded_setup)), + "{label}: {budgets:?}" + ); + assert_eq!( + single_passes + .setup_budget_passes + .iter() + .map(|pass| pass.budget) + .collect::>(), + [Some(usize::MAX)], + "{label}" + ); +} + +#[test] +fn padded_setup_budget_passes_match_one_unbounded_pass() { + use akita_config::{policy_of, proof_optimized::fp128::OneHot, RecursiveCommitmentConfig}; + + type Recursive = RecursiveCommitmentConfig; + + // Restricted domains from the unpruned-oracle fixtures. The oracle itself + // does not model setup-prefix offloading, so it cannot rank V6 schedules. + let oracle_domains = [ + RingDimensionSearchDomain::new([ + CommitmentRingDims::uniform(64), + CommitmentRingDims { + inner: 128, + outer: 64, + opening: 64, + }, + ]) + .unwrap(), + RingDimensionSearchDomain::new([ + CommitmentRingDims::uniform(64), + CommitmentRingDims { + inner: 128, + outer: 64, + opening: 64, + }, + CommitmentRingDims { + inner: 128, + outer: 128, + opening: 64, + }, + ]) + .unwrap(), + RingDimensionSearchDomain::new([ + CommitmentRingDims::uniform(64), + CommitmentRingDims { + inner: 128, + outer: 64, + opening: 64, + }, + CommitmentRingDims { + inner: 128, + outer: 64, + opening: 128, + }, + ]) + .unwrap(), + ]; + for (domain_index, domain) in oracle_domains.iter().enumerate() { + let mut policy = policy_for_domain( + policy_of::(akita_params::ChunkedWitnessCfg::default_non_chunked()), + domain, + ); + policy.selective_l2_response_model = crate::SelectiveL2ResponseModelId::Disabled; + for num_vars in [14, 16, 18] { + assert_budgeted_search_matches_unbounded_pass::( + policy, + onehot_group(num_vars, 1), + &format!("one-hot domain {domain_index} nv={num_vars}"), + ); + } + } +} + +// The drift job already checks every V6 catalog row against artifacts that the +// unbudgeted search produced. Only the unbounded comparison passes are slow. +#[test] +#[ignore = "plans three catalog rows without a setup budget; takes about 100 s"] +fn padded_setup_budget_matches_one_unbounded_pass_on_catalog_rows() { + use akita_config::{ + policy_of, + proof_optimized::{fp128, fp32}, + RecursiveCommitmentConfig, + }; + + type Fp32Dense = RecursiveCommitmentConfig; + type Fp128Dense = RecursiveCommitmentConfig; + for num_vars in [20, 22] { + assert_budgeted_search_matches_unbounded_pass::( + policy_of::(akita_params::ChunkedWitnessCfg::default_non_chunked()), + PolynomialGroupLayout::singleton(num_vars), + &format!("fp32_dense_recursive nv={num_vars}"), + ); + } + assert_budgeted_search_matches_unbounded_pass::( + policy_of::(akita_params::ChunkedWitnessCfg::default_non_chunked()), + PolynomialGroupLayout::singleton(20), + "fp128_dense_recursive nv=20", + ); +}