Skip to content

Wave 4: 3-layer code search — intersection / ranking / formatting / CLI + correctness + cleanup - #368

Open
dean0x wants to merge 564 commits into
mainfrom
wave/wave4-search
Open

dean0x wants to merge 564 commits into
mainfrom
wave/wave4-search

Conversation

@dean0x

@dean0x dean0x commented Jun 26, 2026

Copy link
Copy Markdown
Owner

This branch lands the complete three-layer code search system into main: a lexical trigram index, a temporal co-change index, an AST structural index, and a compound engine that intersects and re-ranks all three. It includes the Wave 4 feature foundation (tickets 4a through 4d), three rounds of dog-fooding correctness work (waves 4/5 correctness tickets #393 through #412), and the 2026-08/09 robustness pass (#413, #414, #407, #409). The branch also removes the skim search index legacy positional subcommand (#375), which supersedes the v2.11.0 disambiguation shipped in #385: skim search index now runs a lexical query for the word "index". No part of this search surface was previously in main.
Branch tip: 0f0d0bd (2026-09-05)

Tickets merged on this branch

Wave 0, Foundation

Wave 1, Lexical Index

Wave 2, Temporal Index

Wave 3, AST Structural Index

Wave 4, Compound Engine

Wave 4 dog-fooding round 1 (2026-06-23)

Wave 4 dog-fooding round 2 (2026-06-28) and cleanup

Wave 4/5 dog-fooding round 3 (2026-07 through 2026-08)

2026-08/09 robustness pass

Behaviour changes users will notice

The items below are from the [Unreleased] section of CHANGELOG.md at the branch tip. Only breaking or user-visible changes are listed; additive JSON keys, internal staleness self-heals, and automatic index rebuilds are omitted.

Breaking (automatic index rebuild required on first query after upgrade):

Removed:

  • skim search index as a build command is gone. Builds use --build, --rebuild, or --update. A bare skim search index now runs a lexical query for the word "index".

Added:

  • --offset N pagination across all query arms, including standalone temporal.
  • --phrase --near N composition (ordered and span-bounded simultaneously).
  • -- end-of-flags separator: everything after -- is treated as literal query text.
  • ast_coverage in --ast --json and --stats --json when files exceed the 1 MiB AST cap.
  • git_head_state, temporal_state, and staleness keys in --stats --json.
  • degraded array in --json output when a temporal arm cannot be served.
  • Query echo in text-mode summary: N result(s) for "query" in Tms.

Changed:

  • Single-token queries use AND-intersect plus raw occurrence-count ranking. Multi-word queries continue to use BM25F UNION. Result ordering for single-token queries may differ after upgrade.
  • --blast-radius temporal axis ranks by Jaccard co-change strength, seed first. Files with zero co-change partners no longer appear as their own result.
  • Temporal degradation messages are now reason-specific (unresolved HEAD, empty build, repository mismatch, shallow clone) rather than a single generic string.
  • --root <subdirectory> adopts the enclosing repository's HEAD and scopes temporal rows to the subtree.
  • --install-hooks/--remove-hooks in a linked worktree now route to the shared commondir/hooks directory and disclose that scope on stderr.

Verification

CI: The tip commit is 0f0d0bd; CI runs 33979208549 and 33979206924 were in progress at the time of writing, see the Checks tab.

Local gates (cold cache, Rust 1.98, on branch tip 0f0d0bd):

  • cargo fmt -- --check: clean
  • cargo clippy --all-targets --all-features -- -D warnings on Rust 1.98: clean
  • cargo nextest run -p rskim-search: 1106 passed, 2 skipped
  • cargo nextest run -p rskim --all-targets: 5427 passed (4 slow), 3 skipped
  • cargo test -p rskim-search --doc and cargo test -p rskim --doc: clean

Independent wave-report verifications:

Two read-only verification agents independently audited the engine-produced wave reports against the gate journal artifacts and source code at the merge commits:

Both verifications are on record in the repo tree under .devflow/docs/waves/. The .devflow/docs/ tree is listed in .gitignore; the paths are local to the clone.

ADR-007 round-4 dog-food campaign (promotion gate):

Six corpora, 136 probe rows, 5 findings confirmed: 0 P0, 2 P1, 3 P2. All 5 fixed in 5 commits (c2eac80, b545577, 718adff, 229c108, b3f3a72) and re-verified on a freshly built binary (skim 2.11.0 (b3f3a72)) across 9 verification checks, all PASS.

Full synthesis: .devflow/docs/reviews/dogfood-2026-09/synthesis.md (local path, gitignored).
Fix re-verification: .devflow/docs/reviews/dogfood-2026-09/reverify-fixes-results.md (local path, gitignored).

Known gaps and deferred work

Merge notes

Two reconciles of origin/main into the branch: the first at 67cdaff brought in v2.11.0, #428, #385, #488 and 14 additional main-branch fixes; the second brought in #508. No history rewrite occurred. Squash-merge expected per ADR-005; the merge decision is the author's.

The working tree carries an in-progress merge of origin/main into wave/wave4-search by a concurrent agent. That merge touches cli_temporal_first_parent.rs only (the same file that conflicted in the #409 integration) and is not part of this PR.

Housekeeping at merge time


Relates #174 (north star tracking issue, stays open).
Closes #198, #200, #201, #202, #286, #287, #289, #290.
Closes #355, #356, #357, #358, #364.
Closes #372, #373, #374, #375, #376, #377, #378, #379, #380, #381.
Closes #393, #394, #395, #396, #397, #399, #400, #402, #403, #404, #405, #408, #411, #412.
Closes #413, #414, #407, #409.
Closes #175, #176, #177, #178, #179, #180, #181, #182, #183, #184, #185, #186, #187, #188, #189, #190, #191, #192, #193, #194, #195, #196, #197, #199.

This was referenced Jul 2, 2026
…parator

merge.rs: the per-layer rank sort now uses score DESC, then FileId ASC
as the tiebreaker (AD-409-4).  Previously, equal-score entries within a
single layer had non-deterministic relative ranks because the sort was
only score DESC.  With SEED_STRENGTH=2.0 the blast target always ranks
first; equal-Jaccard co-change partners now rank deterministically by
FileId ASC rather than by arbitrary HashMap/Vec iteration order.

Extends the module doc with the per-layer rank determinism invariant.

merge_tests.rs: two new AC-3 tests:
- test_per_layer_equal_scores_rank_by_file_id_asc: layer
  [(FileId(7),0.5),(FileId(3),0.5)] weight 1.0 yields FileId(3) first
- test_per_layer_jaccard_order_drives_rank_not_file_id: layer
  [(FileId(0),0.1),(FileId(9),0.9)] weight 1.0 yields FileId(9) first

mod.rs: add one additive, truthful clarification to SEARCH_HELP_TEXT
(AD-409-8) that the temporal component of --weights now ranks
--blast-radius peers by co-change strength.

Fixes: #409
…G entry

Adds two unit tests to temporal_tests.rs:
- cochange_partner_strengths_carries_jaccard_both_directions: verifies
  cochange_partner_strengths extracts the correct Jaccard for each partner
  regardless of which column (file_a/file_b) the target appears in (AC-2).
- paths_to_file_ids_drops_unindexed_partners_and_excludes_seed_from_count:
  verifies unindexed partner drops and that the seed is excluded from the
  reported partner count (AC-7 / AD-409-7).

Adds four unit tests to query_tests.rs:
- test_ad409_temporal_weight_only_ranks_by_jaccard_not_alphabetical (AC-1)
- test_ad409_seed_file_ranks_first_in_temporal_layer (AC-2)
- test_ad409_both_layers_accumulate_with_temporal_rank_from_jaccard (AC-5)
- test_ad409_non_finite_jaccard_does_not_panic_and_stays_finite (AC-15/NaN)

Adds CHANGELOG entry for #409 under ### Changed, documenting the user-
visible consequence: blast-radius composite queries now rank co-change
partners by Jaccard DESC; --limit 1 returns the seed file.
…-1,4,6,7,16)

Adds crates/rskim/tests/cli_search_blast_weights.rs with six acceptance tests
against the FX-LINEAR fixture (5 commits; J(anchor,zstrong)=0.60 >
J(anchor,aweak)=0.40 > MIN_COCHANGE_JACCARD; byte-wise path order inverts
Jaccard order to expose the pre-#409 alphabetical defect):

- ac409_1_temporal_weight_only_follows_jaccard (AC-1): --weights 0,0,1
  ranks zstrong before aweak; seed ranks first.
- ac409_2_composite_temporal_order_equals_standalone_blast_order (AC-6):
  self-validating invariant — composite --weights 0,0,1 partner order equals
  standalone --blast-radius order (ADR-007 dog-food proxy).
- ac409_3_repeated_identical_query_is_byte_identical (AC-4): two
  consecutive --json queries produce byte-identical stdout; HashMap
  iteration order must not reach output.
- ac409_4_unindexed_partner_omission_is_disclosed (AC-7 / AD-409-7):
  partners absent from the lexical manifest produce a stderr notice and
  are omitted from results without panicking.
- ac409_16a_shallow_clone_ties_are_deterministic (AC-16a): two runs on
  a single-commit shallow clone produce byte-identical path order.
- ac409_16b_real_repo_shallow_degrades_gracefully (AC-16b): --blast-radius
  on the real repo (shallow CI clone) exits 0 with a "temporal" degraded
  element when temporal ranking is unavailable.

fmt and clippy (1.98.0) both EXIT=0.  AC-23 / PF-019 satisfied.
…_file_ids

The `paths_to_scored_file_ids` function (used by the composite blast-radius
query path `run_blast_radius_composite_query` -- weights 0,0,1) was missing
the AD-409-7 partial-drop notice that already existed in `paths_to_file_ids`.

When some co-change partner paths are absent from the indexed manifest
(e.g. files deleted from disk but still recorded in temporal.db via git
history), the notice:
  "skim search: blast-radius: N of M co-change partners not found in
   the indexed manifest (excluded from scoring)"
was never emitted for the composite query path, only for the standalone
AST path.

Fix: mirror the identical guard from `paths_to_file_ids` in
`paths_to_scored_file_ids` (dropped = allowlist_len - scored_len;
partner_count = allowlist_len - 1 to exclude the seed).

Also fixes the test fixture in `ac409_4_unindexed_partner_omission_is_disclosed`:
- Redesigned commit layout so only aweak.rs is anchor.rs's co-change partner
  (J=1.0), not zstrong.rs (J=0.0 -- never co-changed with anchor in the fixture).
  The old 3-commit layout gave both aweak and zstrong J=2/3, so the notice
  would say "1 of 2" not "1 of 1" as the test expected.
- Added backup/restore of temporal.db: the build-time ghost filter (AD-408-1)
  removes co-change rows for files absent on disk, so a naive delete-then-rebuild
  removes the (anchor, aweak) row from temporal before the query can observe it.
  Backing up temporal.db before the second build and restoring it afterward
  creates the genuine "in temporal but not in lexical manifest" state the notice
  path is designed for.
- Added `find_temporal_db` helper to locate temporal.db in the per-root cache
  subdir without replicating the SHA256 path logic in tests.

Closes: AC-7 / AD-409-7 Gate-1 failure.
… comment

- Extract the duplicated eprintln! drop-notice block from
  paths_to_file_ids and paths_to_scored_file_ids into a shared private
  helper emit_partial_drop_notice(allowlist_len, found).  The message
  text is now in one place, eliminating the risk of the two call sites
  drifting apart.  Both call sites retain their AD-409-7 anchor in
  their containing doc-comment.

- Rephrase the AD-409-6 comment in run_blast_radius_composite_query
  from tombstone language ("sort_unstable_by_key is deleted") to a
  forward-looking statement ("rank derivation is delegated to
  merge_layer_scores").  The anchor ID is preserved; the phrase
  describing a removed artifact is not.
9-pillar self-review of #409 (blast-radius Jaccard ranking).

P0-Functionality — AC-7 "exactly one stderr line" was violated on the
composite blast-radius arm.  execute_query_with_manifest hoisted
paths_to_file_ids above the compound/composite dispatch, but that helper
has a stderr side effect (the AD-409-7 partial-drop notice) and the
composite arm then emitted the same notice a second time from
paths_to_scored_file_ids.  The FileId allowlist is only consumed by the
compound (text+--ast) arm, so it is now built inside that branch —
one notice per query, and one fewer O(manifest) pass on the composite arm.
The E2E guard now asserts the notice appears EXACTLY once (it previously
only asserted `contains`, so the duplicate passed).

P0-Functionality — restored the pre-#409 membership semantics for a
non-empty blast-radius allowlist that resolves to zero FileIds (every
partner and the target deleted from disk, or a target that is not indexed).
#409 retyped the AD-413-16 early-out from the resolved HashSet<FileId> to
the source path map, which narrowed it to the AnchorDiffers sentinel only;
the resolved-empty case fell through and returned the plain lexical hit
list under a --blast-radius flag that had contributed nothing — a confident
ranking that is not a blast radius (ADR-009).  Re-asserted as a companion
temporal_layer.is_empty() guard, which is the one that actually mirrors
run_compound_query's filter_set.is_empty() early-out as the comment claims.
New discriminating regression test; the existing empty-allowlist guard
(AC-24) still passes unchanged.

P1-Functionality — paths_to_scored_file_ids emitted the partial-drop
notice unconditionally, so a fully-unresolved allowlist rendered the
nonsense "N of N-1 co-change partners not found".  It now mirrors
paths_to_file_ids' two-branch guard via a shared
emit_no_indexed_files_notice helper, so both arms disclose the same
condition with the same wording.

P1-Tests — ac409_3_repeated_identical_query_is_byte_identical compared raw
stdout bytes including the wall-clock duration_ms field, so AC-4 was one
loaded-machine timing jitter away from flaking.  It now excludes only that
line (and asserts the field is still present so the normalizer cannot
silently match everything), and adds a non-vacuous guard (PF-007).

Verified: cargo fmt --check, cargo check -p rskim --all-targets,
cargo nextest -p rskim (64 blast/ac409/ad409/cochange tests, all 10 AC-24
guards green), rustup run 1.98.0 cargo clippy -p rskim-search and -p rskim
--all-features --all-targets -D warnings (both clean).  No new
unwrap/expect/panic!/direct indexing in rskim-search (AC-23); AD-409-1..8
anchors intact, no orphans (AC-21).
…ac409_16b (PF-007)

Two Gate-2 Evaluate alignment fixes:

AC-7 / AD-409-7: The old partial-drop count `allowlist_len − found` was
off-by-one when the seed itself was absent from the manifest: the seed
would be double-counted as a "dropped partner".  Fix tracks `seed_resolved`
during the manifest scan (the seed carries `SEED_STRENGTH = 2.0`, a value
no Jaccard score can equal) and computes:

  partner_count  = allowlist_len − 1
  partners_found = found − seed_resolved_as_int
  dropped        = partner_count − partners_found

`emit_seed_unindexed_notice()` fires when the seed is absent; the
partner-drop notice is suppressed when dropped == 0 even if the seed is
missing.  Same logic applied to both `paths_to_file_ids` and its scored
twin `paths_to_scored_file_ids`.

AC-7 regression test `ac409_7_seed_unindexed_notice`: the prior fixture
deleted the seed from disk, but `normalize_blast_radius_path` requires
the seed to exist on disk, and the working-tree staleness scan (AD-379-5)
would also detect the restored file as "1 added" and auto-rebuild.  Fix:
use a binary (non-UTF-8) seed file — `target.bin` goes to `skipped_entries`
during indexing (NonUtf8 content skip), so it is never in manifest entries,
exists on disk (normalize succeeds), and the staleness scan sees it as a
previously-known skipped file (not newly added).

PF-007 / ac409_16b: the byte-identical comparison was vacuous against
`duration_ms` timing noise.  Fix: parse both JSON outputs and compare only
the ranked result-path list, which is the actual determinism invariant.
…xity)

Five confirmed findings fixed:

1. [low/security] Clamp DB-sourced Jaccard to [0.0, 1.0] at trust boundary in
   cochange_partner_strengths so no corrupt co-change row (including one with
   jaccard == 2.0) can impersonate the seed sentinel. NON_FINITE_JACCARD_FLOOR
   moved to module scope; cochange_partner_strengths uses the full finite-range
   guard (j.is_finite() && j >= 0.0 && j <= 1.0) instead of is_finite() alone.

2. [medium/architecture] + 3. [medium/complexity] Eliminate the duplicate
   resolver: paths_to_file_ids now delegates to paths_to_scored_file_ids
   (one .into_iter().map(|(id,_)| id).collect() line) instead of maintaining a
   parallel 30-line body. Membership parity, notice dispatch (emit_no_indexed_files_notice,
   emit_seed_unindexed_notice, emit_partial_drop_notice), and PF-004 widening
   now have exactly one implementation (AD-409-7).

4. [low/architecture] BlastRadiusStrengths alias used in enum variants:
   BlastRadiusResolution::Allowed and Filtered.allow now spell the type as
   BlastRadiusStrengths (not bare HashMap<String, f64>). The alias is added
   to the existing `use super::types::{...}` import so consumers and producers
   use the same spelling.

5. [medium/complexity] emit_partial_drop_notice simplified to two parameters
   (partner_count, partners_found): partners_found is a direct counter
   incremented in the scan loop (not derived from totals after the fact),
   removing the seed_bit / saturating_sub juggling and the 15-line proof-by-
   comment. The remaining arithmetic is one saturating_sub.

Verified: cargo check -p rskim --all-targets → EXIT=0; cargo fmt exits 0.
…ch, collapse duplicate guards

- Extract blast_temporal_layer helper (hoisted before Step 1 / lexical
  search) handles BOTH the Some(empty) AnchorDiffers sentinel and the
  "allowlist non-empty but nothing resolves" case in a single early-out,
  restoring the pre-#409 ordering that avoided a wasted BM25F corpus pass
  when the temporal layer is unresolvable (finding: medium/performance)
- Remove the two split AD-413-16 guards (~90 lines apart) and their
  duplicate nine-field QueryOutput literals; replace with the single
  `let Some(temporal_layer) = blast_temporal_layer(...) else { ... }` site
  before the lexical search (finding: low/architecture duplicate guards)
- blast_temporal_layer adds an `allowed.is_empty()` short-circuit for
  AnchorDiffers: skips paths_to_scored_file_ids to avoid a redundant
  "0 indexed files" stderr notice on top of the upstream mismatch notice
- Renumber steps: old Step 3/4/5/6 become Step 2/3/4/5 to reflect the
  temporal layer now being resolved before Step 1
- Remove stale inline comments: "When blast_radius_paths is None (temporal
  DB absent or not requested), degrades to lexical-only ranking via the
  empty default" — wrong on both counts (finding: medium/complexity)
- Update blast_temporal_layer docstring to accurately describe when
  paths_to_scored_file_ids is and is not called
  (finding: low/architecture stale contract comment)

cargo check -p rskim --all-targets: EXIT=0, zero warnings
…on+reliability)

