Skip to content

feat(search): query-aware truncation#147

Merged
jahala merged 2 commits into
mainfrom
feat/qtrunc-query-aware-truncation
Jun 25, 2026
Merged

feat(search): query-aware truncation#147
jahala merged 2 commits into
mainfrom
feat/qtrunc-query-aware-truncation

Conversation

@jahala

@jahala jahala commented Jun 16, 2026

Copy link
Copy Markdown
Owner

What

Makes select_diverse_lines (the smart-truncation line selector) query-aware: lines containing the searched symbol get a score boost (and their ±1 neighbours a smaller one), so they survive truncation.

Why

The selector kept generically "important" lines (signatures, control flow, calls) but was blind to what the agent searched for — so the very lines mentioning the target symbol could be truncated away, forcing another search. There's a single production call site, and the matched symbol (m.def_name) was already in scope there.

How

  • Replace the dead _lang param with query: Option<&str> (the slot the design always intended) — also removes end-to-end dead plumbing, since the call site no longer extracts lang just to ignore it.
  • Pass 2 (only when query is Some and non-empty): QUERY_MATCH_BOOST (50) on lines containing the symbol, QUERY_PROXIMITY_BOOST (5) on ±1 neighbours, atop the structural score. Integer, deterministic, saturating_add.
  • None/empty query → pass 2 skipped → byte-identical to before.
  • Symbol-name only; threading the raw multi-word query string is a deliberate follow-up.

Tests

  • query_boost_rescues_needle_line — differential: a low-score line carrying the symbol is dropped with None but kept with Some (it sits past the 40-line keep-window; score 1→51).
  • query_boost_preserves_neighbour — the ±1 neighbour survives via the proximity boost.
  • empty_query_behaves_like_noneSome("")None.

472 tests pass · clippy -D warnings clean · fmt clean.

🤖 Generated with Claude Code

