C# method calls need a receiver gate in tier2_same_file and tier3_import_boost — 30 of 34 measured wrong binds, including an enclosing method binding to itself #189

Closed
opened 2026-09-06 11:08:48 +02:00 by buildagent · 4 comments
Member

Measured while auditing the cs-dapper corpus delta from #172. That fix corrected malformed ref names (Query<Post> → Query) and the corrected names then reached the resolver, which bound 34 of them wrongly. The name fix is right and unavoidable — a ref name no symbol can bear is not a defensible thing to ship — but the malformed name had been acting as an accidental precision filter, and removing it exposed pre-existing over-binding.

This issue is about the over-binding, not about #172.

Measured

Two release binaries differing only in crates/plugins/src/csharp.rs; cs-dapper @ 72a54c475f indexed with each; refs joined row-for-row on (path, line, col, kind, occurrence-index). 22429 of 22432 refs matched 1:1; 64 newly resolved, 0 lost, 0 retargeted, plus 3 span-shifted type Link refs = the +67 in baseline.json.

Attributed by refs.resolved_by:

rule Δ correct wrong
tier1a_unique_own_file (10) +7 5 2
tier1b_same_directory (12) +23 21 2
tier1q_pass1 (40) +3 3 0
tier2_same_file (20) +21 4 17
tier3_import_boost (30) +13 0 13
+67 33 34

7+23+21+13+3 = 67, reconciling exactly with the five rule. deltas in stage-baseline.json.

tier1b_same_directory is NOT the problem, despite being the largest delta. It is 21 of 23 correct — it is where GetNullableValue ×18 and CastResult ×3 landed, the best binds in the whole change. I initially cited tier1b +23 and tier2 +21 as corroborating the over-binding and that was wrong; the arithmetic that should have caught it is that 23+21 = 44, not 34. The stage deltas partition all 67 new binds by rule, not the wrong subset. Anyone reading stage-baseline for this class must not repeat that.

30 of the 34 wrong binds are in tier2_same_file and tier3_import_boost.

Source exemplars, read at the site

1. The enclosing method binds to itself (tier2_same_file, ×6 for Query alone).