Five confirmed findings fixed:

1. [medium/consistency] Normalize three blast-radius stderr notice prefixes to
   the established `"skim search: note: "` convention (all three previously used
   `"skim search: blast-radius: "` or `"skim search: blast-radius filter"`).
   Export two as `pub(super) const` — BLAST_RADIUS_SEED_UNINDEXED_NOTICE and
   BLAST_RADIUS_PARTNER_NOT_FOUND — so intra-crate tests can assert against a
   single source of truth (consistent with WEIGHTS_FULLY_INERT_NOTICE and
   WEIGHTS_TEMPORAL_INERT_NOTICE in query.rs).

2. [medium/consistency] Fix stale rustdoc on `paths_to_file_ids` that claimed it
   was "the single source of truth for all three blast-radius call sites" — now
   correctly describes it as the membership-only twin of `paths_to_scored_file_ids`
   with its two actual call sites (compound text+AST arm and
   `resolve_blast_radius_file_ids`).

3. [low/consistency] BlastRadiusStrengths type alias already applied consistently
   in commit 442fa92 (confirmed in-place, no additional change needed).

4. [low/regression] Replace per-entry float comparison for seed identity
   (`jaccard == SEED_STRENGTH` in the scan loop) with a single pre-computed
   `seed_path: Option<&str>` extracted from the allowlist by `find_map`.
   Seed identity is now carried explicitly as a path rather than inferred from
   a sentinel value.  Seedless allowlists (e.g. AC-24 guard tests with all-1.0
   maps) set `seed_path = None`, suppressing the seed-unindexed notice entirely
   and preventing a false user-facing disclosure.  Also correct `partner_count`
   computation: `allowed_paths.len()` when no seed is present (not `len() - 1`).

5. [low/reliability] `paths_to_file_ids` now uses explicit
   `HashSet::with_capacity(scored.len())` to match the pre-sized pattern
   established by `paths_to_scored_file_ids`, making the allocation intent clear.

Verified: cargo check -p rskim --all-targets → EXIT=0; cargo fmt exits 0.
…_assert, fix pre-existing compile/clippy issues

query.rs:
- Extract `fn empty_output(config, ctx, vm_label)` helper and replace the
  three byte-identical QueryOutput zero-result literals in run_compound_query
  and run_blast_radius_composite_query.  Adding a field to QueryOutput now
  requires one edit instead of three (finding 1: low/complexity).
- Add `debug_assert!(config.blast_radius_paths.is_some(), …)` at the top of
  run_blast_radius_composite_query to make the pre-condition checkable rather
  than narrative (finding 4: medium/reliability, reliability.md).
- Document the stderr disclosure asymmetry between the AST and composite blast-
  radius arms for the AnchorDiffers sentinel (finding 3: low/regression).
  The composite arm correctly skips the "0 indexed files" notice; the AST arm
  emits it (intentional, pre-#409 parity); both sites now explain why.
- Findings 2, 4 (stale comment), and 5 were already fixed by the prior commit
  (282bf03) which removed the stale "degrades to lexical-only" sentence and
  rewrote the temporal-layer guard into blast_temporal_layer.  The debug_assert
  above completes finding 4 per its "either/or" fix options.

temporal.rs (pre-existing uncommitted work):
- Export BLAST_RADIUS_SEED_UNINDEXED_NOTICE and BLAST_RADIUS_PARTNER_NOT_FOUND
  constants so tests can assert against a single source of truth.
- Add "note: " prefix to all blast-radius stderr notices for consistent style.
- Identify the seed by path (seed_path: Option<&str>) not by float value so
  seedless allowlists never trigger a false seed-unindexed notice.
- Fix clippy::manual_range_contains in cochange_partner_strengths (j >= 0.0
  && j <= 1.0 → (0.0..=1.0).contains(&j)).
- Refactor paths_to_file_ids to use HashSet::with_capacity.

tests (pre-existing uncommitted + new fixes):
- common/git_fixture.rs: new shared helpers (git_init, git_commit,
  write_and_stage, now_epoch) extracted from per-test duplicates.
- common/mod.rs: add `pub mod git_fixture` declaration so the module is
  reachable via `use common::git_fixture::{…}`.
- cli_search_blast_weights.rs: switch from inline git helpers to
  common::git_fixture (pre-existing change; was missing the mod.rs
  declaration that caused cargo check failures).
- cli_temporal_first_parent.rs: remove unused `use std::fs` import (clippy
  -D warnings at 1.98 toolchain).

Closes #409
…(PF-007, AD-409-7, AC-12/13/19)

Finding 1 (ac409_16b): replace run_search_raw → run_search_raw_ok, unwrap_or(Null)
→ .expect(...), remove optional if-let degraded branch — assertion is now
unconditional because make_merge_commit_fixture() guarantees temporal is always
unavailable (merge-commit HEAD + shallow clone = zero non-merge commits).

Finding 2 (ac409_4, ac409_7): replace run_search_raw → run_search_raw_ok (AC-7
exit-0 requirement now verified), add positive JSON-parse + path-in-results
assertions so the tests are no longer vacuously green on stdout (PF-007).

Finding 3 (ac409_7b): new test — seed absent AND ≥1 partner dropped combination.
Both AD-409-7 notices must fire ("target file not found" + "1 of 2 co-change
partners not found").  partner_count excludes the seed slot (saturating_sub(1)),
so "1 of 2" is the correct count, not "2 of 2" or "1 of 3".

Finding 4 (ac409_ac12, ac409_ac13, ac409_ac19): new tests for AC-12 (--weights
0,0,0 returns zero results), AC-13 (per-page pagination is disjoint and
concatenates to the full unpaginated result), and AC-19 (text + --blast-radius
+ --hot exits 0 with parseable JSON).

Finding 5 (extract): common/git_fixture.rs extracted in a prior commit; this
commit's temporal.rs hunk is doc-comment-only clarification (no behaviour
change) staged from an earlier phase of the #409 review cycle.
…g at all sites

Finding 3: query.rs:535 called AC-7 "exactly one stderr line" while temporal.rs
accurately states "at most two" lines (seed-unindexed + partner-drop are separate
disclosures) and that "exactly one of the two functions runs per query dispatch
path." Reconcile by rewriting the query.rs comment to say what AC-7 actually
governs — no double-reporting across dispatch paths — matching temporal.rs.
Also clarify temporal.rs:433 "exactly once per query" → "each fires at most once
per query" to remove the ambiguity with the "at most two" bound.

Finding 4: remove before/after transition narration at four locations:
- query.rs AD-409-7 comment: drop "hoisting it above the dispatch made ... TWICE"
  narrative; keep the invariant (one function per dispatch path, no double-report).
- query.rs early-out comment: "restoring the pre-#409 early-out ordering" →
  "matching the early-out ordering of the standalone temporal arm."
- temporal.rs BlastRadiusResolution::Allowed: drop "Retyped from HashSet<String>
  ... instead of uniform 1.0" narration; replace with end-state description of
  the type's semantics and downstream contract (AD-409-2).

Findings 1 and 2 from this batch were already resolved by prior commits
(282bf03, commit range on this branch).

compile: cargo check -p rskim --all-targets → EXIT=0
- Remove stale #526 from three comment sites (finding 1); #483 is the
  correct tracker for the --json/degraded key for dropped partners.
  #526 is a different defect (text+--blast-radius+--hot composite discard).

- Expand paths_to_file_ids AD-409-7 paragraph (finding 2) to explicitly
  document the zero-match branch ("matched 0 indexed files") alongside
  the seed-unindexed and partial-drop branches, mirroring the wording
  already used in paths_to_scored_file_ids.

- Replace dead name resolve_blast_radius_filter with the live symbol
  resolve_blast_radius_file_ids at both doc sites (finding 3):
  resolve_blast_radius_paths() shared-core sentence and
  resolve_blast_radius_file_ids() call-site bullet.
The sentence in SEARCH_HELP_TEXT describing Jaccard-strength ranking cited
(AD-409-8) — an internal decision-record ID that end users cannot resolve.
Every other cross-reference in that help text uses a public issue number
(#199, #200, #377, etc.).

Fix: replace (AD-409-8) with (#409) in the printed string, and place the
AD-409-8 anchor in the adjacent /// doc comment above SEARCH_HELP_TEXT,
consistent with how all other AD-409-* anchors are written (rustdoc / //
comments, never user-facing output).  AC-21 grep is still satisfied.

Also applies cargo fmt cleanup to ast_tests.rs (std::collections::HashMap
qualified paths collapsed to local use + bare type).
…up, ordering assertion

Fixes four confirmed findings from the review batch:

[medium/testing BLOCKING] Finding 4 — fix fixture byte-order confusion.
  - Rename create_ac409_project seed file anchor.rs → zzanchor.rs so it
    sorts LAST (aweak < zstrong < zzanchor). The pre-#409 defect placed the
    alphabetically-first file at rank 1; with zzanchor at FileId(2) the
    seed-ranks-first assertion can only pass via SEED_STRENGTH, never by
    accident from FileId-ASC ordering.
  - Correct four stale byte-order comments in query_tests.rs (create_ac409_project
    doc, test_ac409_temporal_weight_only doc) and cli_search_blast_weights.rs
    (module doc lines 16-18, ac409_1 doc, inline comment): anchor < aweak <
    zstrong ('n' < 'w'), not aweak < anchor < zstrong as previously stated.

[low/consistency] Finding 3 — rename test_ad409_* → test_ac409_* (4 tests)
  to match surrounding AC-criterion naming (test_ac12_*, test_ac13_*, helper
  create_ac409_project, E2E tests ac409_1_*, ac409_2_*, …).

[low/complexity BLOCKING] Finding 1 — replace the self-negating comment and
  two redundant is_finite() asserts at the end of test_ac409_both_layers…
  with a concrete ordering assertion: hit.rs (fused score 0.5/61+0.2/62)
  outscores zpartner.rs (0.2/61) and must appear first in results.

[low/complexity SHOULD-FIX] Finding 2 — extract duplicated fixture code:
  - Add temporal_only_weights() → CompositeWeights (replaces 3 identical
    8-line literals in query_tests.rs).
  - Add blast_config(root, cache, text, allowed, weights) → QueryConfig
    (replaces 3 identical 12-field QueryConfig blocks).
  - Add write_and_stage_bytes(dir, filename, &[u8]) to common/git_fixture.rs
    (bytes sibling of write_and_stage); update import and use it in
    ac409_7_seed_unindexed_notice, eliminating two inline git-add blocks.

Refs #409
…ank claim, add target-file notice

Finding 1 (medium): The 'returns the seed' sentence under User-visible consequences was
unconditionally phrased, implying the seed always wins at --limit 1. Added qualifier
'with a temporal-dominant --weights (e.g. 0,0,1)' and a follow-up clause explaining
that at default weights (0.5,0.3,0.2) a file with a strong lexical match may still
outrank the seed in the composite score — matching the plan's own AC-5 worked example.

Finding 2 (low): The unindexed-partners stderr disclosure bullet omitted the second
notice: when the blast-radius target file itself is absent from the indexed manifest,
emit_seed_unindexed_notice fires a separate line on stderr. Added a clause describing
that case alongside the existing partner-count notice.
…nest comment

The paths_to_file_ids_drops_unindexed_partners_and_excludes_seed_from_count
test asserted locally-recomputed arithmetic that always held given the
preceding cardinality assertions:

  dropped       = allowed.len() - file_ids.len()   -- the OLD formula
  partner_count = allowed.len() - 1                 -- constant from setup

Both assertions were tautologies: 'dropped == 1' follows automatically
from 'allowed.len() == 3' and 'file_ids.len() == 2', and the OLD formula
(all_dropped / all_total) is the one emit_partial_drop_notice REPLACED
with (partner_count, partners_found) in production.  Sub-case B had no
stderr capture, so a regression that fired the notice on zero drops would
not be caught.

Fix: delete the tautological blocks from both sub-cases.  The cardinality
and FileId membership assertions (file_ids.len(), contains/not-contains)
are the real coverage.  The exact notice text and the production formula
are covered end-to-end by ac409_4_unindexed_partner_omission_is_disclosed.
Update the docstring to state this split explicitly.
…-008)

Replace stale pre-#409 type and symbol names in both feature-knowledge files:

cmd-search/KNOWLEDGE.md:
- Frontmatter keywords: replace cochange_partner_paths with cochange_partner_strengths;
  add paths_to_scored_file_ids, BlastRadiusStrengths, BlastRadiusResolution, SEED_STRENGTH
- Architecture map: same symbol updates in temporal.rs line
- resolve_blast_radius_paths signature: Option<HashSet<String>> → BlastRadiusResolution
- QueryConfig struct: blast_radius_paths: Option<HashSet<String>> → Option<BlastRadiusStrengths>
- Symbol index for temporal.rs: add new symbols, remove cochange_partner_paths
- Anti-pattern: mention paths_to_scored_file_ids alongside paths_to_file_ids

search-temporal/KNOWLEDGE.md:
- Dispatch flow: HashSet → BlastRadiusStrengths + BlastRadiusResolution
- Blast-radius combined-mode section: update prose, code block, and key-insight paragraph
- Anti-pattern section: blast_radius_paths HashSet → BlastRadiusStrengths
- types.rs key-files entry: add BlastRadiusStrengths type annotation
…d helper

- `paths_to_file_ids` (temporal.rs): replace the manual `with_capacity` +
  `extend` with an iterator chain `.collect()`. `Vec::into_iter()` carries an
  exact `size_hint`, so `HashSet::from_iter` pre-sizes identically; the
  three-line manual form added no allocation benefit and its comment was
  inaccurate. Idiomatic collect is shorter and easier to audit.

- `cli_search_blast_weights.rs`: extract the eight-line `StdCommand` body
  shared by `run_search_raw` and `run_search_raw_ok` into a private
  `search_command` helper. Both callers delegate to it; the exit-check logic
  in `run_search_raw_ok` is unchanged. Future env-var or flag changes need
  one edit instead of two.
Three documentation-accuracy defects in the #409 diff (P2-Documentation,
ADR-001 "fix noticed issues immediately"; PF-008 doc-drift).

1. search-temporal/KNOWLEDGE.md claimed the text + --blast-radius path
   "converts to a FileId allowlist (HashSet<FileId>) and injects as
   SearchQuery.file_filter". That is false for the composite arm:
   run_blast_radius_composite_query deliberately runs the lexical search
   with NO file_filter over a wide K x limit pool (UNION semantics,
   truncate-LAST). Only the compound text+--ast arm sets a file_filter.
   #409 rewrote these sentences and strengthened the false claim.
   Replaced with a two-arm table and an explicit anti-pattern forbidding a
   file_filter on the composite arm.

2. The same file's new resolution snippet said
   "::Degraded/Filtered -> blast_radius_paths stays None". Filtered maps
   to Some(EMPTY), not None -- that Some(empty) != None distinction is
   AD-413-16 / AC-9 and is load-bearing (collapsing it falls through to an
   unfiltered lexical search on a wrong-repo temporal DB). Corrected the
   four-arm mapping and called the invariant out.

3. cmd-search/KNOWLEDGE.md said paths_to_file_ids / paths_to_scored_file_ids
   "use binary search" (O(n log n)). Neither does -- there is no
   binary_search in temporal.rs; each performs ONE linear pass over
   sorted_paths with an O(1) map lookup per entry (AD-409-5). #409 extended
   the fabricated rationale from one function to two. Corrected the claim and
   restated the real requirement: FileId is the slice POSITION, so the slice
   must be manifest.sorted_paths() or the FileIds are silently wrong.

Also replaced a line-number reference in the run_blast_radius_composite_query
precondition comment ("execute_query_with_manifest line ~587") with the symbol
name of the dispatch gate -- it was already stale by 9 lines in its own commit.

Comment/markdown only; no behaviour change.
Verified: cargo fmt --check, cargo check -p rskim --all-targets,
rustup run 1.98.0 cargo clippy -p rskim-search/-p rskim
--all-features --all-targets -- -D warnings (all exit 0).
…Jaccard), seed first