jahala and others added 2 commits June 25, 2026 14:58
When an expanded body exceeds the smart-truncate threshold, select_diverse_lines now boosts lines containing the searched symbol (and their +/-1 neighbours) so the lines the agent actually asked about survive truncation instead of being dropped for generically important ones. The single production call site already had the matched symbol (m.def_name) in scope. Replaces the dead _lang param with the query slot (the design's intent), removing end-to-end dead plumbing. None/empty query is byte-identical to before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Query-aware truncation passed `def_name` as the symbol to boost. For
impl/implements matches `def_name` is the rendered label ("impl Iterator
for Counter" / "Type implements Trait"), which never appears in the
body — so the boost was a no-op for that whole class of matches. Route
the searched symbol via a new `boost_query` helper: `impl_target` (the
trait/interface the user searched for) for impl matches, falling back to
`def_name` for plain definitions.

Also fold query-match detection into the structural-scoring pass so each
body line is read once, dropping the redundant second `lines.get` and the
always-true `is_some_and` guard. Empty queries normalise to `None` up
front, keeping the no-query path byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jahala jahala force-pushed the feat/qtrunc-query-aware-truncation branch from 2565c12 to 344446d Compare June 25, 2026 12:59
@jahala jahala merged commit 76abb65 into main Jun 25, 2026
8 checks passed
paulnsorensen added a commit to paulnsorensen/tilth that referenced this pull request Jul 4, 2026
…et alloc, Bash grammar, tilth_savings) (#102)

* feat(read): never-worse outline gate (jahala#146)

(cherry picked from commit c4803af)

* feat(grok): auto-expand thin wrappers (jahala#149)

(cherry picked from commit 3f40056)

* fix(grok): size-cap thin-wrapper delegate reads; harden the predicate

The cross-file delegate read in thin-wrapper auto-expansion called
fs::read_to_string with no size bound, so a wrapper resolving to a huge
or generated callee file would slurp the whole thing into memory. Route
the read through a new `read_delegate_content` helper that reuses the
already-loaded target content for same-file callees and caps cross-file
reads at the shared source-file bound (bloom_walk::MAX_FILE_SIZE). The
cap is a parameter, so it is unit-tested with a tiny fixture instead of
a 500KB one.

Also harden the predicate:
- Capture the sole internal callee pre-cap (`lone_internal_callee`) so
  expansion is correct regardless of `caps.max_callees`, replacing the
  post-cap `callees_internal.first()` and its misleading "safety" note.
- Add a `start_line > 0` guard so a degenerate/unresolved target cannot
  produce an empty delegate body, mirroring `slice_body`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9df5120)

* ci(release): dual-publish npm wrapper as @plotplot/tilth, best-effort (jahala#144)

(cherry picked from commit e9155c1)

* feat(search): query-aware truncation (jahala#147)

(cherry picked from commit 76abb65)

* feat(search): value-based budget allocation (rate-distortion) (jahala#150)

(cherry picked from commit 66ae97e)

* fix(sync): dedup read_delegate_content after grok cherry-picks

The 9df5120 size-cap pick applied cleanly but produced a duplicate
definition of read_delegate_content alongside 3f40056's copy. Remove
the redundant second definition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(lang): add Bash tree-sitter support (jahala#151)

(cherry picked from commit bc2d7e8)

* fix(sync): thread edit_mode into format_matches test call site

The value-based budget pick (66ae97e) added a segments-collecting test
that calls format_matches, but our fork's format_matches also takes an
edit_mode arg. Pass false at that call site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): runtime token-savings tool (tilth_savings) (jahala#148)

(cherry picked from commit 63553eb)

* feat(mcp): add readOnlyHint annotations to tool definitions

Read-only MCP clients (e.g. Claude Code plan mode) gate any tool that does not declare readOnlyHint, prompting on every call. Mark the read tools read-only and tilth_write as not.

(cherry picked from commit 8196d27)

* fix(callers): 2nd-hop analysis + scaled budget + unified headers for multi-target

Addresses 3 unresolved PR review findings on the comma-separated
multi-target callers search:

- HIGH: the multi-target path omitted the single-target path's
  "Adaptive 2nd-hop impact analysis" entirely, so callers("a,b")
  returned strictly less than two separate callers("a") /
  callers("b") calls. Extracted the block into write_second_hop_impact
  (previously inline in search_callers_expanded) so both paths call
  the same code per target bucket.
- MED: BATCH_EARLY_QUIT (a walk-wide raw-match budget shared across
  every target in the batch walk) was not scaled by target count, so
  an earlier hit-rich target could exhaust the walk before a later,
  rarer target's files were visited. Added scaled_batch_quit, a pure
  function multiplying the single-target budget by target count
  (bounded — dispatch already caps multi-target queries at 5). The
  early-quit mechanism itself is a coarse heuristic and a candidate
  for future removal/replacement; this is a minimal parity fix, not
  an investment in it.
- MED: header format diverged between single (`# Callers of "foo" in
  <scope> — N call site(s)` / `## path:line`) and multi (`## callers
  of "foo"` / `### path:line`) purely on comma-presence. Extracted
  write_caller_bucket so both paths render the identical shape; a
  bucket inside a multi-target query is now byte-identical to what a
  lone single-target search of that symbol would produce.

Both extractions move existing logic verbatim rather than
reimplementing it — search_callers_expanded's behavior is unchanged
except for the mechanical format!->writeln! swap clippy requires when
appending to a shared &mut String instead of constructing the first
String.

Tests: scaled_batch_quit_multiplies_by_target_count,
scaled_batch_quit_treats_zero_targets_as_one (direct unit tests on
the pure scaling function — deterministic, unlike asserting on the
parallel walker's actual starvation behavior),
callers_multi_target_includes_second_hop_impact_per_bucket,
callers_multi_target_header_matches_single_target_shape,
callers_multi_target_later_target_not_starved_by_hit_rich_earlier_target
(scenario-level guard), plus two pre-existing tests updated for the
corrected header casing (Callers vs callers).

(cherry picked from commit 8e6c1bd)

* fix(benchmark): correct three ground-truth strings per grading audit

trustedCIDRs -> isTrustedProxy in gin_client_ip: the field is real but
sits one call-depth past ClientIP()'s own body; isTrustedProxy is the
method ClientIP() actually calls, same signal, zero-hop-deeper. Drop
view.js from express_render_chain and express_app_render: the file is
imported via require('./view') (Node's extensionless convention) and
never appears as literal text in source, so the string was unwinnable
regardless of answer quality.

Applied exactly as proposed in docs/research/grading-audit-2026-07.md
§6 — verified against the pinned commit via git grep, cross-checked by
an independent manual read, zero flips on the three flagship hard
tasks. rg_search_dispatch/glue.rs deliberately left untouched: same
bug class, but flagged as a maintainer decision, not auto-applied,
since its failures are genuine navigation misses, not phrasing gaps.

(cherry picked from commit f195bb9)

* fix(search): suppress outline preview line when a fence will reprint it

format_single_match always printed the raw "→ [line] text" preview
before checking whether expansion would run. expand_match's fence
range always contains m.line (def_range starts at m.line for
definitions; the ±10 fallback for def_range: None / usages trivially
contains it too), so whenever expansion actually fires the preview
line is a byte-identical duplicate of a line inside the fence below.

Repro: searching a <50-line file for a definition with no doc comment
(e.g. TilthError::exit_code) printed the matched line twice — once as
the "→ [31] pub fn exit_code(&self) -> i32 {" preview, once again as
line 31 inside the whole-file expansion fence.

Hoists the dedup/skip decision (previously computed after the
preview) into a `fence_will_follow` predicate checked before writing
either raw preview line (small-file branch and the no-outline
fallback). The structural outline_context branch is untouched — it
renders neighboring entries' signatures, not the matched line's own
source, so it doesn't duplicate the fence.

(cherry picked from commit d791449)

* fix(search): thread caller budget into fit_to_budget — value-based truncation now engages below 24k

Both fit_to_budget call sites (search/mod.rs format_search_result and
search_multi_symbol_expanded) hardcoded crate::budget::DEFAULT_BUDGET
(24,000) instead of the caller's real budget, so value-based match
selection only ever activated when the unconstrained render exceeded
24k tokens — every realistic caller budget silently fell through to
budget::apply's positional tail-cut instead (docs/research/tilth-audit-2026-07-logic.md §3).

Threads budget: Option<u64> through the four *_expanded search
functions and format_search_result, defaulting to DEFAULT_BUDGET via
unwrap_or when None so the no-budget path stays byte-identical. Wires
the already-extracted real budget at both call sites that have one:
MCP's tool_search (mcp/tools/search.rs) and the CLI's run_inner
(lib.rs), whose outer budget::apply pass is now a no-op once
fit_to_budget already selected within the same real budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2f596b0)

* refactor(map): share the gitignore/hidden/skip-dirs walker policy with search

map.rs built its own inline WalkBuilder duplicating search::walker()'s
ignore/hidden/git_* flags and SKIP_DIRS filter_entry — but silently
missing same_file_system(true) (stop at NFS/external mount
boundaries), a guard every other walker in this codebase carries
(search::walker, search::find_basename_fallback).

Diffed the two policies: the ignore/hidden/gitignore flags were
already identical between map and search (no deliberate divergence to
document there). The only structural differences are max_depth
(map-specific, bounds the tree to the CLI depth arg) and sequential
vs parallel execution (map incrementally builds a BTreeMap per-entry;
search fans out across threads) — both stay as-is, since collapsing
map onto the parallel model would be a control-flow rewrite, not a
policy fix.

Extracted the shared flags into search::base_walk_builder(scope) ->
WalkBuilder. walker() adds .threads()+overrides+.build_parallel();
map::generate adds .max_depth()+.build(). map now inherits
same_file_system(true) for free.

(cherry picked from commit 21c4faf)

* test: add direct unit coverage for treesitter weight/name extraction and sibling resolution

lang/treesitter.rs::definition_weight and ::extract_definition_name
had zero direct tests — only exercised transitively through
search/symbol.rs's larger integration tests. Added:

- definition_weight_covers_every_tier: one assertion per weight tier
  (100/90/80/70/60/40/30/default-50), spot-checking a node kind from
  each source language shape the match arms group together.
- Five extract_definition_name cases against real parsed nodes (Rust
  function_item, Python class_definition, TS export_statement
  unwrapping, TS lexical_declaration's variable_declarator walk, and
  the None fallthrough for impl_item, which has no name/identifier/
  declarator field and isn't handled by any special-cased branch).

search/siblings.rs had no test module at all. Added 8 tests for the
pure resolution logic named in the brief (resolve_siblings,
find_parent_entry) — signature-copy, signature-fallback-to-name,
unmatched-name skip, function-before-field ordering, alphabetical
tiebreak, MAX_SIBLINGS truncation, and parent-lookup hit/miss. Scoped
to resolution only, not the tree-sitter query extraction
(extract_sibling_references) — that's a much larger, per-language
surface the brief didn't name.

Verified both suites have teeth: mutated definition_weight's
impl_item/object_declaration arm (90->91) and resolve_siblings'
sort comparator (b_is_fn.cmp(&a_is_fn) -> a_is_fn.cmp(&b_is_fn)),
confirmed each mutation failed its respective test, then reverted.

(cherry picked from commit be6195d)

* fix(sync): resolve clippy findings from upstream ports

Apply clippy's suggested rewrites for uninlined_format_args,
format_push_string (writeln! to avoid write_with_newline on the
trailing newline), format_collect (fold), and doc_markdown backticks.
Delete unused #[cfg(test)] Session::reads_count (leftover from the
tilth_savings port; not present or used upstream).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): derive read view-meta from actual output, not would_outline prediction

The never-worse outline gate (OGATE, from upstream c4803af) can return
full content for a file that would_outline predicts as outlined, which
left the meta header claiming view:"outline" over a [full] body and
prompted a pointless re-read. Key the meta off the emitted view marker.

Also rebuild the large-structured-read test fixture with values longer
than the outline's 40-char preview cap, so the keys outline actually
compresses and the gate stays off — same fixture adjustment upstream
made to its own alias test in c4803af.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: jahala <jahala@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Martin Wedvich <wedvich@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant