diff --git a/CHANGELOG.md b/CHANGELOG.md index 316baf38437..31cf70f9c1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ # 0.11.0 +- Cache fuzzy normalization ceilings and reuse bounded per-worker scoring rows; retain valid ranked matches after long gaps. + - Prepare simple case-sensitive name/path globs once and avoid copying owned native glob patterns; preserve native matching for complex and case-folded patterns. - Share collection basename and root text with the owned path where possible, reducing retained memory without changing buffer limits. diff --git a/TODO.md b/TODO.md index 10e22a1ef43..51c0882cc69 100644 --- a/TODO.md +++ b/TODO.md @@ -365,15 +365,22 @@ Current prioritized inventory and implementation/measurement boundaries: [the reconciled cross-platform audit](docs/performance-analysis.md#reconciled-cross-platform-optimization-audit-after-1040). O01-O22 map to the existing EP/JC/P work; candidates are not speedup claims until measured on both platforms. -- [ ] O01: Adopt measured path-owned basename/root views with fallback copies, unchanged logical +- [x] O01: Adopt measured path-owned basename/root views with fallback copies, unchanged logical budgets and ownership/growth/move coverage. Retain small regressions and Linux/macOS evidence in [the collection-sharing report](docs/benchmarks/collection-text-sharing-20261101.md). -- [ ] O02: Qualify owned-pattern references and prepared simple ASCII glob operands. +- [x] O02: Qualify owned-pattern references and prepared simple ASCII glob operands (#1042). [Cross-platform measurements](docs/benchmarks/native-glob-preparation-20261101.md) preserve case-folded/complex fallbacks, native controls and preparation costs. -- [ ] O03-O06: Independently qualify fuzzy scratch/self-score - reuse, lexical field views, heterogeneous capture/definition lookup, and remaining read-buffer - zeroing. Prefer existing values and interfaces before adding prepared wrappers. +- [ ] O03: Qualify cached fuzzy self-score ceilings and bounded per-worker score rows on macOS + and Linux. Preserve ranking/negation/case semantics and measure allocation counts separately. + [Selected policy and full evidence](docs/benchmarks/fuzzy-score-workspace-20261102.md): exact + initial allocation beats the rejected 64-character minimum; full release suites and scoped lint + are recorded before PR publication. +- [x] Reproduce O03's long-gap sentinel defect with an explicit regression: a valid fuzzy match + after 1,500 bytes was incorrectly absent. Keep the fix in O03 qualification; the existing + general fuzz input cap is too small for this case. +- [ ] O04-O06: Independently qualify lexical field views, heterogeneous capture/definition lookup, + and remaining read-buffer zeroing. Prefer existing values and interfaces before adding prepared wrappers. - [ ] O07-O12: Profile and qualify caller-owned stream buffers, typed reusable directory task slots, batch ownership, bounded result reuse, active-worker dispatch and direct production evaluation. Preserve existing stage policy while isolating task storage and lifecycle costs. diff --git a/docs/benchmarks/fuzzy-score-workspace-20261102-capacity-linux.json.gz b/docs/benchmarks/fuzzy-score-workspace-20261102-capacity-linux.json.gz new file mode 100644 index 00000000000..f0484a10184 Binary files /dev/null and b/docs/benchmarks/fuzzy-score-workspace-20261102-capacity-linux.json.gz differ diff --git a/docs/benchmarks/fuzzy-score-workspace-20261102-capacity-macos.json.gz b/docs/benchmarks/fuzzy-score-workspace-20261102-capacity-macos.json.gz new file mode 100644 index 00000000000..a95cf5c7cee Binary files /dev/null and b/docs/benchmarks/fuzzy-score-workspace-20261102-capacity-macos.json.gz differ diff --git a/docs/benchmarks/fuzzy-score-workspace-20261102-controls-linux.json.gz b/docs/benchmarks/fuzzy-score-workspace-20261102-controls-linux.json.gz new file mode 100644 index 00000000000..13f2afcf0e1 Binary files /dev/null and b/docs/benchmarks/fuzzy-score-workspace-20261102-controls-linux.json.gz differ diff --git a/docs/benchmarks/fuzzy-score-workspace-20261102-controls-macos.json.gz b/docs/benchmarks/fuzzy-score-workspace-20261102-controls-macos.json.gz new file mode 100644 index 00000000000..d0821f30291 Binary files /dev/null and b/docs/benchmarks/fuzzy-score-workspace-20261102-controls-macos.json.gz differ diff --git a/docs/benchmarks/fuzzy-score-workspace-20261102-final-linux.json.gz b/docs/benchmarks/fuzzy-score-workspace-20261102-final-linux.json.gz new file mode 100644 index 00000000000..e08e4c51eff Binary files /dev/null and b/docs/benchmarks/fuzzy-score-workspace-20261102-final-linux.json.gz differ diff --git a/docs/benchmarks/fuzzy-score-workspace-20261102-final-macos.json.gz b/docs/benchmarks/fuzzy-score-workspace-20261102-final-macos.json.gz new file mode 100644 index 00000000000..3de02cbf83b Binary files /dev/null and b/docs/benchmarks/fuzzy-score-workspace-20261102-final-macos.json.gz differ diff --git a/docs/benchmarks/fuzzy-score-workspace-20261102-provenance.json.gz b/docs/benchmarks/fuzzy-score-workspace-20261102-provenance.json.gz new file mode 100644 index 00000000000..456922d6876 Binary files /dev/null and b/docs/benchmarks/fuzzy-score-workspace-20261102-provenance.json.gz differ diff --git a/docs/benchmarks/fuzzy-score-workspace-20261102.md b/docs/benchmarks/fuzzy-score-workspace-20261102.md new file mode 100644 index 00000000000..471f94698a6 --- /dev/null +++ b/docs/benchmarks/fuzzy-score-workspace-20261102.md @@ -0,0 +1,98 @@ +# Fuzzy score normalization and workspace (2026-11-02) + +## Decision + +Compute the pattern-only normalization ceiling once for each immutable Fzf term and reuse bounded score rows in the coordinator and each existing persistent matching worker. Allocate exactly the first input size, then double on growth up to 65,536 cells per row (512 KiB total on the supported hosts). Larger inputs use temporary storage without enlarging the retained buffer. No new thread pool, lock, reference count, option or exception cleanup is added. Plain subsequence matching still avoids scoring when no consumer needs it. Levenshtein and shingle models are outside this change. + +An identical equal-length self-match has exactly one alignment, so summing match, consecutive and word-boundary bonuses replaces the old quadratic self-match. The candidate still searches all valid alignments against the input. Its first row initializes every cell before any read; the other row needs no zeroing. Immutable query state is shared, and scratch is owned by a single coordinator or worker. Moving a workspace transfers storage and leaves the source reusable. + +A separate correctness regression reproduces a valid match after 1,500 skipped bytes: the old `-1000` unmatched sentinel hid the valid raw score `-1484`. The new sentinel leaves one gap of integer headroom below all ordinary scores. First-row and later-row long gaps now retain a match with normalized quality zero. The capped general fuzz campaign cannot reach this long-input counterexample, so the explicit regression remains. + +## Measurement contract + +Final baseline `44381e2b39f2acc52931f877add25721508426d4` has the same runtime tree as merged main `2b21790502fd6bf9b6e1afb53cc1444cf140f296`. Initial causal/capacity trials use #1041 merge `cf23601ab3533c2acb938acda1439dcd69091f61`; do not compare their absolute timings with the later baseline. Full source snapshots/patches, source and binary hashes, build flags, raw repetitions and experiment scripts are preserved below. + +Local M5 Pro uses warm APFS fixtures, one/three/ten requested workers and no affinity. Linux drogon uses a Ryzen 9950X KVM guest, tmpfs fixtures and child CPU affinity restricted to one/three/ten CPUs. Release builds use LLVM 23 and ThinLTO. Builds and allocation diagnostics finish before timing. Absolute times are not compared across hosts. + +Each final native case runs three sessions, discards a warmup for each variant, interleaves nine measured rounds and retains the fastest seven. Counts are 10/1,000/10,000/100,000 with one/three workers, plus 100,000 with ten workers. Ranked name/path, plain subsequence and no-fuzzy controls make 36 cases per session. Every invocation checks exact filename output against an independent oracle; all selected populations are identical. Both variants use `--exact`, `--sort=dir`, explicit UTC and no config/pager. The final matrix contains 4,320 fresh invocations across both hosts, including discarded warmups. + +Existing in-memory benchmarks separately measure preparation and prepared kernels (1/16 predicates) plus full-engine scored selection over 10/1,000/10,000 files and one/three workers. They also use three sessions, nine repetitions and fastest seven. Times below are medians of session means; changes are medians of within-session candidate/baseline ratios. Negative changes mean faster execution. + +## Final native 100,000-file results + +| Host | Case | Workers | Baseline ms | Candidate ms | Change | +| ----- | ----------- | ------: | ----------: | -----------: | -----: | +| macos | ranked-name | 1 | 248.562 | 237.348 | -4.51% | +| macos | ranked-path | 1 | 275.081 | 253.360 | -7.95% | +| macos | bare | 1 | 230.181 | 229.489 | -0.43% | +| macos | control | 1 | 226.122 | 226.749 | +0.31% | +| macos | ranked-name | 3 | 207.446 | 195.669 | -5.75% | +| macos | ranked-path | 3 | 234.268 | 214.411 | -8.80% | +| macos | bare | 3 | 191.115 | 190.505 | -0.28% | +| macos | control | 3 | 186.277 | 186.844 | +0.17% | +| macos | ranked-name | 10 | 215.581 | 206.386 | -4.71% | +| macos | ranked-path | 10 | 242.170 | 220.984 | -8.65% | +| macos | bare | 10 | 198.586 | 197.997 | +0.57% | +| macos | control | 10 | 194.663 | 193.575 | -0.56% | +| linux | ranked-name | 1 | 125.808 | 120.370 | -4.51% | +| linux | ranked-path | 1 | 133.624 | 132.960 | -0.54% | +| linux | bare | 1 | 116.105 | 115.622 | -0.42% | +| linux | control | 1 | 113.700 | 113.466 | -0.02% | +| linux | ranked-name | 3 | 104.189 | 100.800 | -3.83% | +| linux | ranked-path | 3 | 110.944 | 107.937 | -3.04% | +| linux | bare | 3 | 96.294 | 95.326 | -1.45% | +| linux | control | 3 | 92.209 | 91.934 | -0.59% | +| linux | ranked-name | 10 | 110.506 | 109.349 | -1.05% | +| linux | ranked-path | 10 | 124.488 | 116.697 | -6.26% | +| linux | bare | 10 | 106.051 | 104.664 | -1.31% | +| linux | control | 10 | 102.794 | 102.504 | -0.53% | + +## Kernels, preparation and full-engine selection + +| Host | Case | Baseline | Candidate | Change | +| ----- | -------------------------------------------- | ---------: | ---------: | ------: | +| macos | kernel/prepared/constant-true/1/1000 [ns] | 12586.27 | 12900.28 | +2.49% | +| macos | kernel/prepared/fuzzy/1/1000 [ns] | 20328.97 | 20717.42 | +3.02% | +| macos | prepare/prepared/fuzzy-score-and/1 [ns] | 495.32 | 501.72 | +1.76% | +| macos | kernel/prepared/fuzzy-score-and/1/1000 [ns] | 143969.08 | 68612.22 | -52.34% | +| macos | kernel/prepared/fuzzy-score-and/16/1000 [ns] | 2449251.15 | 1252928.05 | -49.44% | +| macos | engine/production/scored-replay/10000/1 [us] | 7255.29 | 6747.95 | -6.10% | +| macos | engine/production/scored-replay/10000/3 [us] | 8048.68 | 7131.61 | -11.39% | +| linux | kernel/prepared/constant-true/1/1000 [ns] | 9528.25 | 9521.51 | -0.02% | +| linux | kernel/prepared/fuzzy/1/1000 [ns] | 15181.01 | 15822.93 | +4.25% | +| linux | prepare/prepared/fuzzy-score-and/1 [ns] | 239.21 | 248.46 | +2.77% | +| linux | kernel/prepared/fuzzy-score-and/1/1000 [ns] | 64275.37 | 42799.85 | -33.54% | +| linux | kernel/prepared/fuzzy-score-and/16/1000 [ns] | 1192220.58 | 843602.85 | -29.21% | +| linux | engine/production/scored-replay/10000/1 [us] | 4168.73 | 3897.86 | -6.52% | +| linux | engine/production/scored-replay/10000/3 [us] | 5336.81 | 5126.70 | -4.05% | + +The kernel gain is specific to scored matching. The unrelated bare fuzzy kernel is slower by 3.02% macOS / 4.25% Linux in this matrix, and the constant-true control changes +2.49% / -0.02%; preserve these layout/noise counterexamples rather than attributing them to removed score allocations. Whole native bare/control paths remain near baseline. Cached ceilings add a small preparation cost (about 2-3% for the one-predicate scored case), which is paid once. + +## Allocation and capacity decision + +The isolated ASan allocation diagnostic runs 1,000 scored queries. Creating a fresh selected workspace each time makes 1,000 allocations / 64,000 requested bytes / 1,000 frees. Reusing a warmed workspace makes zero allocations, zero requested bytes and zero frees on both hosts, with equal result checksums. These are allocation counts only; sanitizer runtimes are never used as performance evidence. + +Initial four-way controls separate baseline, cached ceiling alone, one allocation per standalone scoring call, and persistent engine scratch. A 64-character minimum reduced allocation calls but requested 512,000 bytes in the same fresh diagnostic and regressed the tiny kernel relative to ceiling caching alone. The second capacity experiment selects exact initial size and later doubling. Both the rejected minimum and accepted policy retain their complete raw evidence. + +## Counterexamples, footprint and limits + +- macos: slowest paired native median +1.65% at (1000, 3, 'control'); every small/native control is retained in the raw data. +- macos: build-output executable 4,069,152 -> 4,069,776 bytes (+624); these are not stripped/compressed release sizes. +- linux: slowest paired native median +1.85% at (10, 1, 'ranked-path'); every small/native control is retained in the raw data. +- linux: build-output executable 12,306,928 -> 12,315,416 bytes (+8,488); these are not stripped/compressed release sizes. + +Each active scoring worker may retain up to 512 KiB. Nothing is allocated for its score rows until needed; unusually large inputs use temporary memory and still have the existing quadratic alignment cost. This change does not address all fuzzy models, Unicode semantics, traversal, startup or file I/O. It preserves scoring/ranking, inversion, anchors, folding, short-circuiting and deferred result handling. + +## Verification + +Targeted fuzzy, evaluator, expression-execution, parallel matching, CLI and whole-engine release tests pass on both hosts. Tests exercise normalization against the full-alignment self-score oracle, separator/camel-case patterns, growth, retention caps, moves, alternating query lengths, shared immutable queries with independent worker scratch and long-gap regressions. The complete release/extension suite passes 304/304 on macOS and Linux; Linux uses the existing FUSE-required configuration because drogon can mount. Scoped clang-tidy passes all six changed runtime/test translation units, including the allocation diagnostic. Normal commit hooks pass. Linux MemorySanitizer qualification is separate from release timing. + +## Lossless raw evidence + +- [final-macos](fuzzy-score-workspace-20261102-final-macos.json.gz): 144,878 bytes; SHA-256 `8f3a3736c0a9b88fce0462b5ad8e4c17ac42f428f2fd39f82ca4714840467a4a`. +- [final-linux](fuzzy-score-workspace-20261102-final-linux.json.gz): 143,001 bytes; SHA-256 `a3ae40b7eadee2ab028ef59a7078c8e39f40ab0996132c3e911ff97441617edd`. +- [controls-macos](fuzzy-score-workspace-20261102-controls-macos.json.gz): 284,420 bytes; SHA-256 `0be4405ad10a3e1ef1e4cbf94f65c3253ae13be25e5010c0cac8f52dd46146eb`. +- [controls-linux](fuzzy-score-workspace-20261102-controls-linux.json.gz): 283,494 bytes; SHA-256 `257c7e88bfcf051320a52dbf8d9ea7e07c05defb296323c8e007b6094173bdcf`. +- [capacity-macos](fuzzy-score-workspace-20261102-capacity-macos.json.gz): 224,928 bytes; SHA-256 `fd18e18812ee910358dd40592ae1e2b16ae88ef392576a6a837a3e4398d6171b`. +- [capacity-linux](fuzzy-score-workspace-20261102-capacity-linux.json.gz): 219,727 bytes; SHA-256 `0192c72030eb7a9d540d955cce4fae821f23941192da3fd92a0410d178095bf4`. +- [provenance](fuzzy-score-workspace-20261102-provenance.json.gz): 486,983 bytes; SHA-256 `db37fd94ece2cb0f1207ab441ceae8fd0d87810196e0ca98536dc98776fab5f3`. diff --git a/docs/performance-analysis.md b/docs/performance-analysis.md index 55c2c37e975..7c1c28bd3e7 100644 --- a/docs/performance-analysis.md +++ b/docs/performance-analysis.md @@ -3092,3 +3092,41 @@ per-directory filesystem case decision instead of forcing full regular-file meta mount boundaries, followed links and virtual filesystems before changing that policy; cross-series exact/default timings conflate metadata, matching admission and case behavior and cannot isolate one of them. + +## O03 fuzzy normalization and workspace experiment + +The [qualified experiment](benchmarks/fuzzy-score-workspace-20261102.md) separates the pattern-only +normalization ceiling from alignment search. +An identical, equal-length self-match has one alignment; its consecutive and word-start bonuses +can be computed in linear time without allocating dynamic-programming rows. Parsed immutable +Fzf terms cache that ceiling. Coordinator and persistent-worker-owned score workspaces reuse one +uninitialized allocation for two rows, with a 512 KiB retained bound and temporary storage for +larger inputs. Existing pools/threads remain unchanged. The selected implementation passes paired cross-platform +measurements and isolated allocation diagnostics; publication still requires normal hooks and CI. + +A deterministic test also reproduced an existing correctness problem: matching `x` after 1,500 +`a` bytes succeeds as a subsequence, but the ranked query returned no match. Its valid raw score +is -1,484, below the old -1,000 unmatched sentinel. Use the lowest representable int plus one gap +penalty for the sentinel, preserving headroom in the recurrence; the match then has normalized +quality zero instead of disappearing. Test both first-row and later-row long gaps. The general +fuzz harness caps input at 1,024 bytes, so it cannot cover this counterexample; keep an explicit +long-input regression instead of multiplying the quadratic model campaign cost. + +The score rows use the C++20 owning dynamic-array overwrite allocation API. Two narrow lint +annotations document its required array-specialization syntax. The first row writes every cell +before it can be read; no blanket zeroing, lock, reference counting or exception cleanup is added. +The public workspace remains thread-confined, reusable after moving, and isolated across workers. + +### O03 initial-row capacity decision + +The first 64-input-byte minimum reduced allocation calls but made the tiny fresh-workspace kernel +slower than ceiling caching alone. Three-session in-memory controls on both hosts support allocating +exactly the first input size and doubling only for later growth, within the existing retained bound. +The one-predicate/1,000-entry scored kernel improves about 51% on macOS and 33% on Linux versus the +original implementation. In-memory 10,000-entry scored selection improves about 14% macOS and 5-6% +Linux. These are component/whole-engine fixtures, not filesystem throughput claims. Preserve the +64-byte-minimum control and all native samples. Final parent-based native qualification shows +100,000-file ranked-name/path improvements of about 4-9% macOS and 1-6% Linux depending on workers +and scoring input; bare/control cases stay near baseline. Scored kernels improve 29-54%, while +unrelated bare kernels include 3-4% slower cases and preparation is slightly slower. Keep these +counterexamples and all raw data in the report; the result is not a claim about every fuzzy model. diff --git a/release-site.json b/release-site.json index fe9443e3f54..ed86c768e53 100644 --- a/release-site.json +++ b/release-site.json @@ -60,7 +60,8 @@ "NOTICE.lean.md": "NOTICE.lean.html", "docs/performance-analysis.md": "docs/performance-analysis.html", "docs/design-expression-program.md": "docs/design-expression-program.html", - "docs/expression-qualification-audit.md": "docs/expression-qualification-audit.html" + "docs/expression-qualification-audit.md": "docs/expression-qualification-audit.html", + "docs/benchmarks/fuzzy-score-workspace-20261102.md": "docs/benchmarks/fuzzy-score-workspace-20261102.html" }, "generated_html": { "XFF.md": { diff --git a/xff/engine/BUILD.bazel b/xff/engine/BUILD.bazel index 8eae0bfe34c..b27ae873a11 100644 --- a/xff/engine/BUILD.bazel +++ b/xff/engine/BUILD.bazel @@ -347,6 +347,7 @@ cc_library( ":collect_cc", ":evaluate_cc", ":expression_execution_cc", + "//xff/fuzzy:fuzzy_cc", "//xff/parser:parser_cc", "//xff/registry:registry_cc", "@abseil-cpp//absl/base:core_headers", @@ -398,6 +399,7 @@ cc_library( "//xff/exec:exec_cc", "//xff/filesystem/ignore:ignore_cc", "//xff/filesystem/repo:repo_cc", + "//xff/fuzzy:fuzzy_cc", "//xff/hash:hash_cc", "//xff/matching/language:language_cc", "//xff/matching/mime:mime_cc", @@ -956,6 +958,7 @@ cc_test( ":expression_execution_cc", ":expression_qualification_cc", ":walk_cc", + "//xff/fuzzy:fuzzy_cc", "//xff/parser:parser_cc", "//xff/vfs:vfs_cc", "@abseil-cpp//absl/base:config", diff --git a/xff/engine/evaluate.cc b/xff/engine/evaluate.cc index c3e8aa22e0b..f2553c374e7 100644 --- a/xff/engine/evaluate.cc +++ b/xff/engine/evaluate.cc @@ -997,8 +997,13 @@ bool EvalFuzzyOn(const parser::Expr& expr, EvalContext& ctx, std::string_view su std::optional percent; switch (expr.fuzzy_model) { case parser::FuzzyModel::kFzf: - percent = expr.fuzzy_query ? expr.fuzzy_query->Percent(subject, fold) - : fuzzy::FzfPercent(expr.args.front(), subject, fold); + if (ctx.fuzzy_workspace) { + percent = expr.fuzzy_query ? expr.fuzzy_query->Percent(subject, fold, *ctx.fuzzy_workspace) + : fuzzy::FzfQuery(expr.args.front()).Percent(subject, fold, *ctx.fuzzy_workspace); + } else { + percent = expr.fuzzy_query ? expr.fuzzy_query->Percent(subject, fold) + : fuzzy::FzfPercent(expr.args.front(), subject, fold); + } break; case parser::FuzzyModel::kSequence: percent = fuzzy::SequencePercent(expr.args.front(), subject, fold); break; case parser::FuzzyModel::kLevenshtein: percent = fuzzy::LevenshteinPercent(expr.args.front(), subject, fold); break; diff --git a/xff/engine/evaluate.h b/xff/engine/evaluate.h index 022fe27fed3..6bbb2d7aa01 100644 --- a/xff/engine/evaluate.h +++ b/xff/engine/evaluate.h @@ -50,6 +50,10 @@ namespace xff::exec { class ParallelExec; // bounded concurrent `-exec/-execdir ... ;` runner (xff/exec/exec.h) } // namespace xff::exec +namespace xff::fuzzy { +struct ScoreWorkspace; +} // namespace xff::fuzzy + namespace xff::engine { // Output sink for actions: receives one fully-formed record per action firing @@ -206,6 +210,8 @@ struct EvalContext { // The normalized 0..100 fuzzy quality composed across the expression for {fuzzy}, --sort=score, // and -top. Null when no consumer needs it; reset by the driver per entry. mbo::types::OptionalRef> fuzzy_score; + // Owned by this coordinator/worker, reused across entries; never shared between threads. + mbo::types::OptionalRef fuzzy_workspace; // --summary=hash-verification: the verdict produced when this entry reaches its -hasheq predicate. // Null when that reduction is inactive; an empty optional means short-circuiting has not reached // -hasheq, while true / false records the predicate's actual result independent of the expression's diff --git a/xff/engine/expression_allocations_test.cc b/xff/engine/expression_allocations_test.cc index 27c13c1ce0c..2fe792138dc 100644 --- a/xff/engine/expression_allocations_test.cc +++ b/xff/engine/expression_allocations_test.cc @@ -24,6 +24,7 @@ #include "xff/engine/expression_execution.h" #include "xff/engine/expression_qualification.h" #include "xff/engine/walk.h" +#include "xff/fuzzy/fuzzy.h" #include "xff/parser/parser.h" #include "xff/vfs/filesystem.h" @@ -191,6 +192,33 @@ TEST_F(AllocationTest, CounterObservesAllocationAndFreeWithoutCountingAssertions EXPECT_THAT(released.counts.frees, Ge(1)); } +TEST_F(AllocationTest, ReusedFuzzyWorkspaceAvoidsHotScoreAllocations) { + const fuzzy::FzfQuery query("fl"); + fuzzy::ScoreWorkspace workspace; + const int score = query.Percent("file.txt", false, workspace).value_or(-1); + const auto fresh = MeasureAllocations([&] { + int total = 0; + for (int repeat = 0; repeat < 1'000; ++repeat) { + total += query.Percent("file.txt", false).value_or(-1); + } + return total; + }); + const auto reused = MeasureAllocations([&] { + int total = 0; + for (int repeat = 0; repeat < 1'000; ++repeat) { + total += query.Percent("file.txt", false, workspace).value_or(-1); + } + return total; + }); + EXPECT_THAT(fresh.value, Eq(1'000 * score)); + EXPECT_THAT(reused.value, Eq(fresh.value)); + EXPECT_THAT(fresh.counts.allocations, Eq(1'000)); + EXPECT_THAT(reused.counts.allocations, Eq(0)); + EXPECT_THAT(reused.counts.requested_bytes, Eq(0)); + RecordCounts("fuzzy_fresh_rows", fresh.counts); + RecordCounts("fuzzy_reused_rows", reused.counts); +} + struct AllocationCase { std::string name; std::vector arguments; diff --git a/xff/engine/parallel_match.cc b/xff/engine/parallel_match.cc index 62afcff0ce5..e00c9c60581 100644 --- a/xff/engine/parallel_match.cc +++ b/xff/engine/parallel_match.cc @@ -73,7 +73,7 @@ const std::vector& ParallelMatch::Match(std::vectorMakeWorker(ExpressionWorkerRole::kCoordinator)); } EvaluateEntries( - {}, {}, + coordinator_fuzzy_workspace_, {}, {}, coordinator_ ? mbo::types::OptionalRef{*coordinator_} : mbo::types::OptionalRef{}); return results_; @@ -99,6 +99,7 @@ void ParallelMatch::Run() { matchers.Bind(*expression_); } const auto evaluator = execution_ ? std::optional(execution_->MakeWorker()) : std::nullopt; + fuzzy::ScoreWorkspace fuzzy_workspace; std::size_t generation = 0; for (;;) { { @@ -110,6 +111,7 @@ void ParallelMatch::Run() { generation = generation_; } EvaluateEntries( + fuzzy_workspace, local ? mbo::types::OptionalRef>{*local} : mbo::types::OptionalRef>{}, matchers, @@ -123,6 +125,7 @@ void ParallelMatch::Run() { } void ParallelMatch::EvaluateEntries( + fuzzy::ScoreWorkspace& workspace, mbo::types::OptionalRef> output, mbo::types::OptionalRef matchers, mbo::types::OptionalRef evaluator) { @@ -135,13 +138,14 @@ void ParallelMatch::EvaluateEntries( } const std::size_t end = std::min(first + kChunk, entries_.size()); for (std::size_t index = first; index < end; ++index) { - results_.at(index) = EvaluateEntry(entries_.at(index).AsVisit(), output, matchers, evaluator); + results_.at(index) = EvaluateEntry(entries_.at(index).AsVisit(), workspace, output, matchers, evaluator); } } } ParallelResult ParallelMatch::EvaluateEntry( const Visit& visit, + fuzzy::ScoreWorkspace& workspace, mbo::types::OptionalRef> output, mbo::types::OptionalRef matchers, mbo::types::OptionalRef evaluator) const { @@ -156,6 +160,7 @@ ParallelResult ParallelMatch::EvaluateEntry( .now = absl::UnixEpoch(), .tz = absl::UTCTimeZone(), .fuzzy_score = scores_ ? mbo::types::OptionalRef{fuzzy} : mbo::types::OptionalRef>{}, + .fuzzy_workspace = workspace, .worker_matchers = matchers, .control = control, }; diff --git a/xff/engine/parallel_match.h b/xff/engine/parallel_match.h index 75a4dfaa391..7ccd913c985 100644 --- a/xff/engine/parallel_match.h +++ b/xff/engine/parallel_match.h @@ -20,6 +20,7 @@ #include "xff/engine/collect.h" #include "xff/engine/evaluate.h" #include "xff/engine/expression_execution.h" +#include "xff/fuzzy/fuzzy.h" #include "xff/parser/ast.h" namespace xff::engine { @@ -76,11 +77,13 @@ class ParallelMatch final { bool Ready(std::size_t generation) const ABSL_EXCLUSIVE_LOCKS_REQUIRED(mutex_); bool Finished() const ABSL_EXCLUSIVE_LOCKS_REQUIRED(mutex_); void EvaluateEntries( + fuzzy::ScoreWorkspace& workspace, mbo::types::OptionalRef> output = {}, mbo::types::OptionalRef matchers = {}, mbo::types::OptionalRef evaluator = {}); ParallelResult EvaluateEntry( const Visit& visit, + fuzzy::ScoreWorkspace& workspace, mbo::types::OptionalRef> output, mbo::types::OptionalRef matchers, mbo::types::OptionalRef evaluator) const; @@ -91,6 +94,7 @@ class ParallelMatch final { const std::optional output_; const mbo::types::OptionalRef execution_; std::optional coordinator_; + fuzzy::ScoreWorkspace coordinator_fuzzy_workspace_; std::vector threads_; absl::Mutex mutex_; bool stop_ ABSL_GUARDED_BY(mutex_) = false; diff --git a/xff/engine/run.cc b/xff/engine/run.cc index 02e87482326..e8395e8a767 100644 --- a/xff/engine/run.cc +++ b/xff/engine/run.cc @@ -77,6 +77,7 @@ #include "xff/exec/exec.h" #include "xff/filesystem/ignore/ignore.h" #include "xff/filesystem/repo/repo.h" +#include "xff/fuzzy/fuzzy.h" #include "xff/hash/hash.h" #include "xff/matching/language/catalog.h" #include "xff/matching/language/language.h" @@ -5484,6 +5485,7 @@ RunResult RunFindCore( // The normalized fuzzy quality composed by the evaluator and read by {fuzzy} / --sort=score. // Run-scoped rather than per-entry so the phases can share it; cleared before each evaluation. std::optional fuzzy_score; + fuzzy::ScoreWorkspace fuzzy_workspace; // The -hasheq verdict for the entry currently being evaluated. Kept independently from matched: // a failed check must still reach --summary=hash-verification. std::optional hash_verification; @@ -6234,6 +6236,7 @@ RunResult RunFindCore( .fold_name_case = fold_name_case, .fuzzy_score = full_metadata || rank_by_score ? mbo::types::OptionalRef{fuzzy_score} : mbo::types::OptionalRef>{}, + .fuzzy_workspace = fuzzy_workspace, .hash_verification = hash_verification_summary ? mbo::types::OptionalRef{hash_verification} : mbo::types::OptionalRef>{}, .deferred = deferred_nodes.empty() ? mbo::types::OptionalRef{} @@ -6392,6 +6395,7 @@ RunResult RunFindCore( .block_size = block_size, .fold_name_case = fold_name_case, .fuzzy_score = fuzzy_score, + .fuzzy_workspace = fuzzy_workspace, .hash_verification = hash_verification_summary ? mbo::types::OptionalRef{candidate.hash_verification} : mbo::types::OptionalRef>{}, .deferred = deferred, diff --git a/xff/fuzzy/BUILD.bazel b/xff/fuzzy/BUILD.bazel index 262f60ad69d..890256e820b 100644 --- a/xff/fuzzy/BUILD.bazel +++ b/xff/fuzzy/BUILD.bazel @@ -28,6 +28,7 @@ cc_library( deps = [ "@abseil-cpp//absl/algorithm:container", "@abseil-cpp//absl/strings", + "@mboworks_mbo//mbo/types:optional_ref_cc", ], ) diff --git a/xff/fuzzy/fuzzy.cc b/xff/fuzzy/fuzzy.cc index 7ab69619647..048d10e4fe7 100644 --- a/xff/fuzzy/fuzzy.cc +++ b/xff/fuzzy/fuzzy.cc @@ -17,7 +17,9 @@ #include #include +#include #include +#include #include #include #include @@ -25,6 +27,7 @@ #include "absl/algorithm/container.h" #include "absl/strings/ascii.h" #include "absl/strings/strip.h" +#include "mbo/types/optional_ref.h" namespace xff::fuzzy { @@ -52,11 +55,13 @@ namespace { // The weights. Deliberately small integers with a wide gap between "a character matched" and the // bonuses, so a longer match always beats a shorter one and the bonuses only order matches of the // same length. Tuned by the cases in fuzzy_test.cc, which are the real specification. -constexpr int kMatch = 16; // per matched character -constexpr int kWordStart = 8; // the character begins a word -constexpr int kConsecutive = 8; // it follows the previous match immediately -constexpr int kGap = 1; // per character skipped before a match -constexpr int kUnmatched = -1'000; // "no alignment reaches here", never a real score +constexpr int kMatch = 16; // per matched character +constexpr int kWordStart = 8; // the character begins a word +constexpr int kConsecutive = 8; // it follows the previous match immediately +constexpr int kGap = 1; // per character skipped before a match +// Leave enough headroom for the gap subtraction before the max with the prior row. +// Valid long-gap scores can be below -1,000 and must remain distinct from no alignment. +constexpr int kUnmatched = std::numeric_limits::min() + kGap; bool IsSeparator(char chr) { return chr == '_' || chr == '-' || chr == '.' || chr == '/' || chr == ' '; @@ -99,10 +104,46 @@ std::optional FindText(std::string_view needle, std::string_view ha return std::nullopt; } +// Matching every pattern byte against the same-length identical text has exactly one alignment. +// Its word-start/consecutive bonuses are independent of case folding; no dynamic-programming +// rows or alignment search are needed for this normalization ceiling. +std::int64_t SelfScore(std::string_view pattern) { + std::int64_t result = 0; + for (std::size_t at = 0; at < pattern.size(); ++at) { + result += kMatch + (at == 0 ? 0 : kConsecutive) + (IsWordStart(pattern, at) ? kWordStart : 0); + } + return result; +} + +int NormalizeScore(int score, std::int64_t ceiling) { + if (ceiling == 0) { + return 100; // The empty pattern matches without expressing a quality preference. + } + const std::int64_t scaled = (static_cast(score) * 100) / ceiling; + return static_cast(std::clamp(scaled, 0, 100)); +} + +ScoreWorkspace::Rows AllocateRows(std::size_t count) { + // NOLINTNEXTLINE(cppcoreguidelines-avoid-c-arrays,modernize-avoid-c-arrays): C++20 owning dynamic overwrite storage. + return std::make_unique_for_overwrite(count); +} + +std::optional PercentWithCeiling( + std::string_view pattern, + std::string_view text, + bool fold_case, + std::int64_t ceiling, + mbo::types::OptionalRef workspace = {}) { + const std::optional score = + workspace ? Score(pattern, text, fold_case, *workspace) : Score(pattern, text, fold_case); + return score.has_value() ? std::optional(NormalizeScore(*score, ceiling)) : std::nullopt; +} + enum class TermKind { kFuzzy, kExact, kBoundary, kPrefix, kSuffix, kEqual }; struct QueryTerm { std::string text; + std::int64_t ceiling = 0; TermKind kind = TermKind::kFuzzy; bool inverse = false; }; @@ -157,6 +198,7 @@ QueryTerm ParseTerm(std::string word) { term.kind = suffix ? TermKind::kEqual : TermKind::kPrefix; } term.text = std::move(word); + term.ceiling = SelfScore(term.text); return term; } @@ -204,10 +246,15 @@ std::string_view TrimAnchorWhitespace(std::string_view pattern, std::string_view return text; } -std::optional PositiveTermPercent(const QueryTerm& term, std::string_view text, bool fold_case, bool score) { +std::optional PositiveTermPercent( + const QueryTerm& term, + std::string_view text, + bool fold_case, + bool score, + mbo::types::OptionalRef workspace = {}) { if (term.kind == TermKind::kFuzzy) { if (score) { - return Percent(term.text, text, fold_case); + return PercentWithCeiling(term.text, text, fold_case, term.ceiling, workspace); } return Matches(term.text, text, fold_case) ? std::optional(100) : std::nullopt; } @@ -244,20 +291,30 @@ std::optional PositiveTermPercent(const QueryTerm& term, std::string_view t if (!matched) { return std::nullopt; } - return score ? Percent(term.text, text, fold_case).value_or(100) : 100; + return score ? PercentWithCeiling(term.text, text, fold_case, term.ceiling, workspace).value_or(100) : 100; } // One row of the search: the best score for the pattern prefix whose LAST character is `want`, with // that character placed at each position of `text`. `previous` is the same row for the prefix one -// character shorter (all kUnmatched when `want` is the first character), and the result lands in -// `best`. Split out of Score because the nesting, not the idea, is what made it hard to read. +// character shorter. The first character initializes `best` without reading `previous`. Split out of Score because the +// nesting, not the idea, is what made it hard to read. void ScoreRow( std::string_view text, char want, bool fold_case, bool first, - const std::vector& previous, - std::vector& best) { + std::span previous, + std::span best) { + if (first) { + // Initialize every result before it becomes the previous row. The uninitialized other + // half is never read here, so freshly allocated rows need no blanket initialization. + for (std::size_t at = 0; at < text.size(); ++at) { + best[at] = SameChar(text[at], want, fold_case) + ? kMatch + (IsWordStart(text, at) ? kWordStart : 0) - (kGap * static_cast(at)) + : kUnmatched; + } + return; + } // `reachable` carries the best score of any earlier alignment of the previous character, already // charged the gap penalty for the distance travelled. Updating it as the position advances is what // keeps this linear in the text rather than quadratic. @@ -271,11 +328,6 @@ void ScoreRow( continue; } const int here = kMatch + (IsWordStart(text, at) ? kWordStart : 0); - if (first) { - // The first character may start anywhere, but the later it starts the worse it scores. - best[at] = here - (kGap * static_cast(at)); - continue; - } const int consecutive = at > 0 && previous[at - 1] != kUnmatched ? previous[at - 1] + kConsecutive : kUnmatched; const int from = std::max(reachable, consecutive); best[at] = from == kUnmatched ? kUnmatched : from + here; @@ -285,6 +337,11 @@ void ScoreRow( } // namespace std::optional Score(std::string_view pattern, std::string_view text, bool fold_case) { + ScoreWorkspace workspace; + return Score(pattern, text, fold_case, workspace); +} + +std::optional Score(std::string_view pattern, std::string_view text, bool fold_case, ScoreWorkspace& workspace) { if (pattern.empty()) { return 0; // matches, and says nothing about how well - see Matches } @@ -292,11 +349,25 @@ std::optional Score(std::string_view pattern, std::string_view text, bool f return std::nullopt; } // Two rows are enough, because a pattern character can only follow the one before it. - std::vector previous(text.size(), kUnmatched); - std::vector best(text.size(), kUnmatched); + ScoreWorkspace::Rows temporary; + std::span rows; + if (text.size() > ScoreWorkspace::kMaxRetainedCharacters) { + temporary = AllocateRows(2 * text.size()); + rows = {temporary.get(), 2 * text.size()}; + } else { + if (!workspace.rows || workspace.capacity < text.size()) { + const std::size_t current = workspace.rows ? workspace.capacity : 0; + const std::size_t capacity = std::min(ScoreWorkspace::kMaxRetainedCharacters, std::max(text.size(), 2 * current)); + workspace.rows = AllocateRows(2 * capacity); + workspace.capacity = capacity; + } + rows = {workspace.rows.get(), 2 * workspace.capacity}; + } + auto previous = rows.first(text.size()); + auto best = rows.subspan(text.size(), text.size()); for (std::string_view::size_type i = 0; i < pattern.size(); ++i) { ScoreRow(text, pattern[i], fold_case, /*first=*/i == 0, previous, best); - previous.swap(best); + std::swap(previous, best); } const auto found = absl::c_max_element(previous); if (found == previous.end() || *found == kUnmatched) { @@ -313,12 +384,11 @@ std::optional Percent(std::string_view pattern, std::string_view text, bool if (pattern.empty()) { return 100; } - const std::optional ceiling = Score(pattern, pattern, fold_case); - if (!ceiling.has_value() || *ceiling <= 0) { - return std::nullopt; - } - const std::int64_t scaled = (static_cast(*score) * 100) / *ceiling; - return static_cast(std::clamp(scaled, 0, 100)); + return NormalizeScore(*score, SelfScore(pattern)); +} + +std::optional Percent(std::string_view pattern, std::string_view text, bool fold_case, ScoreWorkspace& workspace) { + return PercentWithCeiling(pattern, text, fold_case, SelfScore(pattern), workspace); } struct FzfQuery::Impl { @@ -340,12 +410,17 @@ std::optional FzfPercent(std::string_view query, std::string_view text, boo } std::optional FzfQuery::Percent(std::string_view text, bool fold_case) const { + ScoreWorkspace workspace; + return Percent(text, fold_case, workspace); +} + +std::optional FzfQuery::Percent(std::string_view text, bool fold_case, ScoreWorkspace& workspace) const { int query_score = 100; for (const OrGroup& group : impl_->groups) { std::optional group_score; bool matched = false; for (const QueryTerm& term : group) { - const std::optional positive = PositiveTermPercent(term, text, fold_case, /*score=*/true); + const std::optional positive = PositiveTermPercent(term, text, fold_case, /*score=*/true, workspace); const bool term_matches = term.inverse ? !positive.has_value() : positive.has_value(); if (!term_matches) { continue; diff --git a/xff/fuzzy/fuzzy.h b/xff/fuzzy/fuzzy.h index a3ca90ef9cc..d38735a2501 100644 --- a/xff/fuzzy/fuzzy.h +++ b/xff/fuzzy/fuzzy.h @@ -16,6 +16,7 @@ #ifndef XFF_FUZZY_FUZZY_H_ #define XFF_FUZZY_FUZZY_H_ +#include #include #include #include @@ -36,6 +37,17 @@ namespace xff::fuzzy { // An EMPTY pattern matches everything, exactly as an empty glob would: it constrains nothing. [[nodiscard]] bool Matches(std::string_view pattern, std::string_view text, bool fold_case); +// Thread-confined reusable score rows. No allocation until a nontrivial score needs them. +// Each row retains at most 65,536 ints (512 KiB total); larger calls use temporary storage. +// Independent workers own independent workspaces. Moving this value transfers its buffer. +struct ScoreWorkspace final { + static constexpr std::size_t kMaxRetainedCharacters = 65'536; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-c-arrays,modernize-avoid-c-arrays): C++20 owning dynamic overwrite storage. + using Rows = std::unique_ptr; + Rows rows; + std::size_t capacity = 0; +}; + // How WELL `pattern` matches `text`, or nullopt when it does not match at all (so the boolean answer // is `Score(...).has_value()`, and a matching empty pattern scores 0 rather than being confused with // no match). Higher is better; the numbers have no meaning on their own, only against each other for @@ -54,11 +66,21 @@ namespace xff::fuzzy { // earliest match for each character is always A match, but it is often not the BEST one, and a // ranking built on the greedy answer would rank by accident. [[nodiscard]] std::optional Score(std::string_view pattern, std::string_view text, bool fold_case); +[[nodiscard]] std::optional Score( + std::string_view pattern, + std::string_view text, + bool fold_case, + ScoreWorkspace& workspace); // Returns Score normalized against the pattern's best possible self-match, as an // integer percentage in [0, 100]. This makes scores from different patterns // comparable when a boolean expression combines several fuzzy predicates. [[nodiscard]] std::optional Percent(std::string_view pattern, std::string_view text, bool fold_case); +[[nodiscard]] std::optional Percent( + std::string_view pattern, + std::string_view text, + bool fold_case, + ScoreWorkspace& workspace); // Evaluates fzf's extended-search query language. Space-separated terms are ANDed, `|` joins OR // alternatives, and the `!`, `'`, `^`, and `$` operators select inverse, exact, prefix, and suffix @@ -75,6 +97,7 @@ class FzfQuery final { explicit FzfQuery(std::string_view query); [[nodiscard]] bool Matches(std::string_view text, bool fold_case) const; [[nodiscard]] std::optional Percent(std::string_view text, bool fold_case) const; + [[nodiscard]] std::optional Percent(std::string_view text, bool fold_case, ScoreWorkspace& workspace) const; private: struct Impl; diff --git a/xff/fuzzy/fuzzy_test.cc b/xff/fuzzy/fuzzy_test.cc index e444bc3d004..4087e5306f7 100644 --- a/xff/fuzzy/fuzzy_test.cc +++ b/xff/fuzzy/fuzzy_test.cc @@ -15,10 +15,13 @@ #include "xff/fuzzy/fuzzy.h" +#include #include +#include #include #include #include +#include #include #include @@ -34,9 +37,11 @@ using ::testing::Eq; using ::testing::Ge; using ::testing::Gt; using ::testing::IsFalse; +using ::testing::IsNull; using ::testing::IsTrue; using ::testing::Le; using ::testing::Lt; +using ::testing::NotNull; using ::testing::Optional; struct FuzzyTest : ::testing::Test {}; @@ -72,7 +77,7 @@ TEST_F(FuzzyTest, CompiledExtendedQueryTruthAgreesWithRankedResults) { const auto ranked = compiled.Percent(candidate, fold); EXPECT_THAT(ranked, Eq(FzfPercent(query, candidate, fold))); if (compiled.Matches(candidate, fold)) { - EXPECT_THAT(ranked, Optional(::testing::_)); + EXPECT_THAT(ranked, Optional(_)); } else { EXPECT_THAT(ranked, Eq(std::nullopt)); } @@ -146,6 +151,141 @@ TEST_F(FuzzyScoreTest, PercentNormalizesScoresAcrossPatterns) { EXPECT_THAT(Percent("foo", "far_out_of", false), Optional(AllOf(Ge(0), Lt(100)))); } +// The unchanged full alignment scorer is the normalization oracle. This intentionally pays +// for the old pattern-against-itself search instead of repeating the new closed-form ceiling. +std::optional AlignmentNormalizedPercent(std::string_view pattern, std::string_view text, bool fold) { + const auto score = Score(pattern, text, fold); + if (!score.has_value()) { + return std::nullopt; + } + if (pattern.empty()) { + return 100; + } + const int ceiling = Score(pattern, pattern, fold).value_or(0); + if (ceiling <= 0) { + return std::nullopt; + } + const auto scaled = (static_cast(*score) * 100) / ceiling; + return static_cast(std::clamp(scaled, 0, 100)); +} + +TEST_F(FuzzyScoreTest, PreparedCeilingsRetainAlignmentScoresAndCaseSemantics) { + // Repeated letters, camel-case boundaries and all separator kinds affect the bonus ceiling. + std::vector patterns{""}; + std::vector frontier{""}; + static constexpr auto kAlphabet = std::to_array({'a', 'A', '_', '.', '/'}); + for (int length = 0; length < 4; ++length) { + std::vector next; + next.reserve(frontier.size() * kAlphabet.size()); + for (const std::string& prefix : frontier) { + for (const char byte : kAlphabet) { + next.push_back(prefix + byte); + } + } + patterns.insert(patterns.end(), next.begin(), next.end()); + frontier = std::move(next); + } + patterns.push_back("a-b c"); + patterns.push_back("path/fileName.cc"); + patterns.push_back(std::string(160, 'a') + "_Tail"); + for (const std::string& pattern : patterns) { + const FzfQuery compiled(pattern); + const std::vector texts{pattern, "a_A/a.a", "", "x" + pattern, pattern + "extra"}; + for (const std::string& text : texts) { + for (const bool fold : {false, true}) { + SCOPED_TRACE(pattern); + SCOPED_TRACE(text); + const auto expected = AlignmentNormalizedPercent(pattern, text, fold); + EXPECT_THAT(Percent(pattern, text, fold), Eq(expected)); + // A space changes extended-query syntax, so only the plain term has this same oracle. + if (!pattern.contains(' ')) { + EXPECT_THAT(compiled.Percent(text, fold), Eq(expected)); + } + } + } + } +} + +TEST_F(FuzzyScoreTest, WorkspaceReusesRowsAcrossPatternsLengthsAndMoves) { + ScoreWorkspace workspace; + EXPECT_THAT(Score("", "empty needs no rows", false, workspace), Optional(Eq(0))); + EXPECT_THAT(workspace.rows, IsNull()); + EXPECT_THAT(workspace.capacity, Eq(0)); + const FzfQuery query("fl | ^file .txt$"); + static constexpr auto kTexts = std::to_array({ + "file.txt", + "a/long_path/fileName.txt", + "not-a-match", + "", + "FILE.TXT", + "fl.txt", + }); + static constexpr auto kPatterns = std::to_array({"", "fl", "file", "F", "z", "aa"}); + for (int repeat = 0; repeat < 3; ++repeat) { + for (const std::string_view text : kTexts) { + for (const bool fold : {false, true}) { + for (const std::string_view pattern : kPatterns) { + SCOPED_TRACE(pattern); + SCOPED_TRACE(text); + EXPECT_THAT(Score(pattern, text, fold, workspace), Eq(Score(pattern, text, fold))); + EXPECT_THAT(Percent(pattern, text, fold, workspace), Eq(AlignmentNormalizedPercent(pattern, text, fold))); + } + EXPECT_THAT(query.Percent(text, fold, workspace), Eq(query.Percent(text, fold))); + } + } + } + EXPECT_THAT(workspace.rows, NotNull()); + const std::size_t capacity = workspace.capacity; + EXPECT_THAT(Score("f", "file", false, workspace), Optional(Eq(24))); + EXPECT_THAT(workspace.capacity, Eq(capacity)); + auto moved = std::move(workspace); + EXPECT_THAT(Score("fl", "file", false, moved), Eq(Score("fl", "file", false))); + // A moved-from workspace can be used again; its retained scalar capacity must not mask an empty buffer. + EXPECT_THAT(Score("a", "a", false, workspace), Optional(Eq(24))); +} + +TEST_F(FuzzyScoreTest, ImmutableQuerySharesNoWorkspaceBetweenThreads) { + const FzfQuery query("fl | foo"); + std::array workspaces; + std::array, 2> observed; + const auto evaluate = [&](std::size_t side, std::string_view text) { + for (int repeat = 0; repeat < 1'000; ++repeat) { + observed.at(side) = query.Percent(text, true, workspaces.at(side)); + } + }; + std::thread left(evaluate, 0, "fileName.txt"); + std::thread right(evaluate, 1, "a/long/path/foo.cc"); + left.join(); + right.join(); + EXPECT_THAT(observed.at(0), Eq(query.Percent("fileName.txt", true))); + EXPECT_THAT(observed.at(1), Eq(query.Percent("a/long/path/foo.cc", true))); +} + +TEST_F(FuzzyScoreTest, LongCandidateGapsRetainLowQualityMatches) { + const std::string prefix(1'500, 'a'); + const std::string text = prefix + "x"; + ScoreWorkspace workspace; + EXPECT_THAT(Matches("x", text, false), IsTrue()); + EXPECT_THAT(Score("x", text, false, workspace), Optional(Eq(-1'484))); + EXPECT_THAT(Percent("x", text, false, workspace), Optional(Eq(0))); + const FzfQuery query("x"); + EXPECT_THAT(query.Matches(text, false), IsTrue()); + EXPECT_THAT(query.Percent(text, false, workspace), Optional(Eq(0))); + const std::string separated = text + std::string(100, 'a') + "y"; + EXPECT_THAT(Score("xy", separated, false, workspace), Optional(Eq(-1'568))); +} + +TEST_F(FuzzyScoreTest, GiantInputDoesNotIncreaseRetainedWorkspaceMemory) { + ScoreWorkspace workspace; + EXPECT_THAT(Score("a", "abc", false, workspace), Optional(Eq(24))); + const std::size_t capacity = workspace.capacity; + const std::string giant(ScoreWorkspace::kMaxRetainedCharacters + 1, 'a'); + EXPECT_THAT(Score("a", giant, false, workspace), Optional(Eq(24))); + EXPECT_THAT(workspace.capacity, Eq(capacity)); + EXPECT_THAT(Score("ab", "a-b", false, workspace), Eq(Score("ab", "a-b", false))); + EXPECT_THAT(workspace.capacity, Le(ScoreWorkspace::kMaxRetainedCharacters)); +} + TEST_F(FuzzyScoreTest, FzfExtendedSearchCombinesSpaceSeparatedTerms) { // fzf's documented extended-search example: each space-separated term is required, while the // sigils choose prefix, suffix, and inverse-exact matching.