# Conflicts:
#	crates/rskim/tests/cli_temporal_first_parent.rs
…he measured structural amplification (ADR-003)
…ed top window; document the zero-result early-out

Replace the near-vacuous ac409_ac19_blast_hot_exits_zero (which only
asserted exit 0 and JSON parse) with ac409_ac19_blast_hot_resorts_fused_
top_window_by_hotspot — a real AC-19 guard.

New FX-HOT-DIVERGE fixture makes the fused (Jaccard-based) ordering and
the hotspot ordering visibly different: zzrare.rs is the strong Jaccard
partner (J=0.75) with only old commits (low hotspot), while active.rs is
the weak partner (J~=0.286) with many recent solo commits (high hotspot).
Fused temporal order: [seed, zzrare, active]. After --hot re-sort of the
top resort_window(3)=100 entries: [active, seed, zzrare].

The test computes the expected order in-test from two independent CLI
queries (fused without --hot, standalone --hot for scores) and
cross-references against the actual compound result. A non-vacuity guard
asserts the two orderings differ before comparing. resort_window arithmetic
is replicated in-test with a comment naming the source function.

CHANGELOG: document the temporal_layer.is_empty() early-out (AD-413-16
semantics restored) — a blast-radius allowlist whose partners all resolve
to zero indexed files now returns zero results with the partial-drop
notice, rather than silently falling back to an unfiltered lexical list.
…number