// benchmarks/Dapper.Tests.Performance/Benchmarks.RepoDB.cs
32:  public Post Query()
33:  {
34:      Step();
36:      return _connection.Query<Post>(i).First();   // -> binds to :32, itself

_connection.Query<Post>(…) is RepoDB's extension method on IDbConnection, external to the repository. The receiver is _connection, not this. Same shape at :43, :50, :57 (all binding to :32), at Benchmarks.Linq2DB.cs:45→:41, Benchmarks.XPO.cs:59→:55, and — most starkly —

// Dapper.Rainbow/Database.cs
375:  public T QueryFirstOrDefault<T>(string sql, dynamic param = null) =>
376:      _connection.QueryFirstOrDefault<T>(sql, param as object, …);  // -> binds to :375

a method's body binding to the method whose body it is.

2. A zero-arg test double beats the real, indexed target (tier3_import_boost, ×7).

// tests/Dapper.Tests/MiscTests.cs:1111
int i = connection.ExecuteScalar<int>("select 123");
    -> binds to tests/Dapper.Tests/WrappedReaderTests.cs:47
       public override object ExecuteScalar()          // zero args, no generic, a DbCommand override

while the correct target is in the index:

// Dapper/SqlMapper.cs:598
public static T? ExecuteScalar<T>(this IDbConnection cnn, string sql, …)

This is the sharpest case: the resolver had the right answer available and chose a test double in an unrelated file.

3. An unrelated same-name method in another file (tier3_import_boost, ×4).

// benchmarks/Dapper.Tests.Performance/Benchmarks.RepoDB.cs:23
DbSettingMapper.Add<Microsoft.Data.SqlClient.SqlConnection>(dbSetting, true);
    -> binds to benchmarks/Dapper.Tests.Performance/LegacyTests.cs:58
       private class Tests : List<Test> { public void Add(Action<int> iteration, string name) }

DbSettingMapper is a RepoDB static class. The bind crosses a file boundary into an unrelated private class.

4. The remainder (tier2_same_file, ×14): Fetch, Get, Read, ExecuteQuery, FindObject, GetObjectByKey — every one a sibling benchmark method binding to a library call with the same bare name (petapoco.Fetch<Post>, _session.Get<Post>, _connection.Read<Post>, session.FindObject<Xpo.Post>).

The common structural fact

Every wrong bind is a method_call with a captured receiver that the resolver did not use. The refs carry a qualifier (I030 receiver capture: _connection, petapoco, session, DbSettingMapper, pd), and in each case that receiver's type is either external or provably not the file/directory the bind landed in. tier2_same_file and tier3_import_boost are locality rules: they answer "is there a same-named symbol nearby?" without asking whether the receiver could possibly be it.

The correct binds have the opposite shape: GetNullableValue is a this SqlDataReader extension with a SqlDataReader receiver; pd.GetFactory<T> has pd = PocoData.ForType(…) bound in the same scope; database.QueryFirstOrDefault<T> has database typed to the class that declares it.

The pre-existing evidence, so this is not read as new

The rule already produced this shape for non-generic calls. Measured old → new for the same targets:

target before #172 after
Add → LegacyTests.cs:58 37 41
ExecuteScalar → WrappedReaderTests.cs:47 3 10
Query → Benchmarks.RepoDB.cs:32 0 4

The first two show the class is old and larger than the 34; the third shows it is not uniformly old, so "pre-existing" is true of the rule and not of every row.

Why existing gates could not see it

  • precision_gate is blind here by construction — its C# population is 3 files / 66 lines / 4 probes with zero generic call sites, it never indexes a corpus repo, and a phantom only scores against a declared decoy. Filed separately; that issue should be read before anyone treats phantoms=0 as clearance for a change here.
  • corpus_ratchet pins resolved as a count; a wrong bind and a right bind are both +1.
  • corpus_stage pins which rule claimed each bind and did move — but it cannot say whether a bind is correct, which is why the split above had to be adjudicated by reading 64 call sites.

What must NOT be done

  • Do not narrow tier1b_same_directory. It is the largest delta and it is 21/23 correct. Tightening it would remove GetNullableValue ×18 and CastResult ×3 — including the 3/3 that #172 exists to recover — to fix 2 wrong binds.
  • Do not disable tier2_same_file or tier3_import_boost wholesale. They carry large correct populations on every other language (tier2 alone is 239→260 here and in the thousands on rust-ripgrep). The defect is the missing receiver condition, not the tiers.
  • Do not gate on "the receiver is not this" alone. Dapper.Rainbow/Database.cs:376 calls through _connection, a field — not this — and is still wrong. The condition has to be about what the receiver's type CAN be, not about its spelling.
  • Do not special-case C#. The receiver-capture facts (I030) exist for all six languages and the locality tiers are shared. A fix should rest on the structural fact — a method_call whose receiver is captured and whose receiver type contradicts the candidate's owner must not be admitted by a locality rule — and its blast radius must be measured on all nine corpora, per rule, before and after, with the binds inspected and not just the deltas.
  • Do not measure this by the corpus delta. A count moving says nothing about which binds arrived. #172's own +67 looked like recall and was 33/34. Any change here must report correct/wrong per rule, adjudicated at source.
  • Do not re-record baseline.json or stage-baseline.json as the success criterion. Both were just blessed with the 33/34 attribution; a fix here should move them again, in the direction of fewer resolved binds, and that reduction is the evidence.

Measured vs inferred

The 64 binds, their per-rule attribution, the four source exemplars, the old/new counts for the three targets, and the 0-lost/0-retargeted figures are all measured on the pinned corpus. That the common structural fact is "a locality rule admitting a bind its captured receiver contradicts" is inferred from reading all 64 sites; it is a hypothesis for the fix, not a verified mechanism in the resolver, and the tier SQL should be read before anyone designs against it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K

Measured while auditing the `cs-dapper` corpus delta from #172. That fix corrected malformed ref names (`Query<Post>` → `Query`) and the corrected names then reached the resolver, which bound **34 of them wrongly**. The name fix is right and unavoidable — a ref name no symbol can bear is not a defensible thing to ship — but the malformed name had been acting as an accidental precision filter, and removing it exposed pre-existing over-binding. **This issue is about the over-binding, not about #172.** ## Measured Two release binaries differing only in `crates/plugins/src/csharp.rs`; `cs-dapper` @ `72a54c475f` indexed with each; refs joined row-for-row on `(path, line, col, kind, occurrence-index)`. 22429 of 22432 refs matched 1:1; **64 newly resolved, 0 lost, 0 retargeted**, plus 3 span-shifted `type Link` refs = the +67 in `baseline.json`. Attributed by `refs.resolved_by`: | rule | Δ | correct | wrong | |---|---|---|---| | `tier1a_unique_own_file` (10) | +7 | 5 | 2 | | `tier1b_same_directory` (12) | +23 | **21** | 2 | | `tier1q_pass1` (40) | +3 | 3 | 0 | | **`tier2_same_file` (20)** | +21 | 4 | **17** | | **`tier3_import_boost` (30)** | +13 | 0 | **13** | | | **+67** | **33** | **34** | 7+23+21+13+3 = 67, reconciling exactly with the five `rule.` deltas in `stage-baseline.json`. **`tier1b_same_directory` is NOT the problem, despite being the largest delta.** It is **21 of 23 correct** — it is where `GetNullableValue` ×18 and `CastResult` ×3 landed, the best binds in the whole change. I initially cited `tier1b +23` and `tier2 +21` as corroborating the over-binding and that was wrong; the arithmetic that should have caught it is that 23+21 = 44, not 34. The stage deltas partition **all 67 new binds** by rule, not the wrong subset. Anyone reading `stage-baseline` for this class must not repeat that. **30 of the 34 wrong binds are in `tier2_same_file` and `tier3_import_boost`.** ## Source exemplars, read at the site **1. The enclosing method binds to itself (`tier2_same_file`, ×6 for `Query` alone).** ```csharp // benchmarks/Dapper.Tests.Performance/Benchmarks.RepoDB.cs 32: public Post Query() 33: { 34: Step(); 36: return _connection.Query<Post>(i).First(); // -> binds to :32, itself ``` `_connection.Query<Post>(…)` is RepoDB's extension method on `IDbConnection`, external to the repository. The receiver is `_connection`, not `this`. Same shape at `:43`, `:50`, `:57` (all binding to `:32`), at `Benchmarks.Linq2DB.cs:45→:41`, `Benchmarks.XPO.cs:59→:55`, and — most starkly — ```csharp // Dapper.Rainbow/Database.cs 375: public T QueryFirstOrDefault<T>(string sql, dynamic param = null) => 376: _connection.QueryFirstOrDefault<T>(sql, param as object, …); // -> binds to :375 ``` a method's body binding to the method whose body it is. **2. A zero-arg test double beats the real, indexed target (`tier3_import_boost`, ×7).** ```csharp // tests/Dapper.Tests/MiscTests.cs:1111 int i = connection.ExecuteScalar<int>("select 123"); -> binds to tests/Dapper.Tests/WrappedReaderTests.cs:47 public override object ExecuteScalar() // zero args, no generic, a DbCommand override ``` while the correct target **is in the index**: ```csharp // Dapper/SqlMapper.cs:598 public static T? ExecuteScalar<T>(this IDbConnection cnn, string sql, …) ``` This is the sharpest case: the resolver had the right answer available and chose a test double in an unrelated file. **3. An unrelated same-name method in another file (`tier3_import_boost`, ×4).** ```csharp // benchmarks/Dapper.Tests.Performance/Benchmarks.RepoDB.cs:23 DbSettingMapper.Add<Microsoft.Data.SqlClient.SqlConnection>(dbSetting, true); -> binds to benchmarks/Dapper.Tests.Performance/LegacyTests.cs:58 private class Tests : List<Test> { public void Add(Action<int> iteration, string name) } ``` `DbSettingMapper` is a RepoDB static class. The bind crosses a file boundary into an unrelated private class. **4. The remainder (`tier2_same_file`, ×14):** `Fetch`, `Get`, `Read`, `ExecuteQuery`, `FindObject`, `GetObjectByKey` — every one a sibling benchmark method binding to a library call with the same bare name (`petapoco.Fetch<Post>`, `_session.Get<Post>`, `_connection.Read<Post>`, `session.FindObject<Xpo.Post>`). ## The common structural fact Every wrong bind is a **`method_call` with a captured receiver that the resolver did not use**. The refs carry a qualifier (I030 receiver capture: `_connection`, `petapoco`, `session`, `DbSettingMapper`, `pd`), and in each case that receiver's type is either external or provably not the file/directory the bind landed in. `tier2_same_file` and `tier3_import_boost` are **locality** rules: they answer "is there a same-named symbol nearby?" without asking whether the receiver could possibly be it. The correct binds have the opposite shape: `GetNullableValue` is a `this SqlDataReader` extension with a `SqlDataReader` receiver; `pd.GetFactory<T>` has `pd = PocoData.ForType(…)` bound in the same scope; `database.QueryFirstOrDefault<T>` has `database` typed to the class that declares it. ## The pre-existing evidence, so this is not read as new The rule already produced this shape for non-generic calls. Measured old → new for the same targets: | target | before #172 | after | |---|---|---| | `Add` → `LegacyTests.cs:58` | **37** | 41 | | `ExecuteScalar` → `WrappedReaderTests.cs:47` | **3** | 10 | | `Query` → `Benchmarks.RepoDB.cs:32` | **0** | 4 | The first two show the class is old and larger than the 34; the third shows it is not uniformly old, so "pre-existing" is true of the *rule* and not of every row. ## Why existing gates could not see it - `precision_gate` is blind here by construction — its C# population is 3 files / 66 lines / 4 probes with zero generic call sites, it never indexes a corpus repo, and a phantom only scores against a *declared* decoy. Filed separately; **that issue should be read before anyone treats `phantoms=0` as clearance for a change here.** - `corpus_ratchet` pins `resolved` as a count; a wrong bind and a right bind are both +1. - `corpus_stage` pins which rule claimed each bind and *did* move — but it cannot say whether a bind is correct, which is why the split above had to be adjudicated by reading 64 call sites. ## What must NOT be done - **Do not narrow `tier1b_same_directory`.** It is the largest delta and it is 21/23 correct. Tightening it would remove `GetNullableValue` ×18 and `CastResult` ×3 — including the 3/3 that #172 exists to recover — to fix 2 wrong binds. - **Do not disable `tier2_same_file` or `tier3_import_boost` wholesale.** They carry large correct populations on every other language (`tier2` alone is 239→260 here and in the thousands on rust-ripgrep). The defect is the missing receiver condition, not the tiers. - **Do not gate on "the receiver is not `this`" alone.** `Dapper.Rainbow/Database.cs:376` calls through `_connection`, a field — not `this` — and is still wrong. The condition has to be about what the receiver's type CAN be, not about its spelling. - **Do not special-case C#.** The receiver-capture facts (I030) exist for all six languages and the locality tiers are shared. A fix should rest on the structural fact — *a `method_call` whose receiver is captured and whose receiver type contradicts the candidate's owner must not be admitted by a locality rule* — and its blast radius must be measured on all nine corpora, per rule, before and after, with the binds inspected and not just the deltas. - **Do not measure this by the corpus delta.** A count moving says nothing about which binds arrived. #172's own `+67` looked like recall and was 33/34. Any change here must report correct/wrong per rule, adjudicated at source. - **Do not re-record `baseline.json` or `stage-baseline.json` as the success criterion.** Both were just blessed with the 33/34 attribution; a fix here should move them again, in the direction of *fewer* resolved binds, and that reduction is the evidence. ## Measured vs inferred The 64 binds, their per-rule attribution, the four source exemplars, the old/new counts for the three targets, and the 0-lost/0-retargeted figures are all **measured** on the pinned corpus. That the common structural fact is "a locality rule admitting a bind its captured receiver contradicts" is **inferred** from reading all 64 sites; it is a hypothesis for the fix, not a verified mechanism in the resolver, and the tier SQL should be read before anyone designs against it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
Author
Member

Verdict: CONFIRMED, and the mechanism is an INVERSION, not a missing gate

The gate you asked for already exists. It is recv_unproven, added by I037, and it was pointing the wrong way.

// crates/indexer/src/index.rs, before this change
let recv_unproven: &str = if has_qualifier {
    "(refs.kind = 'method_call' AND refs.qualifier IS NULL)"
} else { "0" };

A method_call was refused by the locality tiers only when the plugin could not name its receiver at all. _connection.Query<Post>(i) carries qualifier = '_connection', so it was recv_unproven = 0 — more eligible for same-file locality than a call with no receiver written at all. The receiver was captured precisely so tier 1R could decide it; when tier 1R had nothing to decide with, tiers 2 and 3 took the group on co-location alone.

The rest of the path, read at source:

  • crates/plugins/src/csharp.rs::emit_call emits a member_access_expression call as kind = method_call, qualified = false, qualifier = Some(receiver). All six plugins do the analogous thing (I030).
  • resolve_groups admits a ref when refs.qualified = 0 OR qualifier IS NULL, so a receiver-style call is in the locality pipeline by construction.
  • Tier 1Q never sees it, because qualified = 0.

Your hypothesis is confirmed as the mechanism, with one correction to the shape: the fix is not a new gate but a widening of the existing bit into a code.

The fix

recv_unproven stops being a boolean. index::recv_proof:

code meaning refused by
PROVEN (0) no receiver, or this/self/$this (whose type IS the enclosing declaration) nothing
UNCAPTURED (1) I037 — receiver exists, plugin could not name it tiers 1a, 2, 3
LOCALITY_BLIND (2) #189 — captured, non-self receiver in lang_profile::RECEIVER_LOCALITY_BLIND_PROFILES tier 2; tier 3's same-directory and import-KEY edges

Tier 1a is deliberately NOT gated. Its fact is candidate_n = 1 — the name has exactly one definition in the whole project pool — which is evidence about the candidate, not about proximity. Gating it too was built and measured: it costs cs-dapper PocoData.ForType ×18, Provider.GetMySqlConnection ×10 and pd.GetFactory ×5, every one read at source and correct.

Tier 3's third edge is deliberately NOT gated. A specifier that path-RESOLVES to a file NAMES the candidate; same-directory and import-key-stem GUESS it. Only the two that guess are refused.

Tier 1b is untouched, exactly as you asked — its same-directory arm is the sole signal the importless languages have.

Blast radius, bind-for-bind against a df1551f binary, joined on (path, line, col, kind, occurrence)

repo resolved Δ lost gained retargeted
cs-dapper 3811 → 3628 -183 183 0 0
php-guzzle 12031 → 11825 -206 206 0 371
js-express 4153 → 4153 0 0 0 0
py-django 107491 → 107491 0 0 0 0
python-flask 3026 → 3026 0 0 0 0
ruby-sinatra 2850 → 2850 0 0 0 0
rust-analyzer 143113 → 143113 0 0 0 0
rust-ripgrep 15709 → 15709 0 0 0 0
ts-zod 10781 → 10781 0 0 0 0

Seven of nine repos are byte-identical, which is the control that says the change is scoped.

corpus_stage shows where the binds went, and this is the part the resolved count cannot say:

[rule] cs-dapper: rule.tier2_same_file      260 -> 119 (-141)
[rule] cs-dapper: rule.tier3_import_boost   109 ->  30  (-79)
[rule] cs-dapper: rule.tier1r_receiver        3 ->  38  (+35)
[rule] cs-dapper: rule.csharp_extension      15 ->  17   (+2)
[rule] php-guzzle: rule.tier2_same_file     516 ->  31 (-485)
[rule] php-guzzle: rule.tier3_import_boost  183 ->   2 (-181)
[rule] php-guzzle: rule.tier1r_receiver     215 -> 675 (+460)

Tier 2 was pre-empting tier 1R. Tiers 1/2/3 apply before tier 1R runs, so a group tier 2 claimed never reached the pass that can actually type the receiver. Blocking it hands 495 groups across the two repos to tier 1R, and the 371 php-guzzle retargets are that, visible: every $factory->create(...) in tests/Handler/CurlFactoryTest.php moves from the same-file test double at :6446 to the real src/Handler/CurlFactory.php:227.

Did it remove the 34?

#172's +67, re-measured against a binary with csharp.rs reverted to 2f16e22, reproduces your table exactly (rules 10:+7, 12:+23, 20:+21, 30:+13, 40:+3). Under the fix:

  • 33 survive — all 7 tier-1a, all 23 tier-1b, all 3 tier-1Q.
  • 34 go — the 21 tier-2 and 13 tier-3. 30 of those are your measured wrong binds.
  • The other 4 are CORRECT and are lost with them: database.QueryFirstOrDefault at Database.cs:109/:118 and database.QueryFirstOrDefaultAsync at Database.Async.cs:67/:74. database is a parameter typed Database — the receiver whose type IS the target's owner — but it shares a decision group with the wrong _connection.QueryFirstOrDefault three lines away at :376. A tier cannot split a group finer than the gate does, and this gate does not distinguish database from _connection.
  • The remaining 4 wrong binds are the 2 in tier 1a and 2 in tier 1b, which you said not to chase. They are still there.

The other 149 cs-dapper removals, read at source

Every group above 3 occurrences was opened at the call site and at the target:

Phantoms (≈161):

×n site bound to
16 connection.ExecuteReader("select ...") MiscTests.cs:626 — public void ExecuteReader(), a zero-arg xunit [Fact]
46 p.Add("@Code", code), args.Add, results.Add, parameters.Add, number_list.Add, dict.Add, … ParameterTests.cs:37 — DbDynamicParams.Add(IDbDataParameter), one arg, a nested test class
12 r.GetValue(i) SqlMapper.cs:1375 — private static T GetValue<T>(DbDataReader, Type, object?), three args, private
11 i.ToString(), count.ToString(), Convert.ToString(...) SqlMapper.cs:190 — TypeMapEntry.ToString()
10 connection.ExecuteScalar<int>("select 123") WrappedReaderTests.cs:47 — zero-arg DbCommand override (your exemplar 2)
6 handler.Parse(type, val) SqlMapper.cs:3177 — private static T Parse<T>(object? value), one arg
6 reader.Dispose(), reader.DisposeAsync() in GridReader.Async.cs GridReader's own Dispose
4 key.GetHashCode(), commandType.GetHashCode(), … SqlMapper.Identity.cs:238
4 DbSettingMapper.Add<T> and siblings LegacyTests.cs:58 (your exemplar 4)
3 public void Close() => _conn.Close(); in TransactedConnection.cs itself — same for Dispose and CreateCommand
~27 _connection, _db, _dbFast, _session, _get, _dbContext, Session, Linq2SqlContext, GlobalConfiguration, petapoco, … sibling benchmark methods, several the enclosing one (your exemplars 1 and 3)
… Tuple.Create, Enum.Parse, SqlMapper.QueryMultiple, DynamicBulkCopy.Create (→ BulkCopy.Create)

Correct, and this is the measured cost (17): database.* ×9 in Dapper.Rainbow, DbWrappedReader.Create(cmd, reader) ×4 → WrappedReader.cs:86, ByTypeHelpers.Get(type) ×4 → DbConnectionExtensions.cs:70 / DbExceptionExtensions.cs:30. About five singletons were not individually adjudicated.

≈161 phantoms out for 17 correct binds — better than 9:1, against the 5.5:1 I060 accepted for the same kind of cut.

php-guzzle's 206, likewise at source: $e->getRequest() ×89 → src/TransferStats.php:51 while TransferException::getRequest sits indexed at src/Exception/TransferException.php:29; $handler->close() ×39 → an anonymous test double at CurlMultiHandlerTest.php:1887 whose real target is src/Handler/CurlMultiHandler.php:968; $easy->createResponse() ×15 → an anonymous ResponseFactoryInterface in EasyHandleTest.php:241; $request->getMethod() ×10 and ->getHeader() ×10 → test doubles. Roughly 15 of the 206 look correct.

RUBY IS REFUSED, and the refusal is the finding

I built the version with Ruby in it. It removes 239 binds on ruby-sinatra, nearly all core-method phantoms — options.delete(:locals) (Hash#delete) binding Sinatra's DELETE route DSL at base.rb:1543, response.body ×27 binding a test helper, to_s/respond_to?/inspect binding Object's. And it goes RED on precision_gate's own ruby oracle:

ruby: probe greet/method@lib/greeter.rb container=Some("Greeter") callers RECALL miss
  — expected site "main.rb::" to be resolution==resolved. Got [main.rb::=name_fallback]

main.rb does require_relative "lib/greeter" and calls greeter.greet(...). A require_relative names a file — but this resolver never sees it as one: temp.import_rel is built from imports.module GLOB '.*', and a Ruby require carries its relativity in the KEYWORD, not in a leading dot. So Ruby's only file-naming evidence arrives through the file-KEY edge, and gating that edge destroys declared ground truth.

I tried the obvious repair — let LOCALITY_BLIND keep the import-key edge — and measured it: it keeps the ruby probe green but gives back 118 php-guzzle getRequest phantoms and 119 ruby-sinatra phantoms for 5 cs-dapper binds. Rejected on the numbers.

Ruby is therefore out of the registry, with the reason and the prerequisite written into it, and ruby_captured_receiver_keeps_locality_because_a_require_names_a_file pins the decision. Its mutation — adding "ruby" to the registry — is RED. Teaching import_rel about require_relative is a separate change with its own corpus movement; I have not made it.

Two other things I built and refused on measurement

  1. "A captured receiver the file does not BIND is unproven", all languages. Destroys 805 rust-ripgrep binds: dir.create(...) ×334 and dir.command() ×150 where dir: Dir is a closure parameter inside the rgtest! macro, so no binding row exists, and Dir::create at tests/util.rs:102 is the correct target. Also -1170 py-django, -641 rust-analyzer.
  2. The same rule uniformly, with only the resolved-relative edge open: -6021 rust-analyzer, -3135 py-django, -1312 rust-ripgrep. Rust's use crate::util::Dir is an import-KEY edge, not a relative one.

Tests, each with its mutation RUN to real RED

crates/indexer/tests/receiver_phantom.rs, +7 tests, every anti-vacuity precondition asserted (receiver captured, pool ambiguous, no same-file candidate where tier 3 is under test, the import row present where edge 2 is under test):

mutation red
A — delete the LOCALITY_BLIND arm csharp + php same-file, csharp same-directory (3)
B — drop NOT IN (SELF_RECEIVERS_SQL) csharp_self_receiver_still_resolves_by_same_file_locality
C — gate tier 1a on PROVEN too csharp_project_unique_candidate_survives_a_captured_receiver
D — apply the gate to every language rust_captured_receiver_keeps_same_file_locality
E — add "ruby" to the registry ruby_captured_receiver_keeps_locality_because_a_require_names_a_file
F — ungate tier 3's same-directory edge 3 tests
G — ungate tier 3's import-key edge php_captured_receiver_does_not_bind_by_import_key_reachability
H — let tier 2 admit LOCALITY_BLIND 2 tests
I — make m0062 not re-resolve m0047_backfills_only_what_it_can_prove

B is worth a note: my first version of that test used this.Query() calling a method of its own class, and the mutation survived — tier 1R's self arm claims that shape, so the test proved nothing about the exemption. The self exemption is worth exactly 4 binds corpus-wide, and the one that matters is src/Exception/ResponseException.php:50 $this->getRequest() binding TransferException::getRequest two classes up — an inherited self call only locality can reach. The test now uses that shape.

Gates

cargo fmt --all -- --check                                   0
cargo clippy --workspace --all-targets -- -D warnings        0
cargo test --workspace --no-fail-fast                        0   (306 suites, 0 failed)
COSI_E2E_LEG=daemon cargo test -p code-index-mcp             0   (52 suites)
corpus_tier3_ratchet   (executed=2, require=1)               0   tier3-baseline.json UNMOVED
precision_gate -- --nocapture                                0   7/7, phantoms=0
corpus_ratchet                                             101   baseline.json moves — see below
corpus_stage                                               101   stage-baseline.json moves — see below

m0047_backfills_only_what_it_can_prove needed a real change, not a bless: it seeded refs at v46 and asserted on the state after the WHOLE chain, so it was coupled to "no later migration re-resolves". m0062 does, and computing influence from the live contribution graph is the resolver doing its job. The arm now stops at 47 to grade m0047, then completes the chain and asserts the column IS filled — mutation I is what makes that second assertion evidence.

The two protected records, NOT blessed

php-guzzle: resolved 12031 -> 11825 (-206), edges 7905 -> 7727 (-178),
            resolved_method_call 2827 -> 2621 (-206)
cs-dapper:  resolved  3811 ->  3628 (-183), edges 2710 -> 2613  (-97),
            resolved_method_call  509 ->  326 (-183)

Draft reason, for you to accept or reject:

#189: a captured method-call receiver is a NAME, not a TYPE, and the two tiers that decide on proximity were treating it as one. recv_unproven was a boolean that fired only when the plugin could NOT name a receiver (I037), so a ref that DID carry one was more eligible for locality than a ref that carried none — _connection.Query<Post>(i) bound to the enclosing public Post Query() four lines up. It is now a three-valued recv_proof code: a captured non-self receiver in RECEIVER_LOCALITY_BLIND_PROFILES (csharp, php; Ruby deliberately excluded and the registry says why) is refused by tier 2 and by tier 3's same-directory and import-key edges, while tier 1a's project-unique fact and tier 3's path-RESOLVED relative-specifier edge still apply.

Bind-for-bind against a df1551f binary joined on (path, line, col, kind, occurrence): cs-dapper -183 and php-guzzle -206, 0 gained, and 371 php-guzzle refs RETARGET from a same-file test double to the real src/ implementation because blocking tier 2 hands the group to tier 1R. The other seven pinned repos are BYTE-IDENTICAL, which is the control that says the change is scoped. Every removed group above 3 occurrences was read at source: ≈161 phantoms against 17 correct binds on cs-dapper (better than 9:1, against I060's accepted 5.5:1), and on php-guzzle $e->getRequest() x89 bound TransferStats while TransferException::getRequest sat indexed, plus x54 bound to anonymous test doubles. #172's +67 splits exactly as #189 measured it: all 33 correct binds in tiers 1a/1b/1Q survive, the 34 in tiers 2 and 3 go, and 30 of those were the wrong ones — the 4 correct ones lost with them share a decision group with a wrong bind three lines away, which a tier cannot split finer than the gate does.

Staged by path, not pushed, not blessed. Filing the Ruby require_relative prerequisite as its own issue.

## Verdict: CONFIRMED, and the mechanism is an INVERSION, not a missing gate The gate you asked for already exists. It is `recv_unproven`, added by I037, and it was pointing the wrong way. ```rust // crates/indexer/src/index.rs, before this change let recv_unproven: &str = if has_qualifier { "(refs.kind = 'method_call' AND refs.qualifier IS NULL)" } else { "0" }; ``` A `method_call` was refused by the locality tiers **only when the plugin could not name its receiver at all**. `_connection.Query<Post>(i)` carries `qualifier = '_connection'`, so it was `recv_unproven = 0` — *more* eligible for same-file locality than a call with no receiver written at all. The receiver was captured precisely so tier 1R could decide it; when tier 1R had nothing to decide with, tiers 2 and 3 took the group on co-location alone. The rest of the path, read at source: - `crates/plugins/src/csharp.rs::emit_call` emits a `member_access_expression` call as `kind = method_call, qualified = false, qualifier = Some(receiver)`. All six plugins do the analogous thing (I030). - `resolve_groups` admits a ref when `refs.qualified = 0 OR qualifier IS NULL`, so a receiver-style call is in the locality pipeline by construction. - Tier 1Q never sees it, because `qualified = 0`. **Your hypothesis is confirmed as the mechanism**, with one correction to the shape: the fix is not a new gate but a widening of the existing bit into a code. ## The fix `recv_unproven` stops being a boolean. `index::recv_proof`: | code | meaning | refused by | |---|---|---| | `PROVEN` (0) | no receiver, or `this`/`self`/`$this` (whose type IS the enclosing declaration) | nothing | | `UNCAPTURED` (1) | I037 — receiver exists, plugin could not name it | tiers 1a, 2, 3 | | `LOCALITY_BLIND` (2) | **#189** — captured, non-self receiver in `lang_profile::RECEIVER_LOCALITY_BLIND_PROFILES` | tier 2; tier 3's same-directory and import-KEY edges | **Tier 1a is deliberately NOT gated.** Its fact is `candidate_n = 1` — the name has exactly one definition in the whole project pool — which is evidence about the *candidate*, not about proximity. Gating it too was built and measured: it costs cs-dapper `PocoData.ForType` ×18, `Provider.GetMySqlConnection` ×10 and `pd.GetFactory` ×5, every one read at source and correct. **Tier 3's third edge is deliberately NOT gated.** A specifier that path-RESOLVES to a file NAMES the candidate; same-directory and import-key-stem GUESS it. Only the two that guess are refused. Tier 1b is untouched, exactly as you asked — its same-directory arm is the sole signal the importless languages have. ## Blast radius, bind-for-bind against a df1551f binary, joined on `(path, line, col, kind, occurrence)` | repo | resolved | Δ | lost | gained | **retargeted** | |---|---|---|---|---|---| | cs-dapper | 3811 → 3628 | **-183** | 183 | 0 | 0 | | php-guzzle | 12031 → 11825 | **-206** | 206 | 0 | **371** | | js-express | 4153 → 4153 | 0 | 0 | 0 | 0 | | py-django | 107491 → 107491 | 0 | 0 | 0 | 0 | | python-flask | 3026 → 3026 | 0 | 0 | 0 | 0 | | ruby-sinatra | 2850 → 2850 | 0 | 0 | 0 | 0 | | rust-analyzer | 143113 → 143113 | 0 | 0 | 0 | 0 | | rust-ripgrep | 15709 → 15709 | 0 | 0 | 0 | 0 | | ts-zod | 10781 → 10781 | 0 | 0 | 0 | 0 | Seven of nine repos are **byte-identical**, which is the control that says the change is scoped. `corpus_stage` shows where the binds went, and this is the part the resolved count cannot say: ``` [rule] cs-dapper: rule.tier2_same_file 260 -> 119 (-141) [rule] cs-dapper: rule.tier3_import_boost 109 -> 30 (-79) [rule] cs-dapper: rule.tier1r_receiver 3 -> 38 (+35) [rule] cs-dapper: rule.csharp_extension 15 -> 17 (+2) [rule] php-guzzle: rule.tier2_same_file 516 -> 31 (-485) [rule] php-guzzle: rule.tier3_import_boost 183 -> 2 (-181) [rule] php-guzzle: rule.tier1r_receiver 215 -> 675 (+460) ``` **Tier 2 was pre-empting tier 1R.** Tiers 1/2/3 apply before tier 1R runs, so a group tier 2 claimed never reached the pass that can actually type the receiver. Blocking it hands 495 groups across the two repos to tier 1R, and the 371 php-guzzle retargets are that, visible: every `$factory->create(...)` in `tests/Handler/CurlFactoryTest.php` moves from the same-file test double at `:6446` to the real `src/Handler/CurlFactory.php:227`. ## Did it remove the 34? `#172`'s +67, re-measured against a binary with `csharp.rs` reverted to `2f16e22`, reproduces your table exactly (rules 10:+7, 12:+23, 20:+21, 30:+13, 40:+3). Under the fix: - **33 survive** — all 7 tier-1a, all 23 tier-1b, all 3 tier-1Q. - **34 go** — the 21 tier-2 and 13 tier-3. **30 of those are your measured wrong binds.** - The other 4 are CORRECT and are lost with them: `database.QueryFirstOrDefault` at `Database.cs:109`/`:118` and `database.QueryFirstOrDefaultAsync` at `Database.Async.cs:67`/`:74`. `database` is a parameter typed `Database` — the receiver whose type IS the target's owner — but it shares a decision group with the wrong `_connection.QueryFirstOrDefault` three lines away at `:376`. **A tier cannot split a group finer than the gate does**, and this gate does not distinguish `database` from `_connection`. - The remaining 4 wrong binds are the 2 in tier 1a and 2 in tier 1b, which you said not to chase. They are still there. ## The other 149 cs-dapper removals, read at source Every group above 3 occurrences was opened at the call site and at the target: **Phantoms (≈161):** | ×n | site | bound to | |---|---|---| | 16 | `connection.ExecuteReader("select ...")` | `MiscTests.cs:626` — `public void ExecuteReader()`, a **zero-arg xunit `[Fact]`** | | 46 | `p.Add("@Code", code)`, `args.Add`, `results.Add`, `parameters.Add`, `number_list.Add`, `dict.Add`, … | `ParameterTests.cs:37` — `DbDynamicParams.Add(IDbDataParameter)`, **one arg**, a nested test class | | 12 | `r.GetValue(i)` | `SqlMapper.cs:1375` — `private static T GetValue<T>(DbDataReader, Type, object?)`, **three args, private** | | 11 | `i.ToString()`, `count.ToString()`, `Convert.ToString(...)` | `SqlMapper.cs:190` — `TypeMapEntry.ToString()` | | 10 | `connection.ExecuteScalar<int>("select 123")` | `WrappedReaderTests.cs:47` — zero-arg `DbCommand` override (your exemplar 2) | | 6 | `handler.Parse(type, val)` | `SqlMapper.cs:3177` — `private static T Parse<T>(object? value)`, **one arg** | | 6 | `reader.Dispose()`, `reader.DisposeAsync()` in `GridReader.Async.cs` | `GridReader`'s own `Dispose` | | 4 | `key.GetHashCode()`, `commandType.GetHashCode()`, … | `SqlMapper.Identity.cs:238` | | 4 | `DbSettingMapper.Add<T>` and siblings | `LegacyTests.cs:58` (your exemplar 4) | | 3 | `public void Close() => _conn.Close();` in `TransactedConnection.cs` | **itself** — same for `Dispose` and `CreateCommand` | | ~27 | `_connection`, `_db`, `_dbFast`, `_session`, `_get`, `_dbContext`, `Session`, `Linq2SqlContext`, `GlobalConfiguration`, `petapoco`, … | sibling benchmark methods, several the enclosing one (your exemplars 1 and 3) | | … | `Tuple.Create`, `Enum.Parse`, `SqlMapper.QueryMultiple`, `DynamicBulkCopy.Create` (→ `BulkCopy.Create`) | | **Correct, and this is the measured cost (17):** `database.*` ×9 in Dapper.Rainbow, `DbWrappedReader.Create(cmd, reader)` ×4 → `WrappedReader.cs:86`, `ByTypeHelpers.Get(type)` ×4 → `DbConnectionExtensions.cs:70` / `DbExceptionExtensions.cs:30`. About five singletons were not individually adjudicated. **≈161 phantoms out for 17 correct binds — better than 9:1**, against the 5.5:1 I060 accepted for the same kind of cut. php-guzzle's 206, likewise at source: `$e->getRequest()` ×89 → `src/TransferStats.php:51` **while `TransferException::getRequest` sits indexed at `src/Exception/TransferException.php:29`**; `$handler->close()` ×39 → an anonymous test double at `CurlMultiHandlerTest.php:1887` whose real target is `src/Handler/CurlMultiHandler.php:968`; `$easy->createResponse()` ×15 → an anonymous `ResponseFactoryInterface` in `EasyHandleTest.php:241`; `$request->getMethod()` ×10 and `->getHeader()` ×10 → test doubles. Roughly 15 of the 206 look correct. ## RUBY IS REFUSED, and the refusal is the finding I built the version with Ruby in it. It removes **239** binds on ruby-sinatra, nearly all core-method phantoms — `options.delete(:locals)` (`Hash#delete`) binding Sinatra's **DELETE route DSL** at `base.rb:1543`, `response.body` ×27 binding a test helper, `to_s`/`respond_to?`/`inspect` binding `Object`'s. And it goes **RED on `precision_gate`'s own ruby oracle**: ``` ruby: probe greet/method@lib/greeter.rb container=Some("Greeter") callers RECALL miss — expected site "main.rb::" to be resolution==resolved. Got [main.rb::=name_fallback] ``` `main.rb` does `require_relative "lib/greeter"` and calls `greeter.greet(...)`. **A `require_relative` names a file** — but this resolver never sees it as one: `temp.import_rel` is built from `imports.module GLOB '.*'`, and a Ruby require carries its relativity in the KEYWORD, not in a leading dot. So Ruby's only file-naming evidence arrives through the file-KEY edge, and gating that edge destroys declared ground truth. I tried the obvious repair — let `LOCALITY_BLIND` keep the import-key edge — and measured it: it keeps the ruby probe green but **gives back 118 php-guzzle `getRequest` phantoms and 119 ruby-sinatra phantoms** for 5 cs-dapper binds. Rejected on the numbers. Ruby is therefore **out of the registry, with the reason and the prerequisite written into it**, and `ruby_captured_receiver_keeps_locality_because_a_require_names_a_file` pins the decision. Its mutation — adding `"ruby"` to the registry — is RED. Teaching `import_rel` about `require_relative` is a separate change with its own corpus movement; I have not made it. ## Two other things I built and refused on measurement 1. **"A captured receiver the file does not BIND is unproven"**, all languages. Destroys **805** rust-ripgrep binds: `dir.create(...)` ×334 and `dir.command()` ×150 where `dir: Dir` is a **closure parameter inside the `rgtest!` macro**, so no `binding` row exists, and `Dir::create` at `tests/util.rs:102` is the correct target. Also -1170 py-django, -641 rust-analyzer. 2. **The same rule uniformly, with only the resolved-relative edge open**: -6021 rust-analyzer, -3135 py-django, -1312 rust-ripgrep. Rust's `use crate::util::Dir` is an import-KEY edge, not a relative one. ## Tests, each with its mutation RUN to real RED `crates/indexer/tests/receiver_phantom.rs`, +7 tests, every anti-vacuity precondition asserted (receiver captured, pool ambiguous, no same-file candidate where tier 3 is under test, the import row present where edge 2 is under test): | mutation | red | |---|---| | A — delete the `LOCALITY_BLIND` arm | csharp + php same-file, csharp same-directory (3) | | B — drop `NOT IN (SELF_RECEIVERS_SQL)` | `csharp_self_receiver_still_resolves_by_same_file_locality` | | C — gate tier 1a on `PROVEN` too | `csharp_project_unique_candidate_survives_a_captured_receiver` | | D — apply the gate to every language | `rust_captured_receiver_keeps_same_file_locality` | | E — add `"ruby"` to the registry | `ruby_captured_receiver_keeps_locality_because_a_require_names_a_file` | | F — ungate tier 3's same-directory edge | 3 tests | | G — ungate tier 3's import-key edge | `php_captured_receiver_does_not_bind_by_import_key_reachability` | | H — let tier 2 admit `LOCALITY_BLIND` | 2 tests | | I — make `m0062` not re-resolve | `m0047_backfills_only_what_it_can_prove` | B is worth a note: my first version of that test used `this.Query()` calling a method of its own class, and the mutation **survived** — tier 1R's self arm claims that shape, so the test proved nothing about the exemption. The self exemption is worth exactly 4 binds corpus-wide, and the one that matters is `src/Exception/ResponseException.php:50` `$this->getRequest()` binding `TransferException::getRequest` **two classes up** — an inherited self call only locality can reach. The test now uses that shape. ## Gates ``` cargo fmt --all -- --check 0 cargo clippy --workspace --all-targets -- -D warnings 0 cargo test --workspace --no-fail-fast 0 (306 suites, 0 failed) COSI_E2E_LEG=daemon cargo test -p code-index-mcp 0 (52 suites) corpus_tier3_ratchet (executed=2, require=1) 0 tier3-baseline.json UNMOVED precision_gate -- --nocapture 0 7/7, phantoms=0 corpus_ratchet 101 baseline.json moves — see below corpus_stage 101 stage-baseline.json moves — see below ``` `m0047_backfills_only_what_it_can_prove` needed a real change, not a bless: it seeded refs at v46 and asserted on the state after the WHOLE chain, so it was coupled to "no later migration re-resolves". `m0062` does, and computing `influence` from the live contribution graph is the resolver doing its job. The arm now stops at 47 to grade m0047, then completes the chain and asserts the column IS filled — mutation I is what makes that second assertion evidence. ## The two protected records, NOT blessed ``` php-guzzle: resolved 12031 -> 11825 (-206), edges 7905 -> 7727 (-178), resolved_method_call 2827 -> 2621 (-206) cs-dapper: resolved 3811 -> 3628 (-183), edges 2710 -> 2613 (-97), resolved_method_call 509 -> 326 (-183) ``` Draft reason, for you to accept or reject: > #189: a captured method-call receiver is a NAME, not a TYPE, and the two tiers that decide on proximity were treating it as one. `recv_unproven` was a boolean that fired only when the plugin could NOT name a receiver (I037), so a ref that DID carry one was more eligible for locality than a ref that carried none — `_connection.Query<Post>(i)` bound to the enclosing `public Post Query()` four lines up. It is now a three-valued `recv_proof` code: a captured non-self receiver in `RECEIVER_LOCALITY_BLIND_PROFILES` (csharp, php; Ruby deliberately excluded and the registry says why) is refused by tier 2 and by tier 3's same-directory and import-key edges, while tier 1a's project-unique fact and tier 3's path-RESOLVED relative-specifier edge still apply. > > Bind-for-bind against a df1551f binary joined on (path, line, col, kind, occurrence): cs-dapper -183 and php-guzzle -206, 0 gained, and 371 php-guzzle refs RETARGET from a same-file test double to the real src/ implementation because blocking tier 2 hands the group to tier 1R. The other seven pinned repos are BYTE-IDENTICAL, which is the control that says the change is scoped. Every removed group above 3 occurrences was read at source: ≈161 phantoms against 17 correct binds on cs-dapper (better than 9:1, against I060's accepted 5.5:1), and on php-guzzle `$e->getRequest()` x89 bound TransferStats while TransferException::getRequest sat indexed, plus x54 bound to anonymous test doubles. #172's +67 splits exactly as #189 measured it: all 33 correct binds in tiers 1a/1b/1Q survive, the 34 in tiers 2 and 3 go, and 30 of those were the wrong ones — the 4 correct ones lost with them share a decision group with a wrong bind three lines away, which a tier cannot split finer than the gate does. Staged by path, not pushed, not blessed. Filing the Ruby `require_relative` prerequisite as its own issue.
Author
Member

CLOSING — verified on merged master fc329a8, all gates RUN

Close-out lane, independent of the lane that did the work. Merged as 53d26d4 + 9bb09e5 under 8373f2f.

The mechanism is in the tree, and it is generic rather than a C# special case — which is what this issue's "what must NOT be done" required.

  • crates/indexer/src/index.rs:1292 mod recv_proof — three-valued: PROVEN (0), UNCAPTURED (1, I037), LOCALITY_BLIND (2, this issue).
  • index.rs:3796-3812 builds the gate as a CASE over refs.kind, refs.qualifier and the row's language — name-structural, evaluated before any tier runs, and the comment says explicitly it must never ask whether the receiver resolved, "that is the resolve firewall I030 broke once already". Pre-m0018 schemas degrade to PROVEN.
  • crates/core/src/lang_profile.rs:215 RECEIVER_LOCALITY_BLIND_PROFILES = ["csharp", "php"] — one registry, one clause, all languages consulted through it.
  • Tier 1b untouched, as you asked. Tier 1a not gated (its fact is project-wide uniqueness, not proximity). Tier 3's path-RESOLVED relative edge not gated (it NAMES a file; the two that GUESS are the ones refused).
  • Re-heal migration crates/indexer/src/migrations/m0062_i189_receiver_locality_reheal.rs.

Gates RUN on this tree by this lane:

receiver_phantom                                    19 passed, 0 failed   exit 0
precision_gate --release -- --nocapture              7 passed, phantoms=0  exit 0
corpus_ratchet   COSI_CORPUS_REQUIRE=1  executed=7                        exit 0
corpus_stage     COSI_CORPUS_REQUIRE=1  executed=7                        exit 0
corpus_tier3_ratchet COSI_CORPUS_REQUIRE=1 executed=2                     exit 0

executed= is non-zero on all three corpus suites, so none of them is the silent unavailable=1 pass.

The baselines moved DOWN and were re-recorded with the reason, which is the evidence this issue asked for — not blessed to make anything pass. tests/corpus/baseline.json _blessed.reason records cs-dapper resolved 3811 → 3628 (-183), php-guzzle 12031 → 11825 (-206), both entirely in resolved_method_call, seven of nine repos byte-identical, identity_before == identity_after, the ~161:17 phantom-to-correct adjudication read at source, the 371 php-guzzle retargets onto real src/ implementations, and the two variants refused on measurement (-805 ripgrep, -6021 rust-analyzer). Both of your gate-fooling shapes are excluded by construction: the record is a reduction, and the reduction is the claim.

Your #172 +67 splits exactly as you predicted: 33 correct binds in tiers 1a/1b/1Q survive, the 34 in tiers 2/3 go, 30 of them your measured wrong ones. The 4 correct binds lost with them are stated rather than hidden — database.QueryFirstOrDefault shares a decision group with the wrong _connection.QueryFirstOrDefault three lines away, and a tier cannot split a group finer than the gate does.

Ruby is refused, and the refusal is recorded with a test rather than a comment. Adding Ruby removes 239 real phantoms but reddens precision_gate's own ruby oracle, because require_relative produces no import_rel edge. crates/indexer/tests/receiver_phantom.rs:905 ruby_captured_receiver_keeps_locality_because_a_require_names_a_file pins the decision; the prerequisite is #194. Closing here rather than holding this issue open for a different language's import model.

One documentation nit for whoever next touches this (not blocking, filed here so it is not lost): index.rs:1307 says "in these three languages there is no import that could make it one" and index.rs:3779 says "the sole signal these three languages have", but RECEIVER_LOCALITY_BLIND_PROFILES holds two. The prose still counts Ruby, which the registry deliberately excludes — a reader would conclude Ruby is gated when it is not.

## CLOSING — verified on merged master `fc329a8`, all gates RUN Close-out lane, independent of the lane that did the work. Merged as `53d26d4` + `9bb09e5` under `8373f2f`. **The mechanism is in the tree, and it is generic rather than a C# special case** — which is what this issue's "what must NOT be done" required. - `crates/indexer/src/index.rs:1292` `mod recv_proof` — three-valued: `PROVEN` (0), `UNCAPTURED` (1, I037), `LOCALITY_BLIND` (2, this issue). - `index.rs:3796-3812` builds the gate as a `CASE` over `refs.kind`, `refs.qualifier` and the row's language — **name-structural**, evaluated before any tier runs, and the comment says explicitly it must never ask whether the receiver resolved, *"that is the resolve firewall I030 broke once already"*. Pre-m0018 schemas degrade to `PROVEN`. - `crates/core/src/lang_profile.rs:215` `RECEIVER_LOCALITY_BLIND_PROFILES = ["csharp", "php"]` — one registry, one clause, all languages consulted through it. - Tier 1b untouched, as you asked. Tier 1a not gated (its fact is project-wide uniqueness, not proximity). Tier 3's path-RESOLVED relative edge not gated (it NAMES a file; the two that GUESS are the ones refused). - Re-heal migration `crates/indexer/src/migrations/m0062_i189_receiver_locality_reheal.rs`. **Gates RUN on this tree by this lane:** ``` receiver_phantom 19 passed, 0 failed exit 0 precision_gate --release -- --nocapture 7 passed, phantoms=0 exit 0 corpus_ratchet COSI_CORPUS_REQUIRE=1 executed=7 exit 0 corpus_stage COSI_CORPUS_REQUIRE=1 executed=7 exit 0 corpus_tier3_ratchet COSI_CORPUS_REQUIRE=1 executed=2 exit 0 ``` `executed=` is non-zero on all three corpus suites, so none of them is the silent `unavailable=1` pass. **The baselines moved DOWN and were re-recorded with the reason, which is the evidence this issue asked for** — not blessed to make anything pass. `tests/corpus/baseline.json` `_blessed.reason` records `cs-dapper resolved 3811 → 3628 (-183)`, `php-guzzle 12031 → 11825 (-206)`, both entirely in `resolved_method_call`, **seven of nine repos byte-identical**, `identity_before == identity_after`, the ~161:17 phantom-to-correct adjudication read at source, the 371 php-guzzle retargets onto real `src/` implementations, and the two variants refused on measurement (-805 ripgrep, -6021 rust-analyzer). Both of your gate-fooling shapes are excluded by construction: the record is a reduction, and the reduction is the claim. Your `#172` +67 splits exactly as you predicted: 33 correct binds in tiers 1a/1b/1Q survive, the 34 in tiers 2/3 go, **30 of them your measured wrong ones**. The 4 correct binds lost with them are stated rather than hidden — `database.QueryFirstOrDefault` shares a decision group with the wrong `_connection.QueryFirstOrDefault` three lines away, and a tier cannot split a group finer than the gate does. **Ruby is refused, and the refusal is recorded with a test rather than a comment.** Adding Ruby removes 239 real phantoms but reddens `precision_gate`'s own ruby oracle, because `require_relative` produces no `import_rel` edge. `crates/indexer/tests/receiver_phantom.rs:905 ruby_captured_receiver_keeps_locality_because_a_require_names_a_file` pins the decision; the prerequisite is **#194**. Closing here rather than holding this issue open for a different language's import model. **One documentation nit for whoever next touches this** (not blocking, filed here so it is not lost): `index.rs:1307` says *"in these three languages there is no import that could make it one"* and `index.rs:3779` says *"the sole signal these three languages have"*, but `RECEIVER_LOCALITY_BLIND_PROFILES` holds **two**. The prose still counts Ruby, which the registry deliberately excludes — a reader would conclude Ruby is gated when it is not.
Author
Member

POST-CLOSE, and it belongs on this thread: the fix is on master with a MEASURED +11.5% cold-index cost regression on php-guzzle, and master's CI is red because of it

Found by the close-out lane after closing this issue. Not reopening it — the receiver gate this issue asked for is correct and delivered. This is a new, separate consequence of the same change, and it is attached here because this is where the evidence lives.

What CI says

OSS corpus (tier 1) failed on run #4981 (fc329a8) while every other Linux job passed. It was green on 87a3fc8 — the commit immediately before this lane's merge (8373f2f, which never got its own run because it was pushed together with the budget lane).

Reproduced locally on fc329a8, release, corpus tier 1, COSI_CORPUS_REQUIRE=1

cold-index COST drifted from tests/corpus/cost-baseline.json:
  php-guzzle: vm_step 44631448 -> 49762991 (+11.5%, over the +5% ceiling of 46863020)
              — MORE SQLITE WORK for the same index
        crates/indexer/tests/corpus_cost.rs:734, exit 101

These are SQLite's own opcode counters, not the clock. The gate's own message records that they held to 0.04% across four thread/core configurations, so this is not contention and my run being non-isolated does not explain it.

The attribution is unambiguous — only the gated languages moved

repo baseline measured Δ
php-guzzle 44,631,448 49,762,991 +11.50%
cs-dapper 24,519,615 24,930,825 +1.68%
js-express 18,705,323 18,656,639 −0.26%
python-flask 14,525,751 14,525,267 −0.003%
ruby-sinatra 15,698,151 15,625,594 −0.46%
rust-ripgrep 81,381,997 81,389,380 +0.009%
ts-zod 114,140,737 113,973,777 −0.15%

The only two repos that went up are the only two languages in RECEIVER_LOCALITY_BLIND_PROFILES — ["csharp", "php"]. Every other language is flat within ±0.5%. That is this change's signature and nothing else's. The mechanism is visible at crates/indexer/src/index.rs:3796-3812: recv_unproven went from a two-branch boolean to a four-branch CASE carrying sql_adopts_any(...) and NOT IN (SELF_RECEIVERS_SQL), evaluated per candidate row — plus m0062's re-heal, which is why the baseline still reads "schema": 61.

Why it shipped

This lane's own gate list is on this thread and it is complete for what it ran — fmt, clippy, workspace tests, daemon-leg e2e, corpus_tier3_ratchet, precision_gate, corpus_ratchet, corpus_stage. corpus_cost is not on it. corpus_ratchet pins index CONTENT and was correctly re-recorded; corpus_cost pins the WORK, is a separate baseline file, and is the one gate in the CI corpus job that this change could move. It is also the gate CLAUDE.md's standing rule exists for: a correctness suite cannot see a slowdown.

What must NOT be done

Do not bless cost-baseline.json to make CI green. +11.5% for the two gated languages may well be the honest price of the fix — the fix removes ~161 phantoms per 17 correct binds and that is worth paying for — but the bless reason has to say which clause costs the opcodes, measured A/B in one worktree at one commit, the way #73's existing reason does. Blessing it with "#189 changed the resolver" would record the number without the attribution, and this file's whole job is attribution.

Worth checking before blessing: whether the CASE can be narrowed so the two extra branches are only evaluated for rows that can reach them (refs.kind = 'method_call' is already the outer discriminator, but sql_adopts_any and SELF_RECEIVERS_SQL are both inside it), and whether m0062's re-heal is included in the cold-index measurement at all.

## POST-CLOSE, and it belongs on this thread: the fix is on master with a MEASURED +11.5% cold-index cost regression on `php-guzzle`, and master's CI is red because of it Found by the close-out lane after closing this issue. Not reopening it — the receiver gate this issue asked for is correct and delivered. This is a **new, separate consequence** of the same change, and it is attached here because this is where the evidence lives. ### What CI says `OSS corpus (tier 1)` **failed** on run #4981 (`fc329a8`) while every other Linux job passed. It was **green on `87a3fc8`** — the commit immediately before this lane's merge (`8373f2f`, which never got its own run because it was pushed together with the budget lane). ### Reproduced locally on `fc329a8`, release, corpus tier 1, `COSI_CORPUS_REQUIRE=1` ``` cold-index COST drifted from tests/corpus/cost-baseline.json: php-guzzle: vm_step 44631448 -> 49762991 (+11.5%, over the +5% ceiling of 46863020) — MORE SQLITE WORK for the same index crates/indexer/tests/corpus_cost.rs:734, exit 101 ``` **These are SQLite's own opcode counters, not the clock.** The gate's own message records that they held to 0.04% across four thread/core configurations, so this is not contention and my run being non-isolated does not explain it. ### The attribution is unambiguous — only the gated languages moved | repo | baseline | measured | Δ | |---|---|---|---| | **php-guzzle** | 44,631,448 | 49,762,991 | **+11.50%** | | **cs-dapper** | 24,519,615 | 24,930,825 | **+1.68%** | | js-express | 18,705,323 | 18,656,639 | −0.26% | | python-flask | 14,525,751 | 14,525,267 | −0.003% | | ruby-sinatra | 15,698,151 | 15,625,594 | −0.46% | | rust-ripgrep | 81,381,997 | 81,389,380 | +0.009% | | ts-zod | 114,140,737 | 113,973,777 | −0.15% | **The only two repos that went up are the only two languages in `RECEIVER_LOCALITY_BLIND_PROFILES`** — `["csharp", "php"]`. Every other language is flat within ±0.5%. That is this change's signature and nothing else's. The mechanism is visible at `crates/indexer/src/index.rs:3796-3812`: `recv_unproven` went from a two-branch boolean to a four-branch `CASE` carrying `sql_adopts_any(...)` and `NOT IN (SELF_RECEIVERS_SQL)`, evaluated per candidate row — plus `m0062`'s re-heal, which is why the baseline still reads `"schema": 61`. ### Why it shipped This lane's own gate list is on this thread and it is complete for what it ran — fmt, clippy, workspace tests, daemon-leg e2e, `corpus_tier3_ratchet`, `precision_gate`, `corpus_ratchet`, `corpus_stage`. **`corpus_cost` is not on it.** `corpus_ratchet` pins index CONTENT and was correctly re-recorded; `corpus_cost` pins the WORK, is a separate baseline file, and is the one gate in the CI corpus job that this change could move. It is also the gate `CLAUDE.md`'s standing rule exists for: *a correctness suite cannot see a slowdown.* ### What must NOT be done **Do not bless `cost-baseline.json` to make CI green.** +11.5% for the two gated languages may well be the honest price of the fix — the fix removes ~161 phantoms per 17 correct binds and that is worth paying for — but the bless reason has to say *which clause costs the opcodes*, measured A/B in one worktree at one commit, the way `#73`'s existing reason does. Blessing it with "#189 changed the resolver" would record the number without the attribution, and this file's whole job is attribution. Worth checking before blessing: whether the `CASE` can be narrowed so the two extra branches are only evaluated for rows that can reach them (`refs.kind = 'method_call'` is already the outer discriminator, but `sql_adopts_any` and `SELF_RECEIVERS_SQL` are both inside it), and whether `m0062`'s re-heal is included in the cold-index measurement at all.
Author
Member

Cost round: the +11.5% was NOT the price of the fix. It was one pre-existing unsargable clause, and removing it takes every pinned repo BELOW its blessed baseline.

corpus_cost fired on php-guzzle at +11.5% after #189 merged. It is now -9.6%, and so are the other six.

Attribution first, because a total cannot say where

corpus_cost answers "how much work"; nothing in the tree answered "which statement". I built that — crates/indexer/tests/cost_attribution.rs, an #[ignore]d per-statement vm_step harvester on the same sqlite3_trace_v2 hook corpus::work uses, keyed by sqlite3_sql(). Run on both sides of the merge:

statement before #189 after #189 Δ
CREATE TEMP TABLE recv_bound (tier 1R) 6,060,969 11,095,499 +5,034,530
INSERT INTO temp.enclosing_sym 6,519,970 6,303,226 -216,744
final UPDATE refs SET (target_id, resolved_by) 2,602,422 2,490,549 -111,873
tier-attribution SELECT 1,110,270 1,157,381 +47,111
pass total 44,644,660 49,767,560 +5,122,900

98.3% of the rise is one statement, and it is not one I wrote.

Two things follow, and the second is the one that mattered:

  • The recv_proof CASE is not the cost. The statements carrying it moved by ±47k, and the final UPDATE — which evaluates it twice per candidate row — got cheaper, because fewer rows match. The sql_adopts_any GLOBs are noise. Your hypothesis (per-ref where a join would do it once) was the natural one and the measurement refutes it.
  • What #189 did was hand tier 1R the population tiers 2 and 3 used to take (rule.tier1r_receiver 215 → 675 on php-guzzle). temp.recv_calls is built WHERE refs.target_id IS NULL, and tier 1R runs after tiers 1/2/3 have written theirs — so binds tier 2 used to claim now arrive at tier 1R instead. That is the fix working. The bill came because of what tier 1R spends them on.

The clause

AND NOT EXISTS (SELECT 1 FROM symbols s
  WHERE s.file_id = c.file_id AND s.kind IN ('function','method')
    AND ((s.start_line <= c.line AND s.end_line >= c.line)
      != (s.start_line <= b.line AND s.end_line >= b.line)))

"No function or method contains exactly one of (call line, binding line)." The != sits between two range predicates, so it is unsargable: SQLite can use neither start_line nor end_line and evaluates the expression over every function in the file, per row. Deleting it outright took the pass 49,754,346 → 38,800,418 — 22% of everything.

The fix: split the != into the two directions it was hiding

pos is (line << 16) | col and the join requires b.pos < c.pos, so b.line <= c.line always. Given that, for any span s:

  • contains(s,call) AND NOT contains(s,bind) collapses to s.start_line > b.line — s.end_line >= c.line >= b.line already holds;
  • contains(s,bind) AND NOT contains(s,call) collapses to s.end_line < c.line — s.start_line <= b.line <= c.line already holds.

So the one clause is exactly two, and the first is now a narrow range seek on idx_symbols_file_span (file_id, start_line, end_line): only a function starting between the binding and the call can separate them that way, and usually none does. Five lines of SQL.

Result — every repo, not just the one that fired

repo blessed now Δ
rust-ripgrep 81,381,997 72,379,229 -11.1%
js-express 18,705,323 16,345,020 -12.6%
cs-dapper 24,519,615 21,603,795 -11.9%
php-guzzle 44,631,448 40,331,608 -9.6%
ts-zod 114,140,737 105,483,882 -7.6%
python-flask 14,525,751 13,578,407 -6.5%
ruby-sinatra 15,698,151 14,928,391 -4.9%

fullscan_step, sort and autoindex are flat or slightly down on all seven. Read across an isolated run and a loaded one, the readings agree to 0.008% — this dimension does not track load, which is the property the gate is built on.

The gate now fails as a ratchet-down on three repos, which is the "record the win so the ceiling comes down" direction.

Behavioural equivalence: measured, not argued

Indexed all nine pinned repos with an fc329a8 binary and with this one, joined on (path, line, col, kind, occurrence):

cs-dapper     LOST=0 NEW=0 RETARGETED=0      py-django      LOST=0 NEW=0 RETARGETED=0
js-express    LOST=0 NEW=0 RETARGETED=0      python-flask   LOST=0 NEW=0 RETARGETED=0
php-guzzle    LOST=0 NEW=0 RETARGETED=0      ruby-sinatra   LOST=0 NEW=0 RETARGETED=0
rust-analyzer LOST=0 NEW=0 RETARGETED=0      rust-ripgrep   LOST=0 NEW=0 RETARGETED=0
ts-zod        LOST=0 NEW=0 RETARGETED=0

and corpus_ratchet, corpus_stage and corpus_tier3_ratchet all exit 0 against the records blessed at 9bb09e5. This is a cost change and nothing else.

Three other shapes were built and REFUSED on measurement

Recorded in the code comment so nobody re-derives them:

  1. Nearest-binding choice moved into the ON clause as a reverse seek (my first guess: the correlated MAX(b2.pos) looked O(k²) per call). 49,754,346 → 49,754,346. Nothing. SQLite was already doing that part well.
  2. A (file, line) → innermost function memo, filled by a correlated ORDER BY start_line DESC, end_line ASC LIMIT 1. 45,988,565 — inside the band on vm_step, and refused: the mixed ASC/DESC key matches no index, so SQLite sorted per row. sort went 384 → 16,672 on ts-zod and 70 → 5,117 on js-express, failing corpus_cost on six of seven repos in the one dimension that exists to catch exactly that. The multi-dimension design earned its keep here.
  3. The same memo filled by ROW_NUMBER() OVER (PARTITION BY file_id, line) — the shape temp.enclosing_sym already uses. sort flat again, but 51,743,446, worse than doing nothing: it materialises every containing symbol for every line instead of stopping at the first.

Tests, and one of them was vacuous until a mutation said so

Six new tests in receiver_phantom.rs. Mutations, all RUN:

mutation red
J — delete arm 1 (s.start_line > b.line …) a_function_starting_between_the_binding_and_the_call_separates_them
K — delete arm 2 (s.end_line < c.line …) a_function_ending_before_the_call_separates_them
L — arm 1 boundary > → >= a_function_starting_on_the_binding_line_does_not_separate_them
M — arm 2 boundary < → <= a_function_ending_on_the_call_line_does_not_separate_them

The first draft of J and K SURVIVED. The fixtures had no use naming the receiver's type, so recv_origin rejected the refs upstream and they never reached the span test — the tests passed for a reason that had nothing to do with what they claimed to grade. Rewritten on the shape a real tier-1R bind has (copied from php-guzzle's CurlFactoryTest.php: use GuzzleHttp\Handler\CurlFactory; + new CurlFactory(3)), in PHP because #189 has already cleared tiers 2 and 3 out of the way there, so tier 1R is the only tier that can decide. A positive control asserts resolved_by = 60 so "delete one arm" and "delete the whole tier" cannot look alike, and assert_span pins the geometry each arm is isolated by.

L and M were added because the first mutation round showed both boundaries ungraded — every other test stayed green under them.

a_binding_never_sits_on_a_later_line_than_its_call grades the b.line <= c.line precondition the algebra rests on, over the (line << 16) | col key itself.

One mutation survives and I am not claiming otherwise: widening either arm's s.kind IN ('function','method') to include 'class' leaves everything green. That filter is verbatim from the clause I replaced and I did not touch it; no fixture has a class span that separates a binding from a call. Recorded rather than papered over.

Gates

cargo fmt --all -- --check                              0
cargo clippy --workspace --all-targets -- -D warnings   0
cargo test --workspace --no-fail-fast                 101   305 ok, 2 failed — both cost records, below
corpus_ratchet                                          0   baseline.json UNMOVED
corpus_stage                                            0   stage-baseline.json UNMOVED
corpus_tier3_ratchet                                    0   tier3-baseline.json UNMOVED
precision_gate -- --nocapture                           0   7/7, phantoms=0
corpus_cost                                           101   ratchet-DOWN on 3 repos — bless below
ruby_package_cost                                     101   stale conditions (schema 61 vs 62) — see below

cost-baseline.json, NOT blessed — draft reason

#189 cost round: the +11.5% this record caught was not the receiver gate's price, it was ONE PRE-EXISTING UNSARGABLE CLAUSE the gate had merely handed more rows, and fixing it puts every pinned repo BELOW the band it was blessed at. Per-statement attribution (the new cost_attribution tool) put 98.3% of the rise on tier 1R's recv_bound build: #189 moves the population tiers 2 and 3 used to claim into tier 1R (rule.tier1r_receiver 215 -> 675 on php-guzzle), and recv_bound's span test — "no function contains exactly one of (call line, binding line)" — was a != BETWEEN TWO RANGE PREDICATES, so SQLite could use neither start_line nor end_line and evaluated it over every function in the file, per row. Deleting the clause outright took php-guzzle 49,754,346 -> 38,800,418: 22% of the whole pass.

Split into the two directions the != was hiding — legal because pos = (line << 16) | col and b.pos < c.pos give b.line <= c.line, which collapses one arm to start_line > b.line and the other to end_line < c.line — the first arm becomes a narrow range seek on idx_symbols_file_span. rust-ripgrep -11.1%, js-express -12.6%, cs-dapper -11.9%, php-guzzle -9.6%, ts-zod -7.6%, python-flask -6.5%, ruby-sinatra -4.9%; fullscan_step, sort and autoindex flat or down on all seven.

BEHAVIOUR IS UNCHANGED AND THAT IS MEASURED, not asserted: all nine pinned repos indexed with an fc329a8 binary and with this one and joined on (path, line, col, kind, occurrence) give 0 lost, 0 gained, 0 retargeted, and corpus_ratchet, corpus_stage and corpus_tier3_ratchet all pass unchanged. THREE CHEAPER SHAPES WERE BUILT AND REFUSED FIRST and are recorded at the site: moving the nearest-binding choice into the ON clause (no change at all), a memo filled by a correlated mixed ASC/DESC LIMIT 1 (inside the band on vm_step, REFUSED because it sorted per row — sort 384 -> 16,672 on ts-zod, failing this gate on six of seven repos in the dimension built for it), and the same memo filled by ROW_NUMBER() (sort flat, 51.7M — worse than doing nothing). Four mutations run to real RED, one per arm and one per boundary; the first draft of two of them was VACUOUS and the mutation is what said so.

ruby-package-cost.json — measured, but NOT a valid reading, and NOT blessed

It fails only on the condition gate — schema: the band was measured under 61 and this binary is schema 62 — which m0062 caused when #189 merged, before this round. The reading I took:

builtin  vm_step 14,820,174   package vm_step 25,077,495 (blessed 25,865,288, -3.0%)
         fullscan   169,671            fullscan   542,819 (blessed    541,182, +0.3%)
                                       sort 126 / autoindex 20,745 — both identical
         wall_ratio 119% (blessed 140)

The three SQLite dimensions are inside band and the two exact ones are unchanged. The wall ratio is not a measurement: it was taken at load average 13–15 with a dozen sibling cargo test processes, and this record's own superseded reasons document four passes rejected for exactly that ("the 203 came with both legs' wall clock doubled… the spread tracks LOAD, not code"). Its refusal text demands an isolated run on a box below ~88% disk; disk is fine at 60–73%, load is not — it has been 13–21 throughout.

So I am handing this back rather than taking it badly: the re-measure needs a quiet box, and it should be taken after this change lands, since the span rewrite moves the builtin leg too (ruby-sinatra -4.9% in corpus_cost). I did not run the bless — it is a protected record and the numbers I have would launder a loaded wall clock into it.

Staged by path, not pushed, nothing blessed.

## Cost round: the +11.5% was NOT the price of the fix. It was one pre-existing unsargable clause, and removing it takes every pinned repo BELOW its blessed baseline. `corpus_cost` fired on php-guzzle at +11.5% after #189 merged. It is now **-9.6%**, and so are the other six. ## Attribution first, because a total cannot say where `corpus_cost` answers "how much work"; nothing in the tree answered "which statement". I built that — `crates/indexer/tests/cost_attribution.rs`, an `#[ignore]`d per-statement `vm_step` harvester on the same `sqlite3_trace_v2` hook `corpus::work` uses, keyed by `sqlite3_sql()`. Run on both sides of the merge: | statement | before #189 | after #189 | Δ | |---|---|---|---| | **`CREATE TEMP TABLE recv_bound`** (tier 1R) | 6,060,969 | 11,095,499 | **+5,034,530** | | `INSERT INTO temp.enclosing_sym` | 6,519,970 | 6,303,226 | -216,744 | | final `UPDATE refs SET (target_id, resolved_by)` | 2,602,422 | 2,490,549 | -111,873 | | tier-attribution `SELECT` | 1,110,270 | 1,157,381 | +47,111 | | **pass total** | **44,644,660** | **49,767,560** | **+5,122,900** | **98.3% of the rise is one statement, and it is not one I wrote.** Two things follow, and the second is the one that mattered: - The `recv_proof` CASE is **not** the cost. The statements carrying it moved by ±47k, and the final `UPDATE` — which evaluates it twice per candidate row — got *cheaper*, because fewer rows match. The `sql_adopts_any` GLOBs are noise. Your hypothesis (per-ref where a join would do it once) was the natural one and the measurement refutes it. - What #189 did was **hand tier 1R the population tiers 2 and 3 used to take** (`rule.tier1r_receiver` 215 → 675 on php-guzzle). `temp.recv_calls` is built `WHERE refs.target_id IS NULL`, and tier 1R runs after tiers 1/2/3 have written theirs — so binds tier 2 used to claim now arrive at tier 1R instead. That is the fix working. The bill came because of what tier 1R spends them on. ## The clause ```sql AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.file_id = c.file_id AND s.kind IN ('function','method') AND ((s.start_line <= c.line AND s.end_line >= c.line) != (s.start_line <= b.line AND s.end_line >= b.line))) ``` "No function or method contains exactly one of (call line, binding line)." The `!=` sits **between two range predicates**, so it is unsargable: SQLite can use neither `start_line` nor `end_line` and evaluates the expression over every function in the file, per row. Deleting it outright took the pass 49,754,346 → 38,800,418 — **22% of everything**. ## The fix: split the `!=` into the two directions it was hiding `pos` is `(line << 16) | col` and the join requires `b.pos < c.pos`, so **`b.line <= c.line` always**. Given that, for any span `s`: - `contains(s,call) AND NOT contains(s,bind)` collapses to `s.start_line > b.line` — `s.end_line >= c.line >= b.line` already holds; - `contains(s,bind) AND NOT contains(s,call)` collapses to `s.end_line < c.line` — `s.start_line <= b.line <= c.line` already holds. So the one clause is exactly two, and the first is now a **narrow range seek** on `idx_symbols_file_span (file_id, start_line, end_line)`: only a function *starting between* the binding and the call can separate them that way, and usually none does. Five lines of SQL. ## Result — every repo, not just the one that fired | repo | blessed | now | Δ | |---|---|---|---| | rust-ripgrep | 81,381,997 | 72,379,229 | **-11.1%** | | js-express | 18,705,323 | 16,345,020 | **-12.6%** | | cs-dapper | 24,519,615 | 21,603,795 | **-11.9%** | | php-guzzle | 44,631,448 | 40,331,608 | **-9.6%** | | ts-zod | 114,140,737 | 105,483,882 | **-7.6%** | | python-flask | 14,525,751 | 13,578,407 | **-6.5%** | | ruby-sinatra | 15,698,151 | 14,928,391 | **-4.9%** | `fullscan_step`, `sort` and `autoindex` are flat or slightly down on all seven. Read across an isolated run and a loaded one, the readings agree to 0.008% — this dimension does not track load, which is the property the gate is built on. The gate now fails as a **ratchet-down** on three repos, which is the "record the win so the ceiling comes down" direction. ## Behavioural equivalence: measured, not argued Indexed all nine pinned repos with an `fc329a8` binary and with this one, joined on `(path, line, col, kind, occurrence)`: ``` cs-dapper LOST=0 NEW=0 RETARGETED=0 py-django LOST=0 NEW=0 RETARGETED=0 js-express LOST=0 NEW=0 RETARGETED=0 python-flask LOST=0 NEW=0 RETARGETED=0 php-guzzle LOST=0 NEW=0 RETARGETED=0 ruby-sinatra LOST=0 NEW=0 RETARGETED=0 rust-analyzer LOST=0 NEW=0 RETARGETED=0 rust-ripgrep LOST=0 NEW=0 RETARGETED=0 ts-zod LOST=0 NEW=0 RETARGETED=0 ``` and `corpus_ratchet`, `corpus_stage` and `corpus_tier3_ratchet` all exit **0** against the records blessed at `9bb09e5`. This is a cost change and nothing else. ## Three other shapes were built and REFUSED on measurement Recorded in the code comment so nobody re-derives them: 1. **Nearest-binding choice moved into the `ON` clause as a reverse seek** (my first guess: the correlated `MAX(b2.pos)` looked O(k²) per call). 49,754,346 → 49,754,346. **Nothing.** SQLite was already doing that part well. 2. **A `(file, line) → innermost function` memo, filled by a correlated `ORDER BY start_line DESC, end_line ASC LIMIT 1`.** 45,988,565 — inside the band on `vm_step`, and **refused**: the mixed ASC/DESC key matches no index, so SQLite sorted *per row*. `sort` went 384 → 16,672 on ts-zod and 70 → 5,117 on js-express, failing `corpus_cost` on six of seven repos in the one dimension that exists to catch exactly that. The multi-dimension design earned its keep here. 3. **The same memo filled by `ROW_NUMBER() OVER (PARTITION BY file_id, line)`** — the shape `temp.enclosing_sym` already uses. `sort` flat again, but **51,743,446, worse than doing nothing**: it materialises every containing symbol for every line instead of stopping at the first. ## Tests, and one of them was vacuous until a mutation said so Six new tests in `receiver_phantom.rs`. Mutations, all RUN: | mutation | red | |---|---| | J — delete arm 1 (`s.start_line > b.line …`) | `a_function_starting_between_the_binding_and_the_call_separates_them` | | K — delete arm 2 (`s.end_line < c.line …`) | `a_function_ending_before_the_call_separates_them` | | L — arm 1 boundary `>` → `>=` | `a_function_starting_on_the_binding_line_does_not_separate_them` | | M — arm 2 boundary `<` → `<=` | `a_function_ending_on_the_call_line_does_not_separate_them` | **The first draft of J and K SURVIVED.** The fixtures had no `use` naming the receiver's type, so `recv_origin` rejected the refs upstream and they never reached the span test — the tests passed for a reason that had nothing to do with what they claimed to grade. Rewritten on the shape a real tier-1R bind has (copied from php-guzzle's `CurlFactoryTest.php`: `use GuzzleHttp\Handler\CurlFactory;` + `new CurlFactory(3)`), in **PHP** because #189 has already cleared tiers 2 and 3 out of the way there, so tier 1R is the only tier that can decide. A positive control asserts `resolved_by = 60` so "delete one arm" and "delete the whole tier" cannot look alike, and `assert_span` pins the geometry each arm is isolated by. L and M were added *because* the first mutation round showed both boundaries ungraded — every other test stayed green under them. `a_binding_never_sits_on_a_later_line_than_its_call` grades the `b.line <= c.line` precondition the algebra rests on, over the `(line << 16) | col` key itself. **One mutation survives and I am not claiming otherwise:** widening either arm's `s.kind IN ('function','method')` to include `'class'` leaves everything green. That filter is verbatim from the clause I replaced and I did not touch it; no fixture has a class span that separates a binding from a call. Recorded rather than papered over. ## Gates ``` cargo fmt --all -- --check 0 cargo clippy --workspace --all-targets -- -D warnings 0 cargo test --workspace --no-fail-fast 101 305 ok, 2 failed — both cost records, below corpus_ratchet 0 baseline.json UNMOVED corpus_stage 0 stage-baseline.json UNMOVED corpus_tier3_ratchet 0 tier3-baseline.json UNMOVED precision_gate -- --nocapture 0 7/7, phantoms=0 corpus_cost 101 ratchet-DOWN on 3 repos — bless below ruby_package_cost 101 stale conditions (schema 61 vs 62) — see below ``` ## `cost-baseline.json`, NOT blessed — draft reason > #189 cost round: the +11.5% this record caught was not the receiver gate's price, it was ONE PRE-EXISTING UNSARGABLE CLAUSE the gate had merely handed more rows, and fixing it puts every pinned repo BELOW the band it was blessed at. Per-statement attribution (the new `cost_attribution` tool) put 98.3% of the rise on tier 1R's `recv_bound` build: `#189` moves the population tiers 2 and 3 used to claim into tier 1R (`rule.tier1r_receiver` 215 -> 675 on php-guzzle), and `recv_bound`'s span test — "no function contains exactly one of (call line, binding line)" — was a `!=` BETWEEN TWO RANGE PREDICATES, so SQLite could use neither `start_line` nor `end_line` and evaluated it over every function in the file, per row. Deleting the clause outright took php-guzzle 49,754,346 -> 38,800,418: 22% of the whole pass. > > Split into the two directions the `!=` was hiding — legal because `pos = (line << 16) | col` and `b.pos < c.pos` give `b.line <= c.line`, which collapses one arm to `start_line > b.line` and the other to `end_line < c.line` — the first arm becomes a narrow range seek on `idx_symbols_file_span`. rust-ripgrep -11.1%, js-express -12.6%, cs-dapper -11.9%, php-guzzle -9.6%, ts-zod -7.6%, python-flask -6.5%, ruby-sinatra -4.9%; `fullscan_step`, `sort` and `autoindex` flat or down on all seven. > > BEHAVIOUR IS UNCHANGED AND THAT IS MEASURED, not asserted: all nine pinned repos indexed with an fc329a8 binary and with this one and joined on (path, line, col, kind, occurrence) give 0 lost, 0 gained, 0 retargeted, and `corpus_ratchet`, `corpus_stage` and `corpus_tier3_ratchet` all pass unchanged. THREE CHEAPER SHAPES WERE BUILT AND REFUSED FIRST and are recorded at the site: moving the nearest-binding choice into the ON clause (no change at all), a memo filled by a correlated mixed ASC/DESC `LIMIT 1` (inside the band on vm_step, REFUSED because it sorted per row — `sort` 384 -> 16,672 on ts-zod, failing this gate on six of seven repos in the dimension built for it), and the same memo filled by `ROW_NUMBER()` (sort flat, 51.7M — worse than doing nothing). Four mutations run to real RED, one per arm and one per boundary; the first draft of two of them was VACUOUS and the mutation is what said so. ## `ruby-package-cost.json` — measured, but NOT a valid reading, and NOT blessed It fails only on the condition gate — `schema: the band was measured under 61 and this binary is schema 62` — which `m0062` caused when #189 merged, before this round. The reading I took: ``` builtin vm_step 14,820,174 package vm_step 25,077,495 (blessed 25,865,288, -3.0%) fullscan 169,671 fullscan 542,819 (blessed 541,182, +0.3%) sort 126 / autoindex 20,745 — both identical wall_ratio 119% (blessed 140) ``` The three SQLite dimensions are inside band and the two exact ones are unchanged. **The wall ratio is not a measurement**: it was taken at load average 13–15 with a dozen sibling `cargo test` processes, and this record's own superseded reasons document four passes rejected for exactly that ("the 203 came with both legs' wall clock doubled… the spread tracks LOAD, not code"). Its refusal text demands *an isolated run on a box below ~88% disk*; disk is fine at 60–73%, load is not — it has been 13–21 throughout. So I am handing this back rather than taking it badly: **the re-measure needs a quiet box**, and it should be taken *after* this change lands, since the span rewrite moves the builtin leg too (ruby-sinatra -4.9% in `corpus_cost`). I did not run the bless — it is a protected record and the numbers I have would launder a loaded wall clock into it. Staged by path, not pushed, nothing blessed.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#189
No description provided.