From 90f03837d64212d99a09ccccb2b5581b2d3bb861 Mon Sep 17 00:00:00 2001 From: tiammomo <26957354+tiammomo@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:48:51 +0800 Subject: [PATCH 1/3] perf(desktop): measure local workflows and record scale baselines Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> --- CONTRIBUTING.md | 2 + docs/benchmarks/2026-09-06-desktop.json | 444 +++++++++++++ docs/benchmarks/2026-09-06-ui.json | 661 +++++++++++++++++++ docs/desktop-performance-baseline.md | 91 +++ docs/desktop-performance.md | 75 +++ web/package.json | 3 +- web/scripts/benchmark-ui.mjs | 66 ++ web/src-tauri/src/local_engine.rs | 2 + web/src-tauri/src/local_engine/benchmarks.rs | 354 ++++++++++ 9 files changed, 1697 insertions(+), 1 deletion(-) create mode 100644 docs/benchmarks/2026-09-06-desktop.json create mode 100644 docs/benchmarks/2026-09-06-ui.json create mode 100644 docs/desktop-performance-baseline.md create mode 100644 docs/desktop-performance.md create mode 100644 web/scripts/benchmark-ui.mjs create mode 100644 web/src-tauri/src/local_engine/benchmarks.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 99d24eb0..266ce6c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,8 @@ cargo fmt --all --manifest-path src-tauri/Cargo.toml -- --check cargo test --manifest-path src-tauri/Cargo.toml --locked ``` +For optional scale measurements, see the [desktop benchmark guide](docs/desktop-performance.md) and its recorded baseline. + ## Pull request workflow 1. Create a feature/bugfix branch from `dev`. diff --git a/docs/benchmarks/2026-09-06-desktop.json b/docs/benchmarks/2026-09-06-desktop.json new file mode 100644 index 00000000..4002f0b3 --- /dev/null +++ b/docs/benchmarks/2026-09-06-desktop.json @@ -0,0 +1,444 @@ +{ + "arch": "x86_64", + "available_parallelism": 4, + "corpora": [ + { + "active_worktrees_after_cleanup": 0, + "documents": 100, + "git_bytes": 137658, + "metrics": { + "auto_save": { + "max": 32.912805000000006, + "min": 22.85576, + "p50": 28.58102, + "p95": 32.912805000000006, + "samples": 5, + "unit": "ms", + "values": [22.85576, 24.158731999999997, 28.58102, 32.893417, 32.912805000000006] + }, + "background_create": { + "max": 23.971419, + "min": 9.496606, + "p50": 11.195882, + "p95": 23.971419, + "samples": 5, + "unit": "ms", + "values": [9.496606, 10.26851, 11.195882, 11.650806, 23.971419] + }, + "background_discard": { + "max": 9.040188, + "min": 5.339269, + "p50": 5.74626, + "p95": 9.040188, + "samples": 5, + "unit": "ms", + "values": [5.339269, 5.425268, 5.74626, 7.546901, 9.040188] + }, + "background_merge": { + "max": 7.843815, + "min": 5.4492270000000005, + "p50": 6.015154, + "p95": 7.843815, + "samples": 5, + "unit": "ms", + "values": [5.4492270000000005, 6.0076089999999995, 6.015154, 6.4677620000000005, 7.843815] + }, + "checkpoint": { + "max": 4.038369, + "min": 2.314286, + "p50": 2.626687, + "p95": 4.038369, + "samples": 5, + "unit": "ms", + "values": [2.314286, 2.601995, 2.626687, 2.9713119999999997, 4.038369] + }, + "delete": { + "max": 28.676138, + "min": 22.797037, + "p50": 25.343565, + "p95": 28.676138, + "samples": 5, + "unit": "ms", + "values": [22.797037, 22.947369, 25.343565, 27.160565, 28.676138] + }, + "file_import": { + "max": 18.548091999999997, + "min": 12.688096, + "p50": 15.15129, + "p95": 18.548091999999997, + "samples": 5, + "unit": "ms", + "values": [12.688096, 13.691553, 15.15129, 15.522791, 18.548091999999997] + }, + "history": { + "max": 6.109494, + "min": 3.056365, + "p50": 3.734643, + "p95": 6.109494, + "samples": 5, + "unit": "ms", + "values": [3.056365, 3.285426, 3.734643, 3.816646, 6.109494] + }, + "rename": { + "max": 35.172453, + "min": 22.572611, + "p50": 25.261127, + "p95": 35.172453, + "samples": 5, + "unit": "ms", + "values": [22.572611, 24.520528, 25.261127, 28.277377, 35.172453] + }, + "review_agent_changes": { + "max": 8.788871, + "min": 4.333993, + "p50": 6.547852, + "p95": 8.788871, + "samples": 5, + "unit": "ms", + "values": [4.333993, 5.093357, 6.547852, 8.102326, 8.788871] + }, + "review_working_diff": { + "max": 47.291140000000006, + "min": 15.035556, + "p50": 17.557192, + "p95": 47.291140000000006, + "samples": 5, + "unit": "ms", + "values": [15.035556, 15.124655, 17.557192, 19.142808, 47.291140000000006] + }, + "search_first_use": { + "max": 1.948477, + "min": 1.948477, + "p50": 1.948477, + "p95": 1.948477, + "samples": 1, + "unit": "ms", + "values": [1.948477] + }, + "search_warm": { + "max": 0.5316919999999999, + "min": 0.238524, + "p50": 0.27595099999999995, + "p95": 0.425255, + "samples": 20, + "unit": "ms", + "values": [0.238524, 0.24072, 0.24167200000000005, 0.247865, 0.24791, 0.259863, 0.263751, 0.266804, 0.27444999999999997, 0.27595099999999995, 0.27829200000000004, 0.281239, 0.293596, 0.30567099999999997, 0.311146, 0.317741, 0.329741, 0.349048, 0.425255, 0.5316919999999999] + }, + "startup_process_cold": { + "max": 27.81418, + "min": 27.81418, + "p50": 27.81418, + "p95": 27.81418, + "samples": 1, + "unit": "ms", + "values": [27.81418] + }, + "startup_warm": { + "max": 1.2319449999999998, + "min": 0.963251, + "p50": 1.081937, + "p95": 1.2319449999999998, + "samples": 5, + "unit": "ms", + "values": [0.963251, 1.049502, 1.081937, 1.090759, 1.2319449999999998] + } + }, + "peak_rss_bytes": 16547840, + "sqlite_bytes": 548864 + }, + { + "active_worktrees_after_cleanup": 0, + "documents": 1000, + "git_bytes": 538341, + "metrics": { + "auto_save": { + "max": 209.780865, + "min": 135.55478200000002, + "p50": 145.531094, + "p95": 209.780865, + "samples": 5, + "unit": "ms", + "values": [135.55478200000002, 142.77242, 145.531094, 159.517237, 209.780865] + }, + "background_create": { + "max": 90.433183, + "min": 77.30857400000001, + "p50": 80.930455, + "p95": 90.433183, + "samples": 5, + "unit": "ms", + "values": [77.30857400000001, 77.751991, 80.930455, 86.43418, 90.433183] + }, + "background_discard": { + "max": 27.965551, + "min": 19.182767, + "p50": 21.304611, + "p95": 27.965551, + "samples": 5, + "unit": "ms", + "values": [19.182767, 20.242185, 21.304611, 21.775594, 27.965551] + }, + "background_merge": { + "max": 36.939882, + "min": 31.065953, + "p50": 33.96978, + "p95": 36.939882, + "samples": 5, + "unit": "ms", + "values": [31.065953, 33.208989, 33.96978, 36.575883, 36.939882] + }, + "checkpoint": { + "max": 7.069894000000001, + "min": 5.836798, + "p50": 6.823492, + "p95": 7.069894000000001, + "samples": 5, + "unit": "ms", + "values": [5.836798, 6.389372, 6.823492, 7.057901, 7.069894000000001] + }, + "delete": { + "max": 190.157287, + "min": 142.691235, + "p50": 153.01690000000002, + "p95": 190.157287, + "samples": 5, + "unit": "ms", + "values": [142.691235, 147.04210700000002, 153.01690000000002, 181.369588, 190.157287] + }, + "file_import": { + "max": 33.259414, + "min": 27.208961, + "p50": 28.761754, + "p95": 33.259414, + "samples": 5, + "unit": "ms", + "values": [27.208961, 28.286855, 28.761754, 31.576036, 33.259414] + }, + "history": { + "max": 35.593575, + "min": 31.872939, + "p50": 33.248357, + "p95": 35.593575, + "samples": 5, + "unit": "ms", + "values": [31.872939, 31.907385000000005, 33.248357, 34.244181999999995, 35.593575] + }, + "rename": { + "max": 235.816129, + "min": 141.067965, + "p50": 149.667475, + "p95": 235.816129, + "samples": 5, + "unit": "ms", + "values": [141.067965, 149.651906, 149.667475, 171.501518, 235.816129] + }, + "review_agent_changes": { + "max": 21.661424, + "min": 7.518117999999999, + "p50": 16.300607000000003, + "p95": 21.661424, + "samples": 5, + "unit": "ms", + "values": [7.518117999999999, 10.558609, 16.300607000000003, 17.400307, 21.661424] + }, + "review_working_diff": { + "max": 147.773038, + "min": 103.683586, + "p50": 125.920101, + "p95": 147.773038, + "samples": 5, + "unit": "ms", + "values": [103.683586, 116.23692, 125.920101, 132.046604, 147.773038] + }, + "search_first_use": { + "max": 13.588782, + "min": 13.588782, + "p50": 13.588782, + "p95": 13.588782, + "samples": 1, + "unit": "ms", + "values": [13.588782] + }, + "search_warm": { + "max": 5.156510000000001, + "min": 3.606055, + "p50": 4.053037, + "p95": 4.509934, + "samples": 20, + "unit": "ms", + "values": [3.606055, 3.662495, 3.709207, 3.763521, 3.965687, 3.983422000000001, 3.999058, 4.01361, 4.016242, 4.053037, 4.064786, 4.072776999999999, 4.121113, 4.13358, 4.174866, 4.23356, 4.307606000000001, 4.32134, 4.509934, 5.156510000000001] + }, + "startup_process_cold": { + "max": 36.463259, + "min": 36.463259, + "p50": 36.463259, + "p95": 36.463259, + "samples": 1, + "unit": "ms", + "values": [36.463259] + }, + "startup_warm": { + "max": 9.546224, + "min": 8.301605, + "p50": 9.136169, + "p95": 9.546224, + "samples": 5, + "unit": "ms", + "values": [8.301605, 8.875964999999999, 9.136169, 9.215702, 9.546224] + } + }, + "peak_rss_bytes": 18841600, + "sqlite_bytes": 4935680 + }, + { + "active_worktrees_after_cleanup": 0, + "documents": 10000, + "git_bytes": 4538753, + "metrics": { + "auto_save": { + "max": 2150.924858, + "min": 1613.315271, + "p50": 1700.661675, + "p95": 2150.924858, + "samples": 5, + "unit": "ms", + "values": [1613.315271, 1668.414207, 1700.661675, 1778.267508, 2150.924858] + }, + "background_create": { + "max": 968.027968, + "min": 648.799178, + "p50": 693.193654, + "p95": 968.027968, + "samples": 5, + "unit": "ms", + "values": [648.799178, 664.010175, 693.193654, 807.754878, 968.027968] + }, + "background_discard": { + "max": 209.053506, + "min": 159.190384, + "p50": 162.78662699999998, + "p95": 209.053506, + "samples": 5, + "unit": "ms", + "values": [159.190384, 159.200506, 162.78662699999998, 208.99257, 209.053506] + }, + "background_merge": { + "max": 450.864493, + "min": 273.18435200000005, + "p50": 285.30042, + "p95": 450.864493, + "samples": 5, + "unit": "ms", + "values": [273.18435200000005, 283.327313, 285.30042, 361.577469, 450.864493] + }, + "checkpoint": { + "max": 50.767157, + "min": 37.997134, + "p50": 42.287022, + "p95": 50.767157, + "samples": 5, + "unit": "ms", + "values": [37.997134, 39.629986, 42.287022, 42.735074999999995, 50.767157] + }, + "delete": { + "max": 2255.8247699999997, + "min": 1577.2504729999998, + "p50": 1665.54853, + "p95": 2255.8247699999997, + "samples": 5, + "unit": "ms", + "values": [1577.2504729999998, 1658.8066609999998, 1665.54853, 1744.455179, 2255.8247699999997] + }, + "file_import": { + "max": 199.304661, + "min": 145.812497, + "p50": 155.020016, + "p95": 199.304661, + "samples": 5, + "unit": "ms", + "values": [145.812497, 146.736326, 155.020016, 158.59409599999998, 199.304661] + }, + "history": { + "max": 367.265338, + "min": 292.39822399999997, + "p50": 298.138285, + "p95": 367.265338, + "samples": 5, + "unit": "ms", + "values": [292.39822399999997, 294.955578, 298.138285, 362.814829, 367.265338] + }, + "rename": { + "max": 2153.964824, + "min": 1527.665287, + "p50": 1676.625077, + "p95": 2153.964824, + "samples": 5, + "unit": "ms", + "values": [1527.665287, 1530.882071, 1676.625077, 1701.66527, 2153.964824] + }, + "review_agent_changes": { + "max": 127.612118, + "min": 47.315134, + "p50": 77.66756199999999, + "p95": 127.612118, + "samples": 5, + "unit": "ms", + "values": [47.315134, 65.601791, 77.66756199999999, 120.565049, 127.612118] + }, + "review_working_diff": { + "max": 1190.619779, + "min": 915.310957, + "p50": 933.469091, + "p95": 1190.619779, + "samples": 5, + "unit": "ms", + "values": [915.310957, 927.232286, 933.469091, 1009.033175, 1190.619779] + }, + "search_first_use": { + "max": 133.489294, + "min": 133.489294, + "p50": 133.489294, + "p95": 133.489294, + "samples": 1, + "unit": "ms", + "values": [133.489294] + }, + "search_warm": { + "max": 44.581028, + "min": 36.089955, + "p50": 38.638278, + "p95": 41.498808999999994, + "samples": 20, + "unit": "ms", + "values": [36.089955, 36.274332, 36.472761, 37.208246, 37.953993999999994, 38.129746, 38.17015, 38.205928, 38.31880700000001, 38.638278, 39.193608999999995, 39.46358, 39.465126000000005, 39.650551, 40.039421, 40.678754999999995, 40.687043, 41.328696, 41.498808999999994, 44.581028] + }, + "startup_process_cold": { + "max": 105.477114, + "min": 105.477114, + "p50": 105.477114, + "p95": 105.477114, + "samples": 1, + "unit": "ms", + "values": [105.477114] + }, + "startup_warm": { + "max": 98.754254, + "min": 78.148499, + "p50": 89.005158, + "p95": 98.754254, + "samples": 5, + "unit": "ms", + "values": [78.148499, 80.44166799999999, 89.005158, 95.456648, 98.754254] + } + }, + "peak_rss_bytes": 23445504, + "sqlite_bytes": 45088768 + } + ], + "fixture_version": 1, + "latency_percentile": "nearest-rank", + "os": "linux", + "profile": "release", + "schema_version": 1, + "startup_definition": "Fresh worker process with an existing healthy index; OS filesystem caches are not evicted." +} diff --git a/docs/benchmarks/2026-09-06-ui.json b/docs/benchmarks/2026-09-06-ui.json new file mode 100644 index 00000000..bfbf30ea --- /dev/null +++ b/docs/benchmarks/2026-09-06-ui.json @@ -0,0 +1,661 @@ +{ + "schema_version": 1, + "node": "v24.20.0", + "os": "linux", + "arch": "x64", + "scope": "Node timings of production navigation helpers and PageReader SSR; excludes browser layout, paint, IPC and PTY.", + "corpora": [ + { + "documents": 100, + "visible_page_tree": { + "unit": "ms", + "samples": 30, + "p50": 1.2945060000001831, + "p95": 2.40788799999973, + "values": [0.9590780000003178, 0.9783229999998184, 0.9825449999998455, 0.9850420000002487, 0.9850489999998899, 0.9925159999997959, 1.0072730000001684, 1.059744000000137, 1.0834569999997257, 1.0891960000003564, 1.0893779999996696, 1.1404389999997875, 1.1601720000003297, 1.244995999999901, 1.2945060000001831, 1.3556010000002061, 1.4553679999999076, 1.5260480000001735, 1.6137570000000778, 1.619162000000415, 1.6856680000000779, 1.7428979999999683, 1.7593509999996968, 1.7955179999999018, 1.843623000000207, 1.9703389999999672, 2.044507000000067, 2.259397000000263, 2.40788799999973, 2.4627439999999297] + }, + "find_last_concept": { + "unit": "ms", + "samples": 30, + "p50": 0.030632999999852473, + "p95": 0.08526599999959217, + "values": [0.020623999999770604, 0.02091500000005908, 0.020993999999973312, 0.021123000000443426, 0.021240000000034343, 0.021454000000176165, 0.02153199999975186, 0.022474000000329397, 0.022714000000178203, 0.02354299999979048, 0.02976300000000265, 0.030005999999957567, 0.030213999999887164, 0.030248000000028696, 0.030632999999852473, 0.030755999999655614, 0.030826000000161002, 0.03089799999997922, 0.030925999999908527, 0.03146400000014182, 0.03209699999979421, 0.03266499999972439, 0.03368799999998373, 0.034575000000131695, 0.036371000000144704, 0.043184000000110245, 0.05547400000023117, 0.05566799999996874, 0.08526599999959217, 0.10712199999989025] + }, + "collect_submit_paths": { + "unit": "ms", + "samples": 30, + "p50": 0.014822999999978492, + "p95": 0.054275999999845226, + "values": [0.013306000000284257, 0.013320999999905325, 0.013376999999763939, 0.013452000000143016, 0.01353800000015326, 0.01372399999991103, 0.013762999999926251, 0.013830999999754567, 0.01390399999991132, 0.013908000000355969, 0.013995000000250002, 0.014008000000103493, 0.014060999999855994, 0.014216000000033091, 0.014822999999978492, 0.014842000000044209, 0.014869000000089727, 0.015375000000403816, 0.01548400000001493, 0.016073999999662192, 0.018920999999863852, 0.019682000000102562, 0.02095499999995809, 0.022754000000077212, 0.0247309999999743, 0.03477900000007139, 0.03772900000012669, 0.04286099999990256, 0.054275999999845226, 0.06298699999979362] + } + }, + { + "documents": 1000, + "visible_page_tree": { + "unit": "ms", + "samples": 30, + "p50": 10.33755999999994, + "p95": 16.85234599999967, + "values": [8.475954999999885, 8.64229400000022, 8.670937000000322, 8.781400000000303, 8.793337999999949, 8.835066999999981, 9.051530999999613, 9.162671000000046, 9.184399000000212, 9.256610000000364, 9.681068000000323, 9.894089999999778, 9.995331999999962, 10.293255000000045, 10.33755999999994, 11.059925000000021, 11.110729999999876, 11.58248999999978, 11.778992000000017, 11.951602999999977, 12.059415000000172, 12.250968999999714, 12.423789999999826, 12.547689999999875, 12.77214099999992, 12.916372999999567, 14.670346999999765, 15.78086600000006, 16.85234599999967, 21.208341999999902] + }, + "find_last_concept": { + "unit": "ms", + "samples": 30, + "p50": 0.26228100000025734, + "p95": 0.4433880000001409, + "values": [0.22420499999998356, 0.2301690000003873, 0.23270400000001246, 0.23365899999998874, 0.2384279999996579, 0.2392810000001191, 0.24126200000000608, 0.2418999999999869, 0.24322200000005978, 0.24376499999971202, 0.2472819999998137, 0.2493690000001152, 0.25724499999978434, 0.2576860000003762, 0.26228100000025734, 0.26236500000004526, 0.26334399999996094, 0.26733999999987645, 0.2685260000002927, 0.295006000000285, 0.30606600000010076, 0.32759400000031746, 0.33676299999979165, 0.3390599999997903, 0.3480139999996936, 0.4054369999998926, 0.42964500000016415, 0.4327800000000934, 0.4433880000001409, 0.4732920000001286] + }, + "collect_submit_paths": { + "unit": "ms", + "samples": 30, + "p50": 0.1328840000001037, + "p95": 0.1833560000000034, + "values": [0.12745799999993324, 0.12810399999989386, 0.12854299999980867, 0.12856700000020282, 0.12876999999980399, 0.12946599999986574, 0.12965600000006816, 0.12966800000003786, 0.12970499999983076, 0.12972799999988638, 0.12981799999988652, 0.12995300000011412, 0.13122800000019197, 0.13168300000006639, 0.1328840000001037, 0.13304799999968964, 0.13412799999969138, 0.13447100000030332, 0.13485800000034942, 0.13612499999999272, 0.13795699999991484, 0.14180100000021412, 0.14182499999969878, 0.14292299999988245, 0.1491150000001653, 0.14964000000009037, 0.15196400000013455, 0.16067999999995664, 0.1833560000000034, 0.25430099999994127] + } + }, + { + "documents": 10000, + "visible_page_tree": { + "unit": "ms", + "samples": 30, + "p50": 87.73508800000036, + "p95": 125.97928699999966, + "values": [67.6036180000001, 73.23534899999959, 74.03272399999969, 75.8307190000005, 77.63896300000033, 78.1404849999999, 78.48281600000064, 79.45369000000028, 79.56089499999962, 79.82971999999972, 81.24727500000017, 82.94599499999958, 85.61483500000031, 86.48148500000025, 87.73508800000036, 88.52581499999997, 95.68507999999974, 95.7615679999999, 96.58304399999997, 99.81734000000006, 99.97524300000077, 101.86725500000011, 102.52687699999933, 109.62596500000018, 111.07494399999996, 114.13790999999947, 120.37043799999992, 121.25427200000013, 125.97928699999966, 131.24138500000026] + }, + "find_last_concept": { + "unit": "ms", + "samples": 30, + "p50": 2.4382070000001477, + "p95": 5.246979000000465, + "values": [2.194234999999935, 2.2276389999997264, 2.248640000000705, 2.264879000000292, 2.2714330000007976, 2.284045999999762, 2.289557000000059, 2.2913870000002134, 2.320056999999906, 2.3821930000003704, 2.4124769999998534, 2.4210339999999633, 2.4220550000000003, 2.4359519999998156, 2.4382070000001477, 2.463713999999527, 2.502230999999483, 2.5331149999992704, 2.5336409999999887, 2.5731660000001284, 2.6789319999998042, 2.708458000000064, 2.725140000000465, 2.7656440000000657, 2.9923509999998714, 3.5130740000004153, 3.891827000000376, 4.74527500000022, 5.246979000000465, 23.578292000000147] + }, + "collect_submit_paths": { + "unit": "ms", + "samples": 30, + "p50": 1.3483679999999367, + "p95": 2.547595000000001, + "values": [1.10057500000039, 1.1016959999997198, 1.1039559999999256, 1.1102660000005926, 1.122352000000319, 1.129468999999517, 1.1301240000002508, 1.15362499999992, 1.1617420000002312, 1.1768709999996645, 1.1991990000005899, 1.2208160000000134, 1.221853999999439, 1.2799220000006244, 1.3483679999999367, 1.3805019999999786, 1.3977489999997488, 1.4553289999994377, 1.4580340000002252, 1.4640119999994567, 1.5155639999993582, 1.8198510000001988, 1.9392250000000786, 2.213966000000255, 2.2367670000003272, 2.26385399999981, 2.2889769999992495, 2.3629339999997683, 2.547595000000001, 25.00680900000043] + } + } + ], + "page_reader_ssr": { + "unit": "ms", + "samples": 30, + "p50": 3.273863999999776, + "p95": 6.348842999999761, + "values": [2.2212549999994735, 2.261996000000181, 2.3238120000005438, 2.3525530000006256, 2.6443410000001677, 2.666946999999709, 2.688114000000496, 2.6929139999992913, 2.911240999999791, 2.9708739999996396, 2.9985080000005837, 3.029504000000088, 3.157094000000143, 3.2199940000000424, 3.273863999999776, 3.2806000000000495, 3.3681210000004285, 3.593022000000019, 3.8038040000001274, 4.142472999999882, 4.275391000000127, 4.345597999999882, 4.407538999999815, 4.41541799999959, 4.8184110000001965, 4.853743000000577, 5.259814000000006, 5.643398000000161, 6.348842999999761, 7.072392000000036] + }, + "assets": [ + { + "filename": "apl-BK70zBuJ.js", + "bytes": 2292, + "gzip_bytes": 1223 + }, + { + "filename": "asciiarmor-B8ArWv07.js", + "bytes": 792, + "gzip_bytes": 405 + }, + { + "filename": "asn1-CAMdHIDN.js", + "bytes": 3956, + "gzip_bytes": 1947 + }, + { + "filename": "asterisk-8UjU3f4R.js", + "bytes": 4079, + "gzip_bytes": 1740 + }, + { + "filename": "brainfuck-BGmmBqQz.js", + "bytes": 599, + "gzip_bytes": 319 + }, + { + "filename": "clike-M0zcv0lv.js", + "bytes": 22061, + "gzip_bytes": 7718 + }, + { + "filename": "clojure-CgVgnCSF.js", + "bytes": 9406, + "gzip_bytes": 4005 + }, + { + "filename": "cmake-C3B-i0Qa.js", + "bytes": 780, + "gzip_bytes": 464 + }, + { + "filename": "cobol-D3zIkywS.js", + "bytes": 6223, + "gzip_bytes": 3010 + }, + { + "filename": "coffeescript-DPQSX5_4.js", + "bytes": 3855, + "gzip_bytes": 1689 + }, + { + "filename": "commonlisp-Cu7Yvs6h.js", + "bytes": 2320, + "gzip_bytes": 1099 + }, + { + "filename": "crystal-BenaA7mf.js", + "bytes": 5020, + "gzip_bytes": 2101 + }, + { + "filename": "css-h8jJsBXg.js", + "bytes": 24708, + "gzip_bytes": 8216 + }, + { + "filename": "cypher-CuLxR47A.js", + "bytes": 3182, + "gzip_bytes": 1484 + }, + { + "filename": "d-1LjQ9qIz.js", + "bytes": 3673, + "gzip_bytes": 1666 + }, + { + "filename": "diff-BF5J_cjP.js", + "bytes": 302, + "gzip_bytes": 234 + }, + { + "filename": "dist-B0uitb6C.js", + "bytes": 22895, + "gzip_bytes": 10300 + }, + { + "filename": "dist-BqrTXdBZ.js", + "bytes": 4835, + "gzip_bytes": 2298 + }, + { + "filename": "dist-BrqAVpbu.js", + "bytes": 40678, + "gzip_bytes": 16717 + }, + { + "filename": "dist-C0Dh_dc0.js", + "bytes": 20640, + "gzip_bytes": 9202 + }, + { + "filename": "dist-CKO3kV2q.js", + "bytes": 103868, + "gzip_bytes": 34341 + }, + { + "filename": "dist-COkIQfMo.js", + "bytes": 16166, + "gzip_bytes": 7379 + }, + { + "filename": "dist-Ck5aeKWK.js", + "bytes": 14306, + "gzip_bytes": 5756 + }, + { + "filename": "dist-Cq1UVsce.js", + "bytes": 70760, + "gzip_bytes": 25819 + }, + { + "filename": "dist-Cth09xUT.js", + "bytes": 30658, + "gzip_bytes": 12574 + }, + { + "filename": "dist-DDYR91i5.js", + "bytes": 11526, + "gzip_bytes": 5133 + }, + { + "filename": "dist-DGUl_Bf0.js", + "bytes": 2578, + "gzip_bytes": 1478 + }, + { + "filename": "dist-DUJEjbUZ.js", + "bytes": 20894, + "gzip_bytes": 9578 + }, + { + "filename": "dist-DlXi8xqE.js", + "bytes": 97676, + "gzip_bytes": 28371 + }, + { + "filename": "dist-DoHjVMpi.js", + "bytes": 44529, + "gzip_bytes": 19021 + }, + { + "filename": "dist-DpzW78C_.js", + "bytes": 1960, + "gzip_bytes": 1245 + }, + { + "filename": "dist-uoXZnuXL.js", + "bytes": 3229, + "gzip_bytes": 1653 + }, + { + "filename": "dist-wZbJxaIu.js", + "bytes": 34091, + "gzip_bytes": 12936 + }, + { + "filename": "dockerfile-Vy7vyh8a.js", + "bytes": 1906, + "gzip_bytes": 653 + }, + { + "filename": "dtd-Bcs7v-1r.js", + "bytes": 2092, + "gzip_bytes": 869 + }, + { + "filename": "dylan-BbH88FUZ.js", + "bytes": 4041, + "gzip_bytes": 1628 + }, + { + "filename": "ebnf-BfkPfK0C.js", + "bytes": 1979, + "gzip_bytes": 798 + }, + { + "filename": "ecl-DchwyjoF.js", + "bytes": 5092, + "gzip_bytes": 2297 + }, + { + "filename": "eiffel-BsWmahAQ.js", + "bytes": 1692, + "gzip_bytes": 908 + }, + { + "filename": "elm-Cqhinqwl.js", + "bytes": 1866, + "gzip_bytes": 811 + }, + { + "filename": "erlang-BkJgmTOG.js", + "bytes": 7846, + "gzip_bytes": 2893 + }, + { + "filename": "factor-CXKaotZ6.js", + "bytes": 1669, + "gzip_bytes": 608 + }, + { + "filename": "fcl-wdaYc9AF.js", + "bytes": 2029, + "gzip_bytes": 946 + }, + { + "filename": "forth-Ba00b93U.js", + "bytes": 2541, + "gzip_bytes": 1319 + }, + { + "filename": "fortran-Bp5oIPoJ.js", + "bytes": 3862, + "gzip_bytes": 1926 + }, + { + "filename": "gas-CbghHik_.js", + "bytes": 4550, + "gzip_bytes": 1267 + }, + { + "filename": "gherkin-CZLFtNJE.js", + "bytes": 10156, + "gzip_bytes": 5086 + }, + { + "filename": "groovy-DsiF8HW_.js", + "bytes": 4103, + "gzip_bytes": 1744 + }, + { + "filename": "haskell-Ctb2yUqo.js", + "bytes": 4159, + "gzip_bytes": 1888 + }, + { + "filename": "haxe-DI5RJco4.js", + "bytes": 7862, + "gzip_bytes": 2922 + }, + { + "filename": "http-Cih68tzy.js", + "bytes": 845, + "gzip_bytes": 424 + }, + { + "filename": "idl-D1ThVMra.js", + "bytes": 9826, + "gzip_bytes": 4378 + }, + { + "filename": "index-B-R_Pn38.js", + "bytes": 1833281, + "gzip_bytes": 551244 + }, + { + "filename": "index-rlg0uNDu.css", + "bytes": 85714, + "gzip_bytes": 15237 + }, + { + "filename": "javascript-CioCT25R.js", + "bytes": 17020, + "gzip_bytes": 5708 + }, + { + "filename": "julia-qO0NmqGa.js", + "bytes": 5263, + "gzip_bytes": 2163 + }, + { + "filename": "livescript-BGVsv9fV.js", + "bytes": 4081, + "gzip_bytes": 1624 + }, + { + "filename": "lua-DvomzuT3.js", + "bytes": 3130, + "gzip_bytes": 1397 + }, + { + "filename": "mathematica-B5nwbDO_.js", + "bytes": 1899, + "gzip_bytes": 806 + }, + { + "filename": "mbox-DquXlnRS.js", + "bytes": 1386, + "gzip_bytes": 650 + }, + { + "filename": "mirc-usPhfujx.js", + "bytes": 5917, + "gzip_bytes": 2683 + }, + { + "filename": "mllike-DbWzw58i.js", + "bytes": 4823, + "gzip_bytes": 1562 + }, + { + "filename": "modelica-DFNnN39Q.js", + "bytes": 2795, + "gzip_bytes": 1285 + }, + { + "filename": "mscgen-CxA8c-5E.js", + "bytes": 3499, + "gzip_bytes": 973 + }, + { + "filename": "mumps-JylzLIF3.js", + "bytes": 1836, + "gzip_bytes": 955 + }, + { + "filename": "nginx-BJpTWvL6.js", + "bytes": 7409, + "gzip_bytes": 2716 + }, + { + "filename": "nsis-qiyRqcQu.js", + "bytes": 6804, + "gzip_bytes": 2982 + }, + { + "filename": "ntriples-8P-OuE-u.js", + "bytes": 2077, + "gzip_bytes": 734 + }, + { + "filename": "octave-Bndxeq-f.js", + "bytes": 2101, + "gzip_bytes": 1077 + }, + { + "filename": "oz-Df-PJoMD.js", + "bytes": 2881, + "gzip_bytes": 1269 + }, + { + "filename": "pascal-H2xGndwr.js", + "bytes": 2256, + "gzip_bytes": 1174 + }, + { + "filename": "perl-CuAPF61Q.js", + "bytes": 9750, + "gzip_bytes": 3454 + }, + { + "filename": "pig-DuQV-RJ4.js", + "bytes": 2513, + "gzip_bytes": 1373 + }, + { + "filename": "powershell-Bdq-8fxe.js", + "bytes": 7699, + "gzip_bytes": 3283 + }, + { + "filename": "properties-BdJ5Xito.js", + "bytes": 664, + "gzip_bytes": 342 + }, + { + "filename": "protobuf-BB5BJbg_.js", + "bytes": 802, + "gzip_bytes": 507 + }, + { + "filename": "pug-42FZfq4N.js", + "bytes": 6670, + "gzip_bytes": 1962 + }, + { + "filename": "puppet-ClPTL2mw.js", + "bytes": 2540, + "gzip_bytes": 1203 + }, + { + "filename": "python-DcGlaVlT.js", + "bytes": 6284, + "gzip_bytes": 2673 + }, + { + "filename": "q-CgomPxgZ.js", + "bytes": 3691, + "gzip_bytes": 1691 + }, + { + "filename": "r-CEXwld9S.js", + "bytes": 3008, + "gzip_bytes": 1342 + }, + { + "filename": "rpm-DLgjfhgY.js", + "bytes": 1597, + "gzip_bytes": 818 + }, + { + "filename": "ruby-C2IgO1Iq.js", + "bytes": 5104, + "gzip_bytes": 2151 + }, + { + "filename": "sas-RV7lAMho.js", + "bytes": 9331, + "gzip_bytes": 4105 + }, + { + "filename": "scheme-zrufBkLR.js", + "bytes": 6363, + "gzip_bytes": 2382 + }, + { + "filename": "shell-CPGri-V8.js", + "bytes": 2449, + "gzip_bytes": 1203 + }, + { + "filename": "sieve-T3jVzViK.js", + "bytes": 1615, + "gzip_bytes": 770 + }, + { + "filename": "simple-mode-CfPVNBQ1.js", + "bytes": 2295, + "gzip_bytes": 1094 + }, + { + "filename": "smalltalk-unbFF26X.js", + "bytes": 1996, + "gzip_bytes": 847 + }, + { + "filename": "solr-D6k9TkTz.js", + "bytes": 863, + "gzip_bytes": 453 + }, + { + "filename": "sparql-BUzpNdU0.js", + "bytes": 3331, + "gzip_bytes": 1594 + }, + { + "filename": "spreadsheet-DuaAaHPX.js", + "bytes": 1135, + "gzip_bytes": 533 + }, + { + "filename": "sql-BlRI686q.js", + "bytes": 36858, + "gzip_bytes": 10812 + }, + { + "filename": "stex-B4GWcMtu.js", + "bytes": 3121, + "gzip_bytes": 1179 + }, + { + "filename": "stylus-mH8AqIG9.js", + "bytes": 23526, + "gzip_bytes": 8510 + }, + { + "filename": "swift-D-UovBCd.js", + "bytes": 3762, + "gzip_bytes": 1814 + }, + { + "filename": "tcl-BVwA_Cc5.js", + "bytes": 2353, + "gzip_bytes": 1215 + }, + { + "filename": "textile-42GyO5L8.js", + "bytes": 6772, + "gzip_bytes": 2441 + }, + { + "filename": "tiddlywiki-CFKt_eg0.js", + "bytes": 2760, + "gzip_bytes": 1071 + }, + { + "filename": "tiki-DVIbrCSt.js", + "bytes": 3235, + "gzip_bytes": 1261 + }, + { + "filename": "toml-CrE7307U.js", + "bytes": 1190, + "gzip_bytes": 539 + }, + { + "filename": "troff-egxjbO92.js", + "bytes": 957, + "gzip_bytes": 435 + }, + { + "filename": "ttcn-Bif4PbYY.js", + "bytes": 4824, + "gzip_bytes": 2133 + }, + { + "filename": "ttcn-cfg-HF6YYF9m.js", + "bytes": 4078, + "gzip_bytes": 1723 + }, + { + "filename": "turtle-rIxNyn7P.js", + "bytes": 1957, + "gzip_bytes": 882 + }, + { + "filename": "vb-BKri_QdP.js", + "bytes": 3650, + "gzip_bytes": 1807 + }, + { + "filename": "vbscript-942Vq-oZ.js", + "bytes": 5431, + "gzip_bytes": 2547 + }, + { + "filename": "velocity-CtiYJhpF.js", + "bytes": 2703, + "gzip_bytes": 1110 + }, + { + "filename": "verilog-DS5Aimhi.js", + "bytes": 8435, + "gzip_bytes": 3520 + }, + { + "filename": "vhdl-_Fr0R5ju.js", + "bytes": 3312, + "gzip_bytes": 1494 + }, + { + "filename": "webidl-CHu7Ju6Y.js", + "bytes": 2449, + "gzip_bytes": 1247 + }, + { + "filename": "xquery-DsnvKsij.js", + "bytes": 6225, + "gzip_bytes": 2561 + }, + { + "filename": "yacas-D0JtPqFj.js", + "bytes": 2141, + "gzip_bytes": 1084 + }, + { + "filename": "z80-xc9iG3nA.js", + "bytes": 1755, + "gzip_bytes": 792 + } + ], + "max_rss_bytes": 1123123200 +} diff --git a/docs/desktop-performance-baseline.md b/docs/desktop-performance-baseline.md new file mode 100644 index 00000000..d1a50812 --- /dev/null +++ b/docs/desktop-performance-baseline.md @@ -0,0 +1,91 @@ +# Desktop baseline — 2026-09-06 + +Measurements of `dev` engine behavior at `694c0a03d7a1ce4cd79f79c2247254efcb053ad7` +with the fixture-v1 harness in this PR. No production optimization is included. +[Run instructions and definitions](desktop-performance.md), +[all engine samples](benchmarks/2026-09-06-desktop.json), and +[all UI samples/assets](benchmarks/2026-09-06-ui.json) accompany this report. + +## Environment and limits + +Linux x86_64, kernel `6.6.87.2-microsoft-standard-WSL2`, Intel Core i7-12700H; +a hardened Docker container limited to four CPU cores and 8 GiB RAM. Rust/Cargo +1.98.0, Node 24.20.0, release Rust profile, production Vite build. Disposable +Spaces use the disk-backed verifier environment, not tmpfs. The host is shared, +so these are diagnostic samples, not a controlled hardware performance guarantee. + +Each corpus uses one fresh worker process; its index already exists. OS caches +are not flushed. Search has 20 warm samples, mutations/warm opens have five, and +UI helpers have 30. Values below are **p50 / p95 in milliseconds**. One-sample +rows show the same observation twice; five-sample p95 is the maximum. Fake Agent +edits exercise real worktrees without model, network or terminal latency. + +## Local engine + +| Operation | 100 documents | 1,000 documents | 10,000 documents | +|---|---:|---:|---:| +| Process-cold engine open (one sample) | 27.81 / 27.81 | 36.46 / 36.46 | 105.48 / 105.48 | +| Warm engine open | 1.08 / 1.23 | 9.14 / 9.55 | 89.01 / 98.75 | +| First-use search (one sample) | 1.95 / 1.95 | 13.59 / 13.59 | 133.49 / 133.49 | +| Warm search | 0.28 / 0.43 | 4.05 / 4.51 | 38.64 / 41.50 | +| Auto Save | 28.58 / 32.91 | 145.53 / 209.78 | 1700.66 / 2150.92 | +| Rename | 25.26 / 35.17 | 149.67 / 235.82 | 1676.63 / 2153.96 | +| Delete | 25.34 / 28.68 | 153.02 / 190.16 | 1665.55 / 2255.82 | +| Import text file | 15.15 / 18.55 | 28.76 / 33.26 | 155.02 / 199.30 | +| Working Review/diff | 17.56 / 47.29 | 125.92 / 147.77 | 933.47 / 1190.62 | +| Agent Review list/diff | 6.55 / 8.79 | 16.30 / 21.66 | 77.67 / 127.61 | +| Create Background Change | 11.20 / 23.97 | 80.93 / 90.43 | 693.19 / 968.03 | +| Merge Background Change | 6.02 / 7.84 | 33.97 / 36.94 | 285.30 / 450.86 | +| Discard Background Change | 5.75 / 9.04 | 21.30 / 27.97 | 162.79 / 209.05 | +| Create checkpoint | 2.63 / 4.04 | 6.82 / 7.07 | 42.29 / 50.77 | +| Open History | 3.73 / 6.11 | 33.25 / 35.59 | 298.14 / 367.27 | + +| Resource after workflow | 100 documents | 1,000 documents | 10,000 documents | +|---|---:|---:|---:| +| Worker peak RSS (MiB) | 15.78 | 17.97 | 22.36 | +| SQLite + WAL + SHM (MiB) | 0.52 | 4.71 | 43.00 | +| Git repository (MiB) | 0.13 | 0.51 | 4.33 | +| Remaining managed worktrees | 0 | 0 | 0 | + +RSS covers the engine worker, not the Tauri WebView or desktop application. +Imports and Git history accumulate over the five workflow iterations. Search and +startup samples precede those mutations. + +## Frontend diagnostics + +| Operation | 100 pages | 1,000 pages | 10,000 pages | +|---|---:|---:|---:| +| Normalize/filter/sort navigation | 1.29 / 2.41 | 10.34 / 16.85 | 87.74 / 125.98 | +| Find final page | 0.03 / 0.09 | 0.26 / 0.44 | 2.44 / 5.25 | +| Collect Submit paths | 0.01 / 0.05 | 0.13 / 0.18 | 1.35 / 2.55 | + +PageReader SSR of the 20-paragraph fixture: 3.27 / 6.35 ms. +The largest JS asset is `index-B-R_Pn38.js`: 1,833,281 bytes raw, +551,244 bytes gzip. Node peak RSS (1071.09 MiB) includes the +Vite SSR loader and asset processing; it is not a renderer-memory estimate. +These timings exclude browser layout/paint, scrolling, IPC and window startup. + +## Priorities supported by this baseline + +1. Profile the Auto Save/rename/delete call paths first. Their 10,000-document + medians are around 1.7 seconds; working Review/diff is around 0.93 seconds. + Attribute time to repository preparation, filesystem scans and Git operations + before choosing caching or changing lock scope. This single-Space sequential + workload does not establish cross-Space lock contention. +2. Profile navigation normalization/sorting and its call frequency in MainLayout. + At 10,000 pages the helper alone costs about 88 ms median. Measure an actual + browser trace before deciding how much memoization or virtualization is needed. +3. Investigate route/panel chunking using the approximately 1.8 MB entry asset and + a browser startup trace. File size is evidence of payload cost, not measured + loading time or frame rate. +4. Keep startup/search work proportional to the evidence: lazy index startup is + already implemented by #129. The current healthy-index engine open is about + 105 ms process-cold and warm search about 39 ms at 10,000 documents. Measure + missing-index repair separately before another indexing change. +5. All managed worktrees were cleaned in this successful workflow. Failure-path + cleanup, concurrent editing and long-lived memory still need dedicated runs; + zero leftovers here does not prove those cases. + +Repeat with more samples on the intended macOS desktop before setting budgets or +claiming an improvement. Preserve Markdown/Git authority, stale-write checks and +review isolation in any follow-up optimization. diff --git a/docs/desktop-performance.md b/docs/desktop-performance.md new file mode 100644 index 00000000..045452b2 --- /dev/null +++ b/docs/desktop-performance.md @@ -0,0 +1,75 @@ +# Desktop performance measurements + +This benchmark measures the current local engine rather than the retired server +compiler. It changes no production locking, indexing or Git behavior. Startup +already uses lazy index reconciliation (PR #129). + +## Run + +Use the same Node/Rust toolchain and Tauri platform dependencies as normal +contribution checks. The Rust suite contains a small offline workflow smoke test; +large measurements are opt-in and should run on an otherwise idle machine. + +```sh +cd web +npm ci +npm run build +npm run bench:ui +cargo test --release --locked --manifest-path src-tauri/Cargo.toml local_engine::benchmarks::desktop_benchmark -- --ignored --exact --nocapture +``` + +To save machine-readable reports, set `COWIKI_BENCH_OUTPUT` for Rust and +`COWIKI_BENCH_UI_OUTPUT` for the UI command. Parent directories must already exist. +Use temporary paths; do not commit private Spaces or machine-local runtime files. + +Defaults are 100, 1,000 and 10,000 generated documents, 20 search samples and five +mutation samples. For a quick check, `COWIKI_BENCH_SIZES=100` limits the corpus. +`COWIKI_BENCH_SEARCH_SAMPLES`, `COWIKI_BENCH_MUTATION_SAMPLES` and +`COWIKI_BENCH_UI_SAMPLES` adjust repetitions within documented code bounds. +Do not compare debug and release measurements. + +## What is measured + +Each corpus consists of deterministic Markdown pages with frontmatter, eight +paragraphs and a cross-page link, arranged in groups of 100. Fixture creation, +initial import/indexing and the initial Git commit happen before timing. Each +corpus then runs in a fresh child process against its own temporary Space. + +- Process-cold startup: the first `LocalEngine::open` in that child, using an + existing healthy index. This excludes process spawn and Tauri window startup. +- Warm startup: subsequent engine opens in the same process. +- First-use and warm search through the public local search API. +- Auto Save with the same mutation lock and stale-content check as desktop IPC. +- Rename, delete and actual file import. +- Working diffs, Agent Review lists, History and checkpoint creation. +- Background Change creation, merge and discard, using deterministic file edits + in a real managed Git worktree as a fake Agent. +- Process peak RSS, SQLite database/WAL/SHM bytes, Git bytes, and leftover managed + worktrees after successful cleanup. + +Mutation samples perform a realistic sequential workflow: imports and history +accumulate. Assertions verify that saves do not create commits, merged content +appears in the Draft, discarded content does not, and worktrees are cleaned. +Missing results or broken operations fail the command instead of producing a +misleading timing report. No model, PTY or Cloud call is made. + +OS filesystem caches are **not** evicted. “Process cold” does not mean disk cold. +RSS is the worker's lifetime high-water mark, not per-operation allocation. +Percentiles use nearest rank, and reports retain raw samples. With five mutation +samples, p95 is the slowest sample; use more repetitions before a performance claim. + +The UI benchmark measures real navigation helpers on flat 100/1,000/10,000-page +lists, PageReader server rendering for a representative page, and raw/gzip sizes +of production JS/CSS assets. It is a diagnostic baseline: browser layout/paint, +interactive list scrolling, startup window latency and IPC require a separate +browser/desktop profiler recording. SSR numbers do not predict frame rate. + +## Comparing results + +Record the source commit, Rust/Node versions, OS/architecture, CPU and memory +limits, build profile, fixture version and sample counts. Run before and after on +the same machine and compare distributions, not a single favorable sample. Keep +SQLite derived, preserve Git/Markdown authority, and retain stale-write/conflict +checks when an optimization is justified by measurements. + +See the [recorded baseline](desktop-performance-baseline.md) for measured results and priorities. diff --git a/web/package.json b/web/package.json index 854ab453..ac87e721 100644 --- a/web/package.json +++ b/web/package.json @@ -34,7 +34,8 @@ "test:cloud-space-dialog": "node --experimental-strip-types --test tests/cloud-space-dialog.test.ts", "tauri": "tauri", "desktop:dev": "tauri dev", - "desktop:build": "tauri build" + "desktop:build": "tauri build", + "bench:ui": "node scripts/benchmark-ui.mjs" }, "dependencies": { "@codemirror/autocomplete": "^6.20.3", diff --git a/web/scripts/benchmark-ui.mjs b/web/scripts/benchmark-ui.mjs new file mode 100644 index 00000000..80138a4f --- /dev/null +++ b/web/scripts/benchmark-ui.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; +import { gzipSync } from 'node:zlib'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { createServer } from 'vite'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const samples = Number(process.env.COWIKI_BENCH_UI_SAMPLES ?? 30); +assert(Number.isInteger(samples) && samples > 0 && samples <= 1000, 'samples must be 1–1000'); +const vite = await createServer({ root, appType: 'custom', logLevel: 'silent', server: { middlewareMode: true, watch: null } }); + +function measure(action) { + action(); // Warm module and JIT initialization outside the samples. + const values = []; + for (let index = 0; index < samples; index++) { + const start = performance.now(); + action(); + values.push(performance.now() - start); + } + values.sort((left, right) => left - right); + return { unit: 'ms', samples, p50: values[Math.ceil(samples * 0.50) - 1], p95: values[Math.ceil(samples * 0.95) - 1], values }; +} + +try { + const { visiblePageTree, findConcept, submitConceptPaths } = await vite.ssrLoadModule('/src/lib/okf-pages.ts'); + const { PageReader } = await vite.ssrLoadModule('/src/components/PageReader.tsx'); + const corpora = [100, 1000, 10000].map((documents) => { + const pages = Array.from({ length: documents }, (_, index) => ({ + slug: `topic-${index}`, path: `topic-${index}.md`, title: `Topic ${index}`, + summary: 'Local Markdown and Git knowledge', kind: 'page', branch: 'main', children: [], + })); + const last = `topic-${documents - 1}`; + const tree = visiblePageTree(pages); + assert.equal(tree.length, documents); + assert(findConcept(tree, last)); + assert.equal(submitConceptPaths(tree).length, documents); + return { + documents, + visible_page_tree: measure(() => visiblePageTree(pages)), + find_last_concept: measure(() => findConcept(tree, last)), + collect_submit_paths: measure(() => submitConceptPaths(tree)), + }; + }); + const body = '# Benchmark page\n\n' + 'Local Markdown keeps knowledge portable. [Related](topic-1.md)\n\n'.repeat(20); + const pageRender = measure(() => renderToStaticMarkup(React.createElement(PageReader, { body }))); + const assets = []; + for (const filename of (await readdir(new URL('../dist/assets/', import.meta.url))).sort()) { + if (!/\.(js|css)$/.test(filename)) continue; + const content = await readFile(new URL(`../dist/assets/${filename}`, import.meta.url)); + assets.push({ filename, bytes: content.length, gzip_bytes: gzipSync(content).length }); + } + const report = { + schema_version: 1, node: process.version, os: process.platform, arch: process.arch, + scope: 'Node timings of production navigation helpers and PageReader SSR; excludes browser layout, paint, IPC and PTY.', + corpora, page_reader_ssr: pageRender, assets, + max_rss_bytes: process.resourceUsage().maxRSS * 1024, + }; + const text = JSON.stringify(report, null, 2); + if (process.env.COWIKI_BENCH_UI_OUTPUT) await writeFile(process.env.COWIKI_BENCH_UI_OUTPUT, text); + console.log(text); +} finally { + await vite.close(); +} diff --git a/web/src-tauri/src/local_engine.rs b/web/src-tauri/src/local_engine.rs index febbeeb8..fefeeca7 100644 --- a/web/src-tauri/src/local_engine.rs +++ b/web/src-tauri/src/local_engine.rs @@ -17,6 +17,8 @@ use crate::knowledge_index::{self, SearchHit}; use crate::okf::{self, DocumentKind}; mod agent_changes; +#[cfg(test)] +mod benchmarks; pub use crate::knowledge_index::BrokenLink; pub use agent_changes::AgentChange; diff --git a/web/src-tauri/src/local_engine/benchmarks.rs b/web/src-tauri/src/local_engine/benchmarks.rs new file mode 100644 index 00000000..15ddacb7 --- /dev/null +++ b/web/src-tauri/src/local_engine/benchmarks.rs @@ -0,0 +1,354 @@ +//! Opt-in measurement of real local-engine operations on disposable Spaces. +//! A fresh child process measures each prepared corpus; fixture creation is excluded. +use super::*; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::time::Instant; + +const RESULT_PREFIX: &str = "COWIKI_BENCH_RESULT="; +const SLUG: &str = "benchmark"; + +fn document_path(index: usize) -> String { + format!("topics/group-{:03}/page-{index:05}.md", index / 100) +} + +fn document(index: usize, count: usize) -> String { + let next = (index + 1) % count; + let mut text = format!( + "---\ntitle: Topic {index:05}\ntype: Concept\nsummary: Deterministic local search fixture\n---\n\n# Topic {index:05}\n\n" + ); + for section in 0..8 { + text.push_str(&format!( + "## Section {section}\n\nKnowledge topic{index:05} concerns local Markdown ownership, Git reviews, incremental search, and offline collaboration. This paragraph is deterministic evidence for the desktop benchmark.\n\n" + )); + } + text.push_str(&format!( + "[Related topic](../group-{:03}/page-{next:05}.md)\n", + next / 100 + )); + text +} + +fn fixture(root: &Path, count: usize) { + let folder = root.join("space"); + for index in 0..count { + let file = folder.join(document_path(index)); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(file, document(index, count)).unwrap(); + } + let engine = LocalEngine::open(&root.join("metadata")).unwrap(); + engine.add_space("Benchmark", SLUG, &folder).unwrap(); + engine.submit(SLUG, &[]).unwrap(); + assert!(!engine.has_uncommitted_changes(SLUG).unwrap()); + std::fs::write( + root.join("import.txt"), + "A deterministic imported source for the benchmark.", + ) + .unwrap(); +} + +fn elapsed(action: impl FnOnce() -> T) -> (T, f64) { + let start = Instant::now(); + let result = action(); + (result, start.elapsed().as_secs_f64() * 1000.0) +} + +fn percentile(sorted: &[f64], quantile: f64) -> f64 { + sorted[((sorted.len() as f64 * quantile).ceil() as usize).saturating_sub(1)] +} + +fn distribution(mut samples: Vec) -> Value { + samples.sort_by(f64::total_cmp); + json!({ + "unit": "ms", "samples": samples.len(), "min": samples[0], + "p50": percentile(&samples, 0.50), "p95": percentile(&samples, 0.95), + "max": samples[samples.len() - 1], "values": samples, + }) +} + +fn record( + metrics: &mut BTreeMap<&'static str, Vec>, + name: &'static str, + action: impl FnOnce() -> T, +) -> T { + let (result, duration) = elapsed(action); + metrics.entry(name).or_default().push(duration); + result +} + +fn tree_bytes(root: &Path) -> u64 { + walkdir::WalkDir::new(root) + .into_iter() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_file()) + .filter_map(|entry| entry.metadata().ok()) + .map(|metadata| metadata.len()) + .sum() +} + +fn peak_rss_bytes() -> Option { + #[cfg(unix)] + { + let mut usage = std::mem::MaybeUninit::::uninit(); + // SAFETY: getrusage initializes the supplied rusage on success. + if unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) } == 0 { + let value = unsafe { usage.assume_init() }.ru_maxrss as u64; + return Some(if cfg!(target_os = "macos") { + value + } else { + value * 1024 + }); + } + } + None +} + +fn measure(root: &Path, count: usize, search_samples: usize, mutation_samples: usize) -> Value { + let metadata = root.join("metadata"); + let folder = root.join("space"); + let mut metrics = BTreeMap::new(); + let engine = record(&mut metrics, "startup_process_cold", || { + LocalEngine::open(&metadata).unwrap() + }); + assert_eq!(engine.list_spaces().unwrap().len(), 1); + for _ in 0..mutation_samples { + let reopened = record(&mut metrics, "startup_warm", || { + LocalEngine::open(&metadata).unwrap() + }); + drop(reopened); + } + let hits = record(&mut metrics, "search_first_use", || { + engine.search(SLUG, "topic00000", 20).unwrap() + }); + assert!(!hits.keyword.is_empty()); + for index in 0..search_samples { + let query = format!("topic{:05}", (index * 37) % count); + let response = record(&mut metrics, "search_warm", || { + engine.search(SLUG, &query, 20).unwrap() + }); + assert!( + !response.keyword.is_empty(), + "fixture query must produce a result" + ); + } + for iteration in 0..mutation_samples { + let path = document_path(iteration % count); + let original = std::fs::read_to_string(folder.join(&path)).unwrap(); + let edited = format!("{original}\nBenchmark edit {iteration}.\n"); + let commits = engine.commit_count(SLUG).unwrap(); + record(&mut metrics, "auto_save", || { + let _guard = engine.lock_mutations().unwrap(); + engine + .write_page_checked(SLUG, &path, &edited, Some(&original), false) + .unwrap(); + }); + assert_eq!( + engine.commit_count(SLUG).unwrap(), + commits, + "Auto Save must not commit" + ); + assert_eq!(std::fs::read_to_string(folder.join(&path)).unwrap(), edited); + let diffs = record(&mut metrics, "review_working_diff", || { + engine.working_diff(SLUG).unwrap() + }); + assert!(diffs.iter().any(|diff| diff.path == path)); + record(&mut metrics, "checkpoint", || { + engine + .create_checkpoint(SLUG, Some("Benchmark checkpoint")) + .unwrap() + }); + record(&mut metrics, "history", || engine.history(SLUG).unwrap()); + engine.submit(SLUG, &[]).unwrap(); + + let renamed = format!("topics/renamed-{iteration}.md"); + record(&mut metrics, "rename", || { + engine.rename_path(SLUG, &path, &renamed).unwrap() + }); + assert!(folder.join(&renamed).is_file()); + record(&mut metrics, "delete", || { + engine.delete_path(SLUG, &renamed).unwrap() + }); + assert!(!folder.join(&renamed).exists()); + // Restore the same corpus before the next measured operation. + engine.write_page(SLUG, &path, &original).unwrap(); + engine.submit(SLUG, &[]).unwrap(); + + let input = root.join(format!("import-{iteration}.txt")); + std::fs::write( + &input, + format!("Unique source iteration {iteration}: local offline import."), + ) + .unwrap(); + let outcomes = record(&mut metrics, "file_import", || { + engine + .ingest_files(SLUG, &[input.to_string_lossy().into_owned()]) + .unwrap() + }); + assert!(outcomes[0].source.is_some()); + assert!(outcomes[0].error.is_none()); + engine.submit(SLUG, &[]).unwrap(); + + let change = record(&mut metrics, "background_create", || { + engine + .create_agent_change(SLUG, "Benchmark fake Agent") + .unwrap() + }); + let background = format!("{original}\nBackground edit {iteration}.\n"); + std::fs::write(change.worktree_path.join(&path), &background).unwrap(); + let changes = record(&mut metrics, "review_agent_changes", || { + engine.list_agent_changes(SLUG).unwrap() + }); + let reviewed = changes.iter().find(|item| item.id == change.id).unwrap(); + assert!(!reviewed.diffs.is_empty()); + record(&mut metrics, "background_merge", || { + engine.merge_agent_change(SLUG, &change.id).unwrap() + }); + assert_eq!( + std::fs::read_to_string(folder.join(&path)).unwrap(), + background + ); + assert!( + !change.worktree_path.exists(), + "merged worktree must be cleaned" + ); + engine.submit(SLUG, &[]).unwrap(); + + let discarded = engine + .create_agent_change(SLUG, "Discarded fake Agent") + .unwrap(); + std::fs::write(discarded.worktree_path.join(&path), "Discard this change").unwrap(); + record(&mut metrics, "background_discard", || { + engine.discard_agent_change(SLUG, &discarded.id).unwrap() + }); + assert_eq!( + std::fs::read_to_string(folder.join(&path)).unwrap(), + background + ); + assert!( + !discarded.worktree_path.exists(), + "discarded worktree must be cleaned" + ); + } + let repository = Repository::open(&folder).unwrap(); + let active_worktrees = repository.worktrees().unwrap().len(); + assert_eq!(active_worktrees, 0); + let metrics = metrics + .into_iter() + .map(|(name, samples)| (name, distribution(samples))) + .collect::>(); + let sqlite_bytes = ["local.db", "local.db-wal", "local.db-shm"] + .iter() + .filter_map(|name| std::fs::metadata(metadata.join(name)).ok()) + .map(|file| file.len()) + .sum::(); + json!({ + "documents": count, "metrics": metrics, + "peak_rss_bytes": peak_rss_bytes(), + "sqlite_bytes": sqlite_bytes, + "git_bytes": tree_bytes(&folder.join(".git")), + "active_worktrees_after_cleanup": active_worktrees, + }) +} + +fn bounded_setting(name: &str, default: usize, max: usize) -> usize { + let value = std::env::var(name) + .map(|value| { + value + .parse::() + .expect("benchmark setting must be an integer") + }) + .unwrap_or(default); + assert!( + (1..=max).contains(&value), + "{name} must be between 1 and {max}" + ); + value +} + +#[test] +#[ignore = "run through desktop_benchmark; this is the fresh-process worker"] +fn benchmark_worker() { + let root = PathBuf::from( + std::env::var("COWIKI_BENCH_WORKER_ROOT").expect("run desktop_benchmark instead"), + ); + let count = bounded_setting("COWIKI_BENCH_DOCUMENTS", 100, 10_000); + let searches = bounded_setting("COWIKI_BENCH_SEARCH_SAMPLES", 20, 1_000); + let mutations = bounded_setting("COWIKI_BENCH_MUTATION_SAMPLES", 5, 100); + println!( + "{RESULT_PREFIX}{}", + measure(&root, count, searches, mutations) + ); +} + +#[test] +#[ignore = "opt-in benchmark; creates disposable 100/1,000/10,000-document Spaces"] +fn desktop_benchmark() { + let sizes = + std::env::var("COWIKI_BENCH_SIZES").unwrap_or_else(|_| "100,1000,10000".to_string()); + let sizes = sizes + .split(',') + .map(|value| value.trim().parse::().expect("invalid corpus size")) + .collect::>(); + assert!(!sizes.is_empty() && sizes.len() <= 3); + assert!(sizes.iter().all(|value| (1..=10_000).contains(value))); + let mut results = Vec::new(); + for count in sizes { + eprintln!("Preparing {count} documents (excluded from timings)..."); + let root = tempfile::tempdir().unwrap(); + fixture(root.path(), count); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--ignored", + "--exact", + "local_engine::benchmarks::benchmark_worker", + "--nocapture", + ]) + .env("COWIKI_BENCH_WORKER_ROOT", root.path()) + .env("COWIKI_BENCH_DOCUMENTS", count.to_string()) + .output() + .unwrap(); + assert!( + output.status.success(), + "worker failed: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let result = stdout + .lines() + .find_map(|line| line.strip_prefix(RESULT_PREFIX)) + .expect("worker did not return measurements"); + results.push(serde_json::from_str::(result).unwrap()); + eprintln!("Measured {count} documents."); + } + let report = json!({ + "schema_version": 1, "fixture_version": 1, + "os": std::env::consts::OS, "arch": std::env::consts::ARCH, + "profile": if cfg!(debug_assertions) { "debug" } else { "release" }, + "available_parallelism": std::thread::available_parallelism().map(|n| n.get()).ok(), + "startup_definition": "Fresh worker process with an existing healthy index; OS filesystem caches are not evicted.", + "latency_percentile": "nearest-rank", "corpora": results, + }); + let text = serde_json::to_string_pretty(&report).unwrap(); + if let Ok(path) = std::env::var("COWIKI_BENCH_OUTPUT") { + std::fs::write(path, &text).unwrap(); + } + println!("{text}"); +} + +#[test] +fn benchmark_smoke_exercises_the_complete_offline_workflow() { + let root = tempfile::tempdir().unwrap(); + fixture(root.path(), 3); + let report = measure(root.path(), 3, 2, 1); + assert_eq!(report["active_worktrees_after_cleanup"], 0); + assert_eq!(report["metrics"]["search_warm"]["samples"], 2); + assert!(report["sqlite_bytes"].as_u64().unwrap() > 0); +} + +#[test] +fn benchmark_percentiles_use_nearest_rank_without_interpolating_tail_samples() { + let report = distribution(vec![20.0, 1.0, 4.0, 2.0]); + assert_eq!(report["p50"], 2.0); + assert_eq!(report["p95"], 20.0); +} From d268a431d7b31a35abae24278c3e2b8a6983e614 Mon Sep 17 00:00:00 2001 From: tiammomo <26957354+tiammomo@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:06:03 +0800 Subject: [PATCH 2/3] fix(perf): embed source and toolchain provenance in reports Address review comment 3994914618 on #152. Refs #20. Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> --- docs/desktop-performance.md | 11 +++ web/package.json | 5 +- web/scripts/benchmark-provenance.mjs | 22 ++++++ web/scripts/benchmark-ui.mjs | 8 ++- web/src-tauri/src/local_engine/benchmarks.rs | 71 +++++++++++++++++--- web/tests/benchmark-provenance.test.mjs | 29 ++++++++ 6 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 web/scripts/benchmark-provenance.mjs create mode 100644 web/tests/benchmark-provenance.test.mjs diff --git a/docs/desktop-performance.md b/docs/desktop-performance.md index 045452b2..fddbf0c7 100644 --- a/docs/desktop-performance.md +++ b/docs/desktop-performance.md @@ -28,6 +28,17 @@ mutation samples. For a quick check, `COWIKI_BENCH_SIZES=100` limits the corpus. `COWIKI_BENCH_UI_SAMPLES` adjust repetitions within documented code bounds. Do not compare debug and release measurements. +New reports use schema version 2. Both engine and UI JSON embed `source.commit`, +`source.dirty`, and `toolchain` entries for Rust, Cargo and Node. They record the +effective corpus sizes and sample counts in `settings`; the UI also records its +warmup/rendering settings. These values are captured before measurement. Missing +Git/toolchain information is `null`, never substituted with a guessed revision. +Run through the documented Cargo/npm commands in the checkout being measured, +and rebuild `dist` before comparing UI asset sizes. A dirty checkout needs its +patch preserved alongside the report; its commit alone does not identify it. +The 2026-09-06 schema-v1 samples are retained as historical evidence and have not +been relabeled with a newer checkout or toolchain. + ## What is measured Each corpus consists of deterministic Markdown pages with frontmatter, eight diff --git a/web/package.json b/web/package.json index 7c6c9bbc..338af4de 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "test": "npm run test:auth-flow && npm run test:versioned-document && npm run test:local-space && npm run test:sidebar-layout && npm run test:client-settings && npm run test:design-system && npm run test:page-frontmatter && npm run test:agent-terminal && npm run test:agent-contract && npm run test:okf-pages && npm run test:workspace-navigation && npm run test:window-chrome && npm run test:page-header && npm run test:source-ingest && npm run test:desktop-workflow && npm run test:history && npm run test:link-diagnostics && npm run test:cloud-sync && npm run test:cloud-client && npm run test:cloud-shell && npm run test:cloud-review-model && npm run test:review-preview && npm run test:skill-hosting && npm run test:cloud-space-dialog", + "test": "npm run test:auth-flow && npm run test:versioned-document && npm run test:local-space && npm run test:sidebar-layout && npm run test:client-settings && npm run test:design-system && npm run test:page-frontmatter && npm run test:agent-terminal && npm run test:agent-contract && npm run test:okf-pages && npm run test:workspace-navigation && npm run test:window-chrome && npm run test:page-header && npm run test:source-ingest && npm run test:desktop-workflow && npm run test:history && npm run test:link-diagnostics && npm run test:cloud-sync && npm run test:cloud-client && npm run test:cloud-shell && npm run test:cloud-review-model && npm run test:review-preview && npm run test:skill-hosting && npm run test:cloud-space-dialog && npm run test:benchmark-provenance", "test:auth-flow": "node --experimental-strip-types --test tests/auth-flow.test.ts", "test:versioned-document": "node --experimental-strip-types --test tests/versioned-document.test.ts", "test:local-space": "node --experimental-strip-types --test tests/local-space.test.ts", @@ -36,7 +36,8 @@ "tauri": "tauri", "desktop:dev": "tauri dev", "desktop:build": "tauri build", - "bench:ui": "node scripts/benchmark-ui.mjs" + "bench:ui": "node scripts/benchmark-ui.mjs", + "test:benchmark-provenance": "node --test tests/benchmark-provenance.test.mjs" }, "dependencies": { "@codemirror/autocomplete": "^6.20.3", diff --git a/web/scripts/benchmark-provenance.mjs b/web/scripts/benchmark-provenance.mjs new file mode 100644 index 00000000..3d478fc0 --- /dev/null +++ b/web/scripts/benchmark-provenance.mjs @@ -0,0 +1,22 @@ +import { execFileSync } from 'node:child_process'; + +function output(program, args, cwd) { + try { + return execFileSync(program, args, { cwd, encoding: 'utf8', timeout: 5000, maxBuffer: 65536, stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + } catch { + return null; + } +} + +export function benchmarkProvenance(cwd) { + const commit = output('git', ['rev-parse', '--verify', 'HEAD'], cwd); + const status = output('git', ['status', '--porcelain', '--untracked-files=normal'], cwd); + return { + source: { commit, dirty: status === null ? null : status !== '' }, + toolchain: { + rustc: output('rustc', ['--version', '--verbose'], cwd), + cargo: output('cargo', ['--version'], cwd), + node: process.version, + }, + }; +} diff --git a/web/scripts/benchmark-ui.mjs b/web/scripts/benchmark-ui.mjs index 80138a4f..e53f956e 100644 --- a/web/scripts/benchmark-ui.mjs +++ b/web/scripts/benchmark-ui.mjs @@ -6,10 +6,13 @@ import { gzipSync } from 'node:zlib'; import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { createServer } from 'vite'; +import { benchmarkProvenance } from './benchmark-provenance.mjs'; const root = fileURLToPath(new URL('../', import.meta.url)); const samples = Number(process.env.COWIKI_BENCH_UI_SAMPLES ?? 30); assert(Number.isInteger(samples) && samples > 0 && samples <= 1000, 'samples must be 1–1000'); +const provenance = benchmarkProvenance(root); +const sizes = [100, 1000, 10000]; const vite = await createServer({ root, appType: 'custom', logLevel: 'silent', server: { middlewareMode: true, watch: null } }); function measure(action) { @@ -27,7 +30,7 @@ function measure(action) { try { const { visiblePageTree, findConcept, submitConceptPaths } = await vite.ssrLoadModule('/src/lib/okf-pages.ts'); const { PageReader } = await vite.ssrLoadModule('/src/components/PageReader.tsx'); - const corpora = [100, 1000, 10000].map((documents) => { + const corpora = sizes.map((documents) => { const pages = Array.from({ length: documents }, (_, index) => ({ slug: `topic-${index}`, path: `topic-${index}.md`, title: `Topic ${index}`, summary: 'Local Markdown and Git knowledge', kind: 'page', branch: 'main', children: [], @@ -53,7 +56,8 @@ try { assets.push({ filename, bytes: content.length, gzip_bytes: gzipSync(content).length }); } const report = { - schema_version: 1, node: process.version, os: process.platform, arch: process.arch, + schema_version: 2, ...provenance, node: process.version, os: process.platform, arch: process.arch, + settings: { sizes, samples, fixture_version: 1, warmup_iterations: 1, page_reader_paragraphs: 20, latency_percentile: 'nearest-rank', rendering: 'SSR', assets: 'dist/assets' }, scope: 'Node timings of production navigation helpers and PageReader SSR; excludes browser layout, paint, IPC and PTY.', corpora, page_reader_ssr: pageRender, assets, max_rss_bytes: process.resourceUsage().maxRSS * 1024, diff --git a/web/src-tauri/src/local_engine/benchmarks.rs b/web/src-tauri/src/local_engine/benchmarks.rs index 15ddacb7..efc42506 100644 --- a/web/src-tauri/src/local_engine/benchmarks.rs +++ b/web/src-tauri/src/local_engine/benchmarks.rs @@ -265,6 +265,42 @@ fn bounded_setting(name: &str, default: usize, max: usize) -> usize { value } +fn provenance_output(program: &str, args: &[&str]) -> Option { + let output = std::process::Command::new(program) + .args(args) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .stdin(std::process::Stdio::null()) + .output() + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn benchmark_report( + sizes: &[usize], + searches: usize, + mutations: usize, + corpora: Vec, +) -> Value { + let status = provenance_output( + "git", + &["status", "--porcelain", "--untracked-files=normal"], + ); + json!({ + "schema_version": 2, "fixture_version": 1, + "source": { "commit": provenance_output("git", &["rev-parse", "--verify", "HEAD"]), "dirty": status.map(|value| !value.is_empty()) }, + "toolchain": { "rustc": provenance_output("rustc", &["--version", "--verbose"]), "cargo": provenance_output("cargo", &["--version"]), "node": provenance_output("node", &["--version"]) }, + "settings": { "sizes": sizes, "search_samples": searches, "mutation_samples": mutations, "fixture_version": 1, "paragraphs_per_document": 8, "documents_per_directory": 100, "search_limit": 20, "worker_process_per_corpus": true }, + "os": std::env::consts::OS, "arch": std::env::consts::ARCH, + "profile": if cfg!(debug_assertions) { "debug" } else { "release" }, + "available_parallelism": std::thread::available_parallelism().map(|n| n.get()).ok(), + "startup_definition": "Fresh worker process with an existing healthy index; OS filesystem caches are not evicted.", + "latency_percentile": "nearest-rank", "corpora": corpora, + }) +} + #[test] #[ignore = "run through desktop_benchmark; this is the fresh-process worker"] fn benchmark_worker() { @@ -291,6 +327,10 @@ fn desktop_benchmark() { .collect::>(); assert!(!sizes.is_empty() && sizes.len() <= 3); assert!(sizes.iter().all(|value| (1..=10_000).contains(value))); + let searches = bounded_setting("COWIKI_BENCH_SEARCH_SAMPLES", 20, 1_000); + let mutations = bounded_setting("COWIKI_BENCH_MUTATION_SAMPLES", 5, 100); + // Capture the checkout and toolchain before fixture creation or measurements. + let mut report = benchmark_report(&sizes, searches, mutations, Vec::new()); let mut results = Vec::new(); for count in sizes { eprintln!("Preparing {count} documents (excluded from timings)..."); @@ -305,6 +345,8 @@ fn desktop_benchmark() { ]) .env("COWIKI_BENCH_WORKER_ROOT", root.path()) .env("COWIKI_BENCH_DOCUMENTS", count.to_string()) + .env("COWIKI_BENCH_SEARCH_SAMPLES", searches.to_string()) + .env("COWIKI_BENCH_MUTATION_SAMPLES", mutations.to_string()) .output() .unwrap(); assert!( @@ -321,14 +363,7 @@ fn desktop_benchmark() { results.push(serde_json::from_str::(result).unwrap()); eprintln!("Measured {count} documents."); } - let report = json!({ - "schema_version": 1, "fixture_version": 1, - "os": std::env::consts::OS, "arch": std::env::consts::ARCH, - "profile": if cfg!(debug_assertions) { "debug" } else { "release" }, - "available_parallelism": std::thread::available_parallelism().map(|n| n.get()).ok(), - "startup_definition": "Fresh worker process with an existing healthy index; OS filesystem caches are not evicted.", - "latency_percentile": "nearest-rank", "corpora": results, - }); + report["corpora"] = json!(results); let text = serde_json::to_string_pretty(&report).unwrap(); if let Ok(path) = std::env::var("COWIKI_BENCH_OUTPUT") { std::fs::write(path, &text).unwrap(); @@ -344,6 +379,26 @@ fn benchmark_smoke_exercises_the_complete_offline_workflow() { assert_eq!(report["active_worktrees_after_cleanup"], 0); assert_eq!(report["metrics"]["search_warm"]["samples"], 2); assert!(report["sqlite_bytes"].as_u64().unwrap() > 0); + let complete = benchmark_report(&[3], 2, 1, vec![report]); + assert_eq!(complete["schema_version"], 2); + assert_eq!(complete["settings"]["sizes"], json!([3])); + assert_eq!( + complete["settings"]["search_samples"], + complete["corpora"][0]["metrics"]["search_warm"]["samples"] + ); + assert_eq!( + complete["settings"]["mutation_samples"], + complete["corpora"][0]["metrics"]["startup_warm"]["samples"] + ); + assert!(complete["source"]["commit"] + .as_str() + .is_some_and(|value| value.len() == 40)); + assert!(complete["toolchain"]["rustc"] + .as_str() + .is_some_and(|value| value.starts_with("rustc "))); + assert!(complete["toolchain"]["cargo"] + .as_str() + .is_some_and(|value| value.starts_with("cargo "))); } #[test] diff --git a/web/tests/benchmark-provenance.test.mjs b/web/tests/benchmark-provenance.test.mjs new file mode 100644 index 00000000..dfb9919d --- /dev/null +++ b/web/tests/benchmark-provenance.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { benchmarkProvenance } from '../scripts/benchmark-provenance.mjs'; + +test('reports identify clean and modified source without inventing a commit outside Git', () => { + const root = mkdtempSync(join(tmpdir(), 'cowiki-benchmark-provenance-')); + const git = (...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false', '-c', 'user.name=Benchmark', '-c', 'user.email=benchmark@example.test', ...args], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + try { + const unknown = benchmarkProvenance(root); + assert.equal(unknown.source.commit, null); + assert.equal(unknown.source.dirty, null); + git('init', '-q'); + git('commit', '--allow-empty', '-qm', 'fixture'); + const clean = benchmarkProvenance(root); + assert.equal(clean.source.commit, git('rev-parse', 'HEAD')); + assert.equal(clean.source.dirty, false); + assert.equal(clean.toolchain.node, process.version); + writeFileSync(join(root, 'changed.md'), '# Changed'); + const changed = benchmarkProvenance(root); + assert.equal(changed.source.commit, clean.source.commit); + assert.equal(changed.source.dirty, true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); From 2cce8158c9105d8f48ed102bb612b06ef895e619 Mon Sep 17 00:00:00 2001 From: tiammomo <26957354+tiammomo@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:20:47 +0800 Subject: [PATCH 3/3] test(perf): allow unavailable provenance in cross-runtime smoke The report contract permits null metadata when Git or Rust tooling is unavailable. Preserve the offline workflow smoke under cross-compiled Windows/Wine execution. Refs #20. Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> --- web/src-tauri/src/local_engine/benchmarks.rs | 23 ++++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/web/src-tauri/src/local_engine/benchmarks.rs b/web/src-tauri/src/local_engine/benchmarks.rs index efc42506..e33af7a1 100644 --- a/web/src-tauri/src/local_engine/benchmarks.rs +++ b/web/src-tauri/src/local_engine/benchmarks.rs @@ -390,15 +390,20 @@ fn benchmark_smoke_exercises_the_complete_offline_workflow() { complete["settings"]["mutation_samples"], complete["corpora"][0]["metrics"]["startup_warm"]["samples"] ); - assert!(complete["source"]["commit"] - .as_str() - .is_some_and(|value| value.len() == 40)); - assert!(complete["toolchain"]["rustc"] - .as_str() - .is_some_and(|value| value.starts_with("rustc "))); - assert!(complete["toolchain"]["cargo"] - .as_str() - .is_some_and(|value| value.starts_with("cargo "))); + let commit = &complete["source"]["commit"]; + assert!(commit.is_null() || commit.as_str().is_some_and(|value| value.len() == 40)); + // Cross-compiled test runners and source archives need not have the build + // toolchain or Git installed. Missing provenance is explicitly null. + for (tool, prefix) in [("rustc", "rustc "), ("cargo", "cargo ")] { + let value = &complete["toolchain"][tool]; + assert!( + value.is_null() + || value + .as_str() + .is_some_and(|value| value.starts_with(prefix)) + ); + } + assert!(provenance_output("cowiki-nonexistent-provenance-tool", &[]).is_none()); } #[test]