F-C4-01 (dog-food 2026-09, corpus c4-merge, probe A-02).

Root cause (the reported hypothesis was falsified): the minified-line guard
plays no part.  A 955-byte line whose marker is a normal word token is scored
and anchored correctly.  The failing shape is a marker GLUED to a long run
(`AAAA...AAAAmk4_longline_marker` is one 919-byte word token), which the
AD-411-7 token_length gate correctly refuses to count as a whole-token match.
search_exact_intersection then emits the file as a substring-only candidate,
score 0.0 with an EMPTY match_positions vec, so it can stay in the result set
for git-grep recall parity (ADR-007).

The defect is in snippet.rs::extract_snippet_and_verify: the AD-396-5 guard
nulls the content-derived anchor whenever match_positions is empty, which it
intended as the "<3-byte short query" signal.  Substring-only candidates share
that shape, so a verified match came back with line_number/line_range/snippet
all null even though substring_first_anchor had just located the occurrence.

Narrow the guard to its real condition, a query that produces no trigram
(extract_query_ngrams(...).is_empty() -- the same predicate the reader uses to
route to short_query_fallback), documented as AD-396-8.  Short queries stay
snippet-less; no line-length cap is introduced, so a 20 KB single line anchors
too.  Score 0.0 for substring-only candidates is unchanged and deliberate.

