ruby_package_parity: the SelfQualifier arm absorbs resolution differences, and closing #167's mechanism made 11 of them visible #170

Closed
opened 2026-09-06 02:06:55 +02:00 by buildagent · 2 comments
Member

Found while closing #167. Filed rather than swallowed, and pinned in the suite so it cannot grow in silence.

The arm

classify tries SelfQualifier first, and its predicate is

&& diff.iter().all(|i| *i == C_QUALIFIER || *i == C_TARGET);

C_TARGET is in there deliberately — the doc calls it "the self qualifier, alone or with its resolution consequence" — but the arm never checks that the target moved because the qualifier did. Any row where the qualifier text differs AND the two legs resolved to different targets is classified as SelfQualifier, whatever caused the second difference.

What that hid

EcosystemMarker's predicate was the shape "builtin ~UNRESOLVED~, package resolved by TIER1B_FILE_KEY_IMPORT". It only ever saw rows SelfQualifier had already declined. Measured on ruby-sinatra at its pinned sha:

11 of the 876 SelfQualifier rows carry EcosystemMarker's exact shape — builtin unresolved, package resolved, brule == -1, prule == 13.

They were counted as qualifier-text deltas before #167 as much as after it. This is not a regression; it is a pre-existing looseness that closing the other mechanism made legible.

Why it matters

The suite's whole claim is "every delta carries a Mechanism whose predicate is checked ON THE ROW" — the alternative to "a column was excluded from the comparison". An arm that admits an unexplained target difference alongside an explained qualifier difference is, for those rows, exactly the excluded column it was built to avoid. And the count is now load-bearing in a way it was not: with four of five mechanisms closed, SelfQualifier == 876 is most of what stands between the suite and vacuity.

What was done here

Not widened, not narrowed — pinned:

const SELF_QUALIFIER_ROWS_WITH_A_RESOLUTION_DELTA: usize = 11;

counted in compare from project_rules (which classify no longer reads at all) and asserted. If the number grows, the surviving mechanism is absorbing resolution differences it does not name, and someone has to look.

Directions

  1. Split the arm. SelfQualifier proper = qualifier text only (834 by the record's own count in _prdoc/records/84-P4-parity-deltas.md, "876 refs (834 alone, 42 with a resolution consequence)"). The 42-with-a-consequence half wants its own predicate saying what the consequence is — tier 1Q anchors on the qualifier, so a missing qualifier legitimately unresolves a ref, and that is checkable via resolved_by on the builtin leg.
  2. Require the resolution difference to be in the direction the mechanism predicts. A missing qualifier can only cost the package a bind, never gain it one; 11 of these rows are the package GAINING one, which the mechanism cannot explain.

Direction 2 is the smaller change and would move exactly those 11 rows to None, i.e. to the loud panic. That is the right destination for them, but it needs the #86 qualifier work or a new named mechanism, so it is filed rather than done in the #165/#167 lane.

#167 (closed EcosystemMarker, which is what exposed this), #84, #102, #86 (the missing qualifier text itself).

🤖 Generated with Claude Code

https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K

Found while closing #167. Filed rather than swallowed, and pinned in the suite so it cannot grow in silence. ## The arm `classify` tries `SelfQualifier` first, and its predicate is ```rust && diff.iter().all(|i| *i == C_QUALIFIER || *i == C_TARGET); ``` `C_TARGET` is in there deliberately — the doc calls it "the `self` qualifier, **alone or with its resolution consequence**" — but the arm never checks that the target moved *because* the qualifier did. Any row where the qualifier text differs AND the two legs resolved to different targets is classified as `SelfQualifier`, whatever caused the second difference. ## What that hid `EcosystemMarker`'s predicate was the shape "builtin `~UNRESOLVED~`, package resolved by `TIER1B_FILE_KEY_IMPORT`". It only ever saw rows `SelfQualifier` had already declined. Measured on `ruby-sinatra` at its pinned sha: **11 of the 876 `SelfQualifier` rows carry `EcosystemMarker`'s exact shape** — builtin unresolved, package resolved, `brule == -1`, `prule == 13`. They were counted as qualifier-text deltas before #167 as much as after it. This is not a regression; it is a pre-existing looseness that closing the other mechanism made legible. ## Why it matters The suite's whole claim is *"every delta carries a `Mechanism` whose predicate is checked ON THE ROW"* — the alternative to "a column was excluded from the comparison". An arm that admits an unexplained target difference alongside an explained qualifier difference is, for those rows, exactly the excluded column it was built to avoid. And the count is now load-bearing in a way it was not: with four of five mechanisms closed, `SelfQualifier == 876` is most of what stands between the suite and vacuity. ## What was done here Not widened, not narrowed — **pinned**: ```rust const SELF_QUALIFIER_ROWS_WITH_A_RESOLUTION_DELTA: usize = 11; ``` counted in `compare` from `project_rules` (which `classify` no longer reads at all) and asserted. If the number grows, the surviving mechanism is absorbing resolution differences it does not name, and someone has to look. ## Directions 1. **Split the arm.** `SelfQualifier` proper = qualifier text only (834 by the record's own count in `_prdoc/records/84-P4-parity-deltas.md`, "876 refs (834 alone, 42 with a resolution consequence)"). The 42-with-a-consequence half wants its own predicate saying *what* the consequence is — tier 1Q anchors on the qualifier, so a missing qualifier legitimately unresolves a ref, and that is checkable via `resolved_by` on the builtin leg. 2. **Require the resolution difference to be in the direction the mechanism predicts.** A missing qualifier can only cost the package a bind, never gain it one; 11 of these rows are the package GAINING one, which the mechanism cannot explain. Direction 2 is the smaller change and would move exactly those 11 rows to `None`, i.e. to the loud panic. That is the right destination for them, but it needs the #86 qualifier work or a new named mechanism, so it is filed rather than done in the #165/#167 lane. ## Related #167 (closed `EcosystemMarker`, which is what exposed this), #84, #102, #86 (the missing qualifier text itself). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
Author
Member

Direction 1 done; direction 2 REFUTED by reading the eleven rows

Measured in /tmp/cosi-lane-precision off origin/master (f6a878a), ruby-sinatra at its pinned sha, both legs indexed as the suite does.

The 876, decomposed

Instrumented compare to classify every row classify calls SelfQualifier by the direction of its resolution difference:

qualifier_only                                             834
target b=UNRES p=res   brule=-1  prule=13 (file_key_import)  11
target b=res   p=UNRES brule=41 (tier1q_rel_samefile) prule=-1  31
                                                          ---
                                                          876

834 + 42, exactly as _prdoc/records/84-P4-parity-deltas.md says. What the record did not say is that the 42 run in two opposite directions.

Direction 2's premise is false, and the eleven rows say so at source

The direction proposes requiring the resolution difference to run the way the mechanism predicts, on the premise that "a missing qualifier can only cost the package a bind, never gain it one; 11 of these rows are the package GAINING one, which the mechanism cannot explain."

All eleven are in one file, sinatra-contrib/lib/sinatra/cookies.rb:

B: sinatra-contrib/lib/sinatra/cookies.rb  read request  33:5  qualifier=self  ~UNRESOLVED~
P: sinatra-contrib/lib/sinatra/cookies.rb  read request  33:5  qualifier=       lib/sinatra/base.rb#request@978

…and the same for response. lib/sinatra/base.rb:978 is the attr_accessor on Sinatra::Base. Sinatra::Cookies is a helper module mixed into Sinatra::Base, so self.request there names exactly that accessor. The package leg's answer is the correct one, and the builtin's refusal is the error.

The mechanism is the self qualifier's own gate, not the port: a self/Self/static/parent qualifier restricts the candidate pool to the ref's own file (I026), cookies.rb declares no request, so the builtin refuses; with no qualifier text the package never enters that gate, reaches tier 1b's file-key import arm, and lands on the truth. That is the I026 residual already on record — "Self::/static:: resolve by locality not impl-hierarchy" — showing up in Ruby, where a mixin's self.x is by construction not in the mixin's file.

So sending these eleven to None would make the suite panic on correct binds. Direction 2 is refused. The rows do not need a louder destination; they need a name.

Direction 1, implemented — three mechanisms, all predicates ON THE ROW

Mechanism is now:

variant rows predicate (row-only)
SelfQualifier 834 diff does not contain the target column
QualifierTextCostsARelativeBind 31 builtin has a target, package ~UNRESOLVED~
SelfGateRefusesAMixinAccessor 11 builtin ~UNRESOLVED~, package has a target

Nothing reads a rule column to classify, so the split needs no signature change and stays a statement about the data. And the case the old predicate quietly admitted — both legs bound, to different targets — now falls to _ => None and panics. It has never been observed here, and that is recorded as ungraded rather than claimed.

The absorbed-count backstop (SELF_QUALIFIER_ROWS_WITH_A_RESOLUTION_DELTA = 11) is replaced by something stronger, not deleted: each consequence arm is counted a SECOND time from the deciding RULE — the column classify does not read.

  • SelfGateRefusesAMixinAccessor: brule == -1 && prule == 13
  • QualifierTextCostsARelativeBind: brule == 41 && prule == -1

A split that mis-assigns a row agrees with itself; it cannot agree with a column it never looked at. Plus a file-set pin: every SelfGateRefusesAMixinAccessor row must be in sinatra-contrib/lib/sinatra/cookies.rb, so the population spreading is a finding rather than a count that drifted.

The two structural facts the file already pinned — one delta per site, 59 files — now apply to the union of the three arms, so they stay the same two facts about the same 876 rows across the split.

MUTATIONS (RUN), and which gate fired

The three gates are ordered, so each run names the one that caught it. The third neutralises the first two, because a mutation that survives under a different gate proves nothing about the gate under it.

  • restore the absorbing arm (SelfQualifier for any diff ⊆ {qualifier, target}) → RED at the ROW/RULE cross-check: SelfGateRefusesAMixinAccessor … left: 0, right: 11.
  • swap the two direction arms → RED at the same cross-check, left: 0, right: 11 — which is the point of the second observation.
  • the absorbing arm again, both cross-checks neutralised → RED at the file-set pin: left: {}, right: {"sinatra-contrib/lib/sinatra/cookies.rb"}.

Suite green after: the_package_and_the_builtin_project_the_same_index ... ok, executed=1, controls=4, printing

#84 axis C — pinned deltas
  SelfQualifier: 834
  QualifierTextCostsARelativeBind: 31
  SelfGateRefusesAMixinAccessor: 11

(Variants renamed from the drafting names SelfQualifierCostsARelativeBind / SelfQualifierLiftsASameFileRefusal: clippy's enum_variant_names refuses three variants sharing one prefix, and the names it forced are better — one says what was lost, the other says what refused.)

What this leaves open

SelfGateRefusesAMixinAccessor is now a named product finding, not a parity artefact: the builtin resolver loses 11 real binds in ruby-sinatra to the self-gate's same-file restriction, and we only know because a packaged leg without the qualifier text found them. Whether the gate should admit a mixin's host is #86/I026 territory and is not touched here. If that is ever fixed, this mechanism goes to zero and its variant should be deleted rather than pinned at zero — the file's own rule, for the reason Mechanism's doc gives.

## Direction 1 done; direction 2 REFUTED by reading the eleven rows Measured in `/tmp/cosi-lane-precision` off `origin/master` (`f6a878a`), ruby-sinatra at its pinned sha, both legs indexed as the suite does. ### The 876, decomposed Instrumented `compare` to classify every row `classify` calls `SelfQualifier` by the direction of its resolution difference: ``` qualifier_only 834 target b=UNRES p=res brule=-1 prule=13 (file_key_import) 11 target b=res p=UNRES brule=41 (tier1q_rel_samefile) prule=-1 31 --- 876 ``` 834 + 42, exactly as `_prdoc/records/84-P4-parity-deltas.md` says. What the record did not say is that the 42 run in **two opposite directions**. ### Direction 2's premise is false, and the eleven rows say so at source The direction proposes requiring the resolution difference to run the way the mechanism predicts, on the premise that *"a missing qualifier can only cost the package a bind, never gain it one; 11 of these rows are the package GAINING one, which the mechanism cannot explain."* All eleven are in **one file**, `sinatra-contrib/lib/sinatra/cookies.rb`: ``` B: sinatra-contrib/lib/sinatra/cookies.rb read request 33:5 qualifier=self ~UNRESOLVED~ P: sinatra-contrib/lib/sinatra/cookies.rb read request 33:5 qualifier= lib/sinatra/base.rb#request@978 ``` …and the same for `response`. `lib/sinatra/base.rb:978` is the `attr_accessor` on `Sinatra::Base`. `Sinatra::Cookies` is a helper module **mixed into** `Sinatra::Base`, so `self.request` there names exactly that accessor. **The package leg's answer is the correct one, and the builtin's refusal is the error.** The mechanism is the `self` qualifier's own gate, not the port: a `self`/`Self`/`static`/`parent` qualifier restricts the candidate pool to the ref's own file (I026), `cookies.rb` declares no `request`, so the builtin refuses; with no qualifier text the package never enters that gate, reaches tier 1b's file-key import arm, and lands on the truth. That is the I026 residual already on record — *"Self::/static:: resolve by locality not impl-hierarchy"* — showing up in Ruby, where a mixin's `self.x` is by construction not in the mixin's file. So sending these eleven to `None` would make the suite panic on **correct binds**. Direction 2 is refused. The rows do not need a louder destination; they need a name. ### Direction 1, implemented — three mechanisms, all predicates ON THE ROW `Mechanism` is now: | variant | rows | predicate (row-only) | |---|---:|---| | `SelfQualifier` | **834** | `diff` does not contain the target column | | `QualifierTextCostsARelativeBind` | **31** | builtin has a target, package `~UNRESOLVED~` | | `SelfGateRefusesAMixinAccessor` | **11** | builtin `~UNRESOLVED~`, package has a target | Nothing reads a rule column to classify, so the split needs no signature change and stays a statement about the data. And the case the old predicate quietly admitted — **both** legs bound, to **different** targets — now falls to `_ => None` and panics. It has never been observed here, and that is recorded as ungraded rather than claimed. The absorbed-count backstop (`SELF_QUALIFIER_ROWS_WITH_A_RESOLUTION_DELTA = 11`) is replaced by something stronger, not deleted: **each consequence arm is counted a SECOND time from the deciding RULE** — the column `classify` does not read. - `SelfGateRefusesAMixinAccessor`: `brule == -1 && prule == 13` - `QualifierTextCostsARelativeBind`: `brule == 41 && prule == -1` A split that mis-assigns a row agrees with itself; it cannot agree with a column it never looked at. Plus a file-set pin: every `SelfGateRefusesAMixinAccessor` row must be in `sinatra-contrib/lib/sinatra/cookies.rb`, so the population spreading is a finding rather than a count that drifted. The two structural facts the file already pinned — one delta per site, **59 files** — now apply to the union of the three arms, so they stay the same two facts about the same 876 rows across the split. ### MUTATIONS (RUN), and which gate fired The three gates are ordered, so each run names the one that caught it. The third neutralises the first two, because a mutation that survives under a different gate proves nothing about the gate under it. * **restore the absorbing arm** (`SelfQualifier` for any `diff ⊆ {qualifier, target}`) → **RED at the ROW/RULE cross-check**: `SelfGateRefusesAMixinAccessor … left: 0, right: 11`. * **swap the two direction arms** → **RED at the same cross-check**, `left: 0, right: 11` — which is the point of the second observation. * **the absorbing arm again, both cross-checks neutralised** → **RED at the file-set pin**: `left: {}, right: {"sinatra-contrib/lib/sinatra/cookies.rb"}`. Suite green after: `the_package_and_the_builtin_project_the_same_index ... ok`, `executed=1`, `controls=4`, printing ``` #84 axis C — pinned deltas SelfQualifier: 834 QualifierTextCostsARelativeBind: 31 SelfGateRefusesAMixinAccessor: 11 ``` (Variants renamed from the drafting names `SelfQualifierCostsARelativeBind` / `SelfQualifierLiftsASameFileRefusal`: clippy's `enum_variant_names` refuses three variants sharing one prefix, and the names it forced are better — one says what was lost, the other says what refused.) ### What this leaves open `SelfGateRefusesAMixinAccessor` is now a **named product finding**, not a parity artefact: the builtin resolver loses 11 real binds in ruby-sinatra to the `self`-gate's same-file restriction, and we only know because a packaged leg without the qualifier text found them. Whether the gate should admit a mixin's host is #86/I026 territory and is not touched here. If that is ever fixed, this mechanism goes to zero *and its variant should be deleted rather than pinned at zero* — the file's own rule, for the reason `Mechanism`'s doc gives.
Author
Member

CLOSING — direction 1 is on master and graded; direction 2 stays refuted

Close-out lane. Verified on master 552e3a2 in /tmp/cosi-lane-closeout.

The arm no longer absorbs

Mechanism (crates/indexer/tests/ruby_package_parity.rs:409) now has three variants where the absorbing one stood:

:424  SelfQualifier                      diff does not contain the target column
:437  QualifierTextCostsARelativeBind    builtin has a target, package ~UNRESOLVED~
:465  SelfGateRefusesAMixinAccessor      builtin ~UNRESOLVED~, package has a target

The backstop this issue asked for — SELF_QUALIFIER_ROWS_WITH_A_RESOLUTION_DELTA = 11 — is gone from the file, replaced by something stronger rather than deleted: each consequence arm is counted a second time from the deciding rule column (brule/prule), which classify never reads, plus a file-set pin. The case the old predicate quietly admitted — both legs bound to different targets — now falls to _ => None and panics.

Run here, exit code captured directly (no pipe)

COSI_CORPUS_DIR=$HOME/.cache/cosi-corpus COSI_CORPUS_REQUIRE=1 \
cargo test -p code-index-indexer --test ruby_package_parity -- --nocapture   EXIT=0

  SelfQualifier: 834
  QualifierTextCostsARelativeBind: 31
  SelfGateRefusesAMixinAccessor: 11
corpus[ruby_package_parity]: executed=1 unavailable=0 not_applicable=0 controls=4 (require=true)
test result: ok. 1 passed; 0 failed

executed=1, controls=4 — not the vacuous executed=0 unavailable=1 shape. 834 + 31 + 11 = 876, the same population, now split by three predicates each checked on the row.

Direction 2 is refused, and that refusal is the finding

The premise — "a missing qualifier can only cost the package a bind, never gain it one" — is false, and the eleven rows say so at source. All eleven are self.request / self.response in sinatra-contrib/lib/sinatra/cookies.rb, binding the attr_accessor at lib/sinatra/base.rb:978. Sinatra::Cookies is mixed into Sinatra::Base, so the package leg's answer is the correct one and the builtin's refusal is the error. Sending those rows to None would make the suite panic on correct binds.

Residual, which is NOT this issue

SelfGateRefusesAMixinAccessor is now a named product finding rather than a parity artefact: the builtin resolver loses 11 real binds on ruby-sinatra to the self-gate's same-file restriction (the I026 residual, "Self::/static:: resolve by locality not impl-hierarchy", surfacing in Ruby where a mixin's self.x is by construction not in the mixin's file). That belongs to #86, and if it is ever fixed the variant should be deleted, not pinned at zero — the file's own rule.

Closing: the arm that absorbed unexplained resolution differences is split, every arm's predicate is checked on the row, and the count is cross-checked against a column the classifier cannot see.

## CLOSING — direction 1 is on master and graded; direction 2 stays refuted Close-out lane. Verified on master `552e3a2` in `/tmp/cosi-lane-closeout`. ### The arm no longer absorbs `Mechanism` (`crates/indexer/tests/ruby_package_parity.rs:409`) now has three variants where the absorbing one stood: ``` :424 SelfQualifier diff does not contain the target column :437 QualifierTextCostsARelativeBind builtin has a target, package ~UNRESOLVED~ :465 SelfGateRefusesAMixinAccessor builtin ~UNRESOLVED~, package has a target ``` The backstop this issue asked for — `SELF_QUALIFIER_ROWS_WITH_A_RESOLUTION_DELTA = 11` — is **gone from the file**, replaced by something stronger rather than deleted: each consequence arm is counted a second time from the deciding rule column (`brule`/`prule`), which `classify` never reads, plus a file-set pin. The case the old predicate quietly admitted — both legs bound to *different* targets — now falls to `_ => None` and panics. ### Run here, exit code captured directly (no pipe) ``` COSI_CORPUS_DIR=$HOME/.cache/cosi-corpus COSI_CORPUS_REQUIRE=1 \ cargo test -p code-index-indexer --test ruby_package_parity -- --nocapture EXIT=0 SelfQualifier: 834 QualifierTextCostsARelativeBind: 31 SelfGateRefusesAMixinAccessor: 11 corpus[ruby_package_parity]: executed=1 unavailable=0 not_applicable=0 controls=4 (require=true) test result: ok. 1 passed; 0 failed ``` `executed=1`, `controls=4` — not the vacuous `executed=0 unavailable=1` shape. 834 + 31 + 11 = 876, the same population, now split by three predicates each checked on the row. ### Direction 2 is refused, and that refusal is the finding The premise — *"a missing qualifier can only cost the package a bind, never gain it one"* — is false, and the eleven rows say so at source. All eleven are `self.request` / `self.response` in `sinatra-contrib/lib/sinatra/cookies.rb`, binding the `attr_accessor` at `lib/sinatra/base.rb:978`. `Sinatra::Cookies` is mixed into `Sinatra::Base`, so **the package leg's answer is the correct one and the builtin's refusal is the error**. Sending those rows to `None` would make the suite panic on correct binds. ### Residual, which is NOT this issue `SelfGateRefusesAMixinAccessor` is now a named **product** finding rather than a parity artefact: the builtin resolver loses 11 real binds on ruby-sinatra to the `self`-gate's same-file restriction (the I026 residual, *"Self::/static:: resolve by locality not impl-hierarchy"*, surfacing in Ruby where a mixin's `self.x` is by construction not in the mixin's file). That belongs to **#86**, and if it is ever fixed the variant should be **deleted, not pinned at zero** — the file's own rule. Closing: the arm that absorbed unexplained resolution differences is split, every arm's predicate is checked on the row, and the count is cross-checked against a column the classifier cannot see.
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#170
No description provided.