Materialize singleton classes#893
Conversation
| /// only created on demand: when a self-method, an `extend`, a `class << self`, or a class-level | ||
| /// instance variable forces one into existence. Many class and module objects therefore never | ||
| /// get a singleton even though every Ruby object has one. This pass fills the gaps. Ancestors are |
There was a problem hiding this comment.
This lazy behaviour seems desirable. What's the purpose of eagerly materializing all of them?
There was a problem hiding this comment.
To make find_member() work for all leaf singleton classes and keep the descendants include them.
The execution time is slightly improved compared to main. The graph memory footprint is <1% increased (because we have them), but I think it's acceptable.
There was a problem hiding this comment.
Another option is to make ancestors/descendants calculation entirely lazy. The graph will only keep direct ancestors/descendants edges, and we provide a utility that traverses them when the transitive ancestors or descendants are needed. (But we need to handle un-materialized singleton classes even in that case.)
31723c3 to
2da53a9
Compare
77de91a to
f8cafce
Compare
|
A few feedback while I'm still understanding the algorithm:
class Foo
class << self # Foo::<Foo> rank-1 singleton
class << self # Foo::<Foo>::<<Foo>> rank-2 singleton
end
end
end
But I can confirm memory usage (both RSS and graph memory) is roughly the same. |
| }); | ||
| context.resolve(); | ||
|
|
||
| // A rank-1 singleton is materialized for every class... |
There was a problem hiding this comment.
I don't think the ranking terminology is well-established in the repo yet. So this is a bit confusing to new readers.
Also, it's not entirely accurate to call Foo::<Foo> a rank-1 singleton class IMO as it implies a class can have multiple singleton classes, which is not true. Foo only has 1 singleton class. Foo::<Foo>::<<Foo>> should be called Foo::<Foo>'s singleton class. Not Foo's rank-2 singleton.
I'd change the comment to something like:
| // A rank-1 singleton is materialized for every class... | |
| // Every declared class has its singleton class automatically populated |
| // Materialize a rank-1 singleton class for every class/module declaration. This runs after | ||
| // every site that creates singletons organically (the convergence loop and | ||
| // `handle_remaining_definitions`), so future rank-N passes — which only build singletons for | ||
| // already-materialized ones — can be appended here and still observe the complete set. |
There was a problem hiding this comment.
Can we explain this in more plain English? Like:
Materialize singleton classes for every class/module declaration.
This runs after the main singleton class creation paths (the main resolution loop andhandle_remaining_definitions) so
it only creates singleton classes to fill the existing singleton classes' ancestors/descendants chain
|
@st0012 I think you compared the base commit of this PR and the HEAD. If we compare |
2da53a9 to
84b8052
Compare
848d5e5 to
4f065f3
Compare
- Add a `materialize_singleton_classes` pass that ensures every class and module declaration has a rank-1 singleton (e.g. `Foo::<Foo>`), even without self-methods, extends, or `class << self`. - Run it after every organic singleton-creation site (the convergence loop and `handle_remaining_definitions`) so future rank-N passes can build on the materialized set, and before `compute_descendants` so the inverted descendants include the singleton chains. - Update the fuzzy-search, CLI-metric, and codebase_stats tests for the extra singleton declarations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Skip `Namespace::SingletonClass` in `declaration_search` so materialized singletons (e.g. `Foo::<Foo>`) never surface as workspace symbols or MCP search results. - Add a regression test and adjust the empty-query test to count only searchable (non-singleton) declarations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…letons - Extend `materialize_singleton_classes` with a rank-N stage: when a nested `class << self` produces an explicit rank-`N` singleton (e.g. `Foo::<Foo>::<<Foo>>`), materialize the rank-`N` singleton of every subclass so the hierarchy stays complete. - Seed from the owners of source-backed rank-`>= 2` singletons — those with a nested `class << self` or a `def self.x` written inside one — and descend the class inheritance tree, climbing one rank per iteration via a local map of each singleton's direct-subclass singletons. Pure linearization artifacts (the singletons of an explicit singleton's ancestors) have no definition or member and are not seeds, so materialization stays scoped to the explicit subtree. - Collect every input both stages need (attached ids, class superclasses, seed owners) in a single declarations scan. - Add tests covering rank-2 materialization for a subclass, seeded by both a nested `class << self` and a member-only (`def self.x`) singleton. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4f065f3 to
76ec482
Compare
Summary
This PR materializes singleton class declarations during resolution.
class << selfhierarchies stay complete for subclasses without trying to materialize every possible singleton class.Why
Rubydex already creates singleton classes on demand for self methods,
extend,class << self, and class-level instance variables. That lazy behavior is correct for Ruby, but it means singleton classes exist in the graph only when source happens to force them into existence.This lazy graph shape leaves gaps in queries that need to walk through singleton-class ancestors and descendants:
find_memberand rely on the singleton ancestor chain being present.For example:
The first stage makes sure both
Foo::<Foo>andBar::<Bar>exist, soFoo::<Foo>can be recorded as an ancestor ofBar::<Bar>. Because source explicitly opens the singleton class ofFoo::<Foo>, the second stage also createsBar::<Bar>::<<Bar>>, the corresponding singleton class ofBar::<Bar>.This PR makes singleton class materialization eager after the main resolution work has settled. The additional declarations are internal and are filtered out of declaration search, so they do not become user-facing workspace symbols. In practice, the runtime and memory impact is negligible, so eager materialization removes these graph completeness gaps without a meaningful cost.
This PR is stacked on #892: singleton classes are materialized before descendants are recomputed, so the descendant relation is derived from the completed ancestor graph.
Notes
The materialization pass is intentionally bounded.
The first stage creates the singleton class of every class and module. For
class Bar < Foo, this createsFoo::<Foo>andBar::<Bar>even if neither class had a singleton class in source.The second stage creates deeper singleton classes only when source has already created a nested singleton class at that depth. In the example above,
Foo::<Foo>::<<Foo>>is source-backed because it came from the nestedclass << self, soFoo::<Foo>seeds creation of the matching singleton class for descendants likeBar::<Bar>.Pure singleton classes created only while linearizing ancestors are not used as seeds. For example, linearizing
Foo::<Foo>::<<Foo>>may requireObject::<Object>::<<Object>>, but that artifact should not cause Rubydex to create every possible singleton class underObject.Declaration search filters out singleton classes so internal names like
Foo::<Foo>do not surface as user-facing search results.Validation
cargo test -p rubydexPerformance and memory comparison against main
Target: large Ruby codebase
Commits:
main:f50e21de848d5e5bCommands:
--statstotal timeThe process-level peak memory number is from
/usr/bin/time -land can be noisy. The graph-memory benchmark is more directly tied to Rubydex's graph allocation; it shows only a+25.72 MB(+0.6%) increase while materializing17,858additional singleton class declarations.Because this PR is stacked, comparing the PR head against its immediate base branch can show different numbers than comparing the listed
maincommit against the PR head.