Tests:
  snippet_tests.rs::test_substring_only_long_token_gets_anchor_ad396_8
  snippet_tests.rs::test_substring_only_20kb_line_gets_anchor_ad396_8
  tests/cli_search_anchor.rs (new E2E through the binary):
    f_c4_01_long_line_substring_match_is_anchored
    f_c4_01_20kb_line_substring_match_is_anchored
    f_c4_01_short_query_remains_unanchored  (AD-355-7 scope guard)
…ast-radius

F-C2-02 (dog-food 2026-09, corpus c2-shallow, probe 409-09).

Root cause: run_temporal_standalone passes `sort` to open_temporal_state_for,
whose emptiness probe is per-sort-dimension.  With `--blast-radius FILE` as the
only temporal flag there is no sort, so `None` is passed and no probe runs (G-3
keeps co-change emptiness from borrowing the empty/shallow wording).  An
entirely empty temporal.db therefore reached query_standalone and produced
`{"mode":"blast-radius","target":...,"total":0,"results":[]}` with no degraded
key and an empty stderr, byte-indistinguishable from a healthy DB in which the
file simply has no co-change partners.  The composite arm
(resolve_blast_radius_paths) has always disclosed this case, so the two arms
disagreed about the same DB state.  Every other reason (missing / corrupt /
newer-schema / repository-mismatch / not-a-repo) was already disclosed by the
Unavailable arm.

Fix (AD-414-24): after the DB opens, the standalone arm runs the composite arm's
exact predicate — partner set empty AND hotspot table empty — and reports the
same element (requested "blast-radius", applied "none", reason from the SSOT
DegradedReason) plus the stderr notice.  Mirroring the composite predicate keeps
a synthetic DB with cochange rows but no hotspot rows from being misreported as
empty; the cheap dimension probe is evaluated first so a healthy DB never pays
for the partner lookup.  The stderr/JSON emit shape is extracted into
report_standalone_degraded so both standalone early-outs stay identical, and
empty_temporal_state becomes the single query-time producer of Empty.

Tests (cli_search_degraded.rs):
  f_c2_02_standalone_blast_radius_discloses_degraded_state  (empty + newer-schema)
  f_c2_02_healthy_zero_partner_blast_radius_stays_quiet     (negative)
F-C2-01 (dog-food 2026-09, corpus c2-shallow, probe 414-07).

Root cause: DegradedReason::Empty already had a shallow branch in `cause` and
`full_message`, keyed on `detail == "shallow"`, but the only producer of that
detail was the BUILD-time zero-row notice in temporal_build.rs.  Every
query-time producer constructed `Empty` with an empty detail, so on a genuine
`--depth 1` clone (meta.is_shallow = 1, confirmed in the DB) the stderr notice
and degraded[].message advised `skim search --rebuild`, which re-derives the
same zero rows because the history is not in the clone.  `remediation` was
`&'static str` and detail-independent by design, so degraded[].remediation
could not carry the unshallow advice at all.

Fix (AD-414-25):
  - empty_temporal_state now reads META_IS_SHALLOW through the already-open
    connection (no extra DB open) and sets the "shallow" detail, so all three
    query-time arms inherit the branch from one builder.
  - DegradedReason::remediation_for(detail) becomes the single emit-site entry
    point; `remediation` is private and holds the detail-independent base table.
  - SHALLOW_EMPTY_REMEDIATION is now the one source for both the message tail
    and the JSON remediation, so a DegradedJson can never advise something its
    own message contradicts (AD-414-1 SSOT).

A meta read failure or an absent row degrades to the non-shallow wording; a
shallow claim is never fabricated.

Tests:
  temporal_tests.rs::f_c2_01_empty_notice_branches_on_shallow_detail
  temporal_tests.rs::f_c2_01_empty_temporal_state_reads_meta_is_shallow
  cli_search_degraded.rs::f_c2_01_shallow_clone_query_notice_names_unshallow
  t19b_remediation_text_conformance updated for the new entry point.
…ge partners

F-C1-01 (dog-food 2026-09, corpus c1-healthy, Gate-2 #409 AC-20).

Root cause: resolve_blast_radius_paths inserted the target with SEED_STRENGTH
unconditionally, after the zero-partner branch had already printed "no co-change
data for X".  With an empty partner set the allowlist was therefore {seed: 2.0},
which paths_to_scored_file_ids turned into a one-element temporal layer, so the
composite arm returned the seed as its own co_change_partner scoring exactly
temporal_weight / 61 (0.2/61 with default weights) even though no co-change
relation exists.

Plan check before changing anything: #409 AC-2 pins the seed at temporal rank 1
"via a finite sentinel strictly greater than the Jaccard maximum" and its fixture
(anchor.rs + zstrong.rs + aweak.rs) always has partners; AC-20 requires a target
with no co-change rows to "exit 0, emit the existing degraded/no-co-change stderr
notice unchanged, and MUST NOT fabricate any ranking".  No criterion asserts the
seed appears with zero partners, so there is no conflict: the fabricated
single-seed ranking is precisely what AC-20 forbids.

Fix (AD-409-9): inject the seed only when at least one cochange row resolved.
The resulting empty allowlist is the "contributes nothing" sentinel that
blast_temporal_layer already early-outs on (ADR-009), so all blast-radius arms
return zero results — matching the standalone arm for the same target.
paths_to_scored_file_ids now returns before its manifest scan on an empty
allowlist, so the redundant "matched 0 indexed files (allowed 0 paths, ...)"
line no longer follows the notice that already explained the situation.

Behaviour change recorded in CHANGELOG: the composite arm no longer surfaces
text matches inside a zero-partner target.

Test: cli_search_blast_weights.rs::ac409_9_zero_partner_seed_is_not_ranked
(asserts results == [] with the notice intact, AND that a target WITH a partner
still ranks first, so the change cannot over-reach).
414-12-message (dog-food 2026-09, corpus c3-absent-rows, finding F-C3-01).

The stderr notice is printed as `skim search: {message}` by the CLI output
layer, so the JSON `message` field is the notice text WITHOUT that prefix.
CLAUDE.md, the AD-414-1 rustdoc on `degraded_notice` / `DegradedJson::new` /
`DegradedJson.message` / `blast_radius_degraded_msg`, and the #414 CHANGELOG
entry all read as a byte-identity claim between the JSON field and the stderr
line, which a `message == first_stderr_line` comparison fails.

Resolution is documentary: the code is correct as it stands.  The prefix is
terminal presentation, not part of the machine-readable contract, and adding it
to the JSON would make every consumer strip it.  The docs now state the actual
relationship (`stderr.contains(message)` holds byte-for-byte; the two strings
are not equal) and tell a consumer that wants the stderr line verbatim to
prepend the prefix.

The CLAUDE.md `search` paragraph also picks up the two contract additions from
this batch: the shallow-clone remediation (AD-414-25) and the standalone
`--blast-radius` degraded array (AD-414-24).
… wave/wave4-search

# Conflicts:
#	.github/workflows/ci.yml
#	crates/rskim/src/cmd/search/mod.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment