From 51364158f7a1e4581eb31fe7895faabc0c6edfdd Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 10 Oct 2026 10:50:35 +0200 Subject: [PATCH] fix(registry): break suffix_match ties by kind, C# stub, then QN order, not depth 1a342553 made suffix_match ties deterministic by preferring the least nested QN, then the smaller QN. Determinism was the point, and it stays. But depth says nothing about which same-named definition a call means. An audit of main against v0.11.0 (2026-10-10) traced three resolution regressions to that one rule: - django: about 5,600 `Model.objects.X(...)` calls tie between `QuerySet.X` and same-named methods elsewhere. `Library.filter`, `EngineHandler.all` and `HttpResponseBase.get` are each one segment shallower, so the QuerySet share fell from 57 % to 24 %. - Kotlin / Java: a call went to a property or field that merely shares the callee's name. Kotlin `where(...)` went to `var where`, and Java `esr.indexMode()` to a field whose QN lacks its class segment. Kotlin CALLS edges on Variable/Field targets went 785 -> 1,648. - C#: .NET reference assemblies (`src/libraries//ref/.cs`, `throw null` bodies) are shallower than the implementation, and "ref" sorts before "src". 14.1 % of dotnet/runtime's CALLS landed on such stubs (4.9 % in v0.11.0). A tie now goes to: 1. the more plainly callable candidate: Function/Method, then any other definition, then an unknown label, then Variable/Field; 2. for C# only, an implementation over a reference-assembly stub; 3. the lexicographically smaller QN. This is still a pure function of the candidate set (O9). The kernel cases behind 1a342553 keep their answers: `dev_name` is the only Function among the fields, and include/linux sorts before tools/virtio. Measured on the bench corpora (macOS arm64, main cb8b641f vs this change, one cold full index each): - django: `objects.X` -> QuerySet 23.7 % -> 41.4 %; CALLS -> Variable 1,022 -> 741 - Kotlin (Exposed): CALLS -> Variable 1,648 -> 480 - Java (elasticsearch): CALLS -> Variable/Field 62,313 -> 42,957 - C# (dotnet/runtime): CALLS -> ref/ stubs 143,799 -> 52,000; CALLS -> Variable/Field 33,779 -> 28,662 - TypeScript: CALLS -> Variable 7,978 -> 7,341 The rest of the django gap needs Manager -> QuerySet typing, which no tie order can supply. Tests: - registry_tie_prefers_callable_over_property - registry_tie_ignores_nesting_depth - registry_tie_prefers_csharp_implementation_over_ref_stub All three are RED on main and RED again with the registry change reverted. Signed-off-by: Martin Vogel --- src/pipeline/registry.c | 120 ++++++++++++++++++++++++++++------------ tests/test_registry.c | 106 +++++++++++++++++++++++++++++++++-- 2 files changed, 186 insertions(+), 40 deletions(-) diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 213554243..644b42b7c 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -223,6 +223,12 @@ static const uint8_t *qn_test_flags(const qn_array_t *arr) { return arr->is_test && arr->is_test_cap >= arr->count ? arr->is_test : NULL; } +/* The cached candidate languages, under the same coverage rule: both side + * arrays grow together and share is_test_cap. */ +static const uint8_t *qn_lang_flags(const qn_array_t *arr) { + return arr->lang && arr->is_test_cap >= arr->count ? arr->lang : NULL; +} + /* Score a candidate for tiebreaking. Higher = better. * Layer 1: Non-test code preferred over test code (+1000) * Layer 2: Namespace proximity via common prefix length (+plen) @@ -237,16 +243,37 @@ static int candidate_score(const char *candidate_qn, const char *module_qn, int return score; } -/* Number of '.'-separated segments in a QN: how deeply the definition is - * nested (file depth + enclosing types). */ -static int qn_depth(const char *qn) { - int depth = 1; - for (const char *p = qn; *p; p++) { - if (*p == '.') { - depth++; - } +/* How plainly a candidate is something a call can target, lower = better: a + * function or method; then any other definition (a class for a constructor + * call, a macro, a type); then a candidate whose label is unknown; last a + * variable or field, which a call reaches only when it holds a function. */ +enum { + REG_RANK_CALLABLE = 0, + REG_RANK_OTHER_DEF = 1, + REG_RANK_UNKNOWN = 2, + REG_RANK_DATA = 3, +}; + +static int callable_rank(const cbm_registry_t *r, const char *qn) { + const char *label = r ? cbm_registry_label_of(r, qn) : NULL; + if (!label) { + return REG_RANK_UNKNOWN; + } + if (strcmp(label, "Function") == 0 || strcmp(label, "Method") == 0) { + return REG_RANK_CALLABLE; + } + if (strcmp(label, "Variable") == 0 || strcmp(label, "Field") == 0) { + return REG_RANK_DATA; } - return depth; + return REG_RANK_OTHER_DEF; +} + +/* A .NET reference assembly (`src/libraries//ref/.cs`) declares an + * assembly's public surface with `throw null` bodies in one shallow file; a + * call that could equally mean the implementation means the implementation. + * C# only: elsewhere a `ref` directory is an ordinary name. */ +static bool csharp_reference_stub(const char *qn, uint8_t lang) { + return lang == CBM_LANG_CSHARP && strstr(qn, ".ref.") != NULL; } /* Total order among candidates that tie on candidate_score. The registry's @@ -255,33 +282,49 @@ static int qn_depth(const char *qn) { * that order (kernel: 632 CALLS edges differed between two indexes of the * same tree, `dev_name` flipping between twenty same-named struct fields * and unresolved, `sg_set_buf` between include/linux and tools/virtio). - * The rule: the least nested definition wins (a top-level function over a - * same-named member two types deep, include/linux over tools/virtio/linux), - * then the lexicographically smaller QN — a pure function of the candidate - * set, never of the order it was built in (O9). */ -static bool candidate_outranks_on_tie(const char *candidate, const char *best) { - int cd = qn_depth(candidate); - int bd = qn_depth(best); - if (cd != bd) { - return cd < bd; + * The rule, a pure function of the candidate set (O9): + * 1. the more plainly callable candidate (`dev_name` the function over the + * struct fields; Kotlin `where(...)` the method over the property); + * 2. for C#, an implementation over a reference-assembly stub; + * 3. the lexicographically smaller QN (include/linux over tools/virtio). + * Nesting depth is deliberately not a criterion: "least nested wins" sent + * django's `Model.objects.filter` to the template `Library.filter`, one + * segment shallower than `QuerySet.filter` (2026-10-10 audit). */ +static bool candidate_outranks_on_tie(const cbm_registry_t *r, const char *candidate, + uint8_t candidate_lang, const char *best, uint8_t best_lang) { + int ck = callable_rank(r, candidate); + int bk = callable_rank(r, best); + if (ck != bk) { + return ck < bk; + } + bool cs = csharp_reference_stub(candidate, candidate_lang); + bool bs = csharp_reference_stub(best, best_lang); + if (cs != bs) { + return !cs; } return strcmp(candidate, best) < 0; } /* Pick candidate with highest composite score (test-deprioritization + namespace - * proximity). `is_test_flags` is the candidates' cached test verdicts, or NULL - * when the caller has none (a filtered subset carries its own copy). */ -static const char *best_by_import_distance(const char **candidates, const uint8_t *is_test_flags, + * proximity), ties settled by candidate_outranks_on_tie. `is_test_flags` and + * `lang_flags` are the candidates' cached test verdicts and languages, or NULL + * when the caller has none (a filtered subset carries its own copies). */ +static const char *best_by_import_distance(const cbm_registry_t *r, const char **candidates, + const uint8_t *is_test_flags, const uint8_t *lang_flags, int count, const char *module_qn) { const char *best = NULL; + uint8_t best_lang = (uint8_t)CBM_LANG_COUNT; int best_score = CBM_NOT_FOUND; for (int i = 0; i < count; i++) { int score = candidate_score(candidates[i], module_qn, is_test_flags ? (int)is_test_flags[i] : -1); + uint8_t lang = lang_flags ? lang_flags[i] : (uint8_t)CBM_LANG_COUNT; if (score > best_score || - (score == best_score && best && candidate_outranks_on_tie(candidates[i], best))) { + (score == best_score && best && + candidate_outranks_on_tie(r, candidates[i], lang, best, best_lang))) { best_score = score; best = candidates[i]; + best_lang = lang; } } return best; @@ -1409,17 +1452,23 @@ static cbm_resolution_t resolve_same_module(const cbm_registry_t *r, const char } /* Strategy 4: multiple candidates with import filtering. */ -static cbm_resolution_t resolve_multi_with_imports(const qn_array_t *arr, const char *module_qn, - const char **import_vals, int import_count) { +static cbm_resolution_t resolve_multi_with_imports(const cbm_registry_t *r, const qn_array_t *arr, + const char *module_qn, const char **import_vals, + int import_count) { const char *filtered[CBM_SZ_256]; uint8_t filtered_test[CBM_SZ_256]; + uint8_t filtered_lang[CBM_SZ_256]; const uint8_t *flags = qn_test_flags(arr); + const uint8_t *langs = qn_lang_flags(arr); int fcount = 0; for (int i = 0; i < arr->count && fcount < CBM_SZ_256; i++) { if (is_import_reachable(arr->items[i], import_vals, import_count)) { if (flags) { filtered_test[fcount] = flags[i]; } + if (langs) { + filtered_lang[fcount] = langs[i]; + } filtered[fcount] = arr->items[i]; fcount++; } @@ -1429,16 +1478,16 @@ static cbm_resolution_t resolve_multi_with_imports(const qn_array_t *arr, const return (cbm_resolution_t){filtered[0], "suffix_match", conf, arr->count}; } if (fcount > SKIP_ONE) { - const char *best = - best_by_import_distance(filtered, flags ? filtered_test : NULL, fcount, module_qn); + const char *best = best_by_import_distance(r, filtered, flags ? filtered_test : NULL, + langs ? filtered_lang : NULL, fcount, module_qn); if (best) { double conf = candidate_count_penalty(CONF_SUFFIX_MATCH, fcount); return (cbm_resolution_t){best, "suffix_match", conf, fcount}; } } /* No import-reachable — use all candidates with penalty */ - const char *best = best_by_import_distance((const char **)arr->items, qn_test_flags(arr), - arr->count, module_qn); + const char *best = best_by_import_distance(r, (const char **)arr->items, qn_test_flags(arr), + qn_lang_flags(arr), arr->count, module_qn); if (best) { double conf = candidate_count_penalty(CONF_SUFFIX_MATCH * REG_HALF_PENALTY, arr->count); return (cbm_resolution_t){best, "suffix_match", conf, arr->count}; @@ -1632,10 +1681,10 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char /* Strategy 4: multiple candidates */ if (import_vals && import_count > 0) { - return resolve_multi_with_imports(arr, module_qn, import_vals, import_count); + return resolve_multi_with_imports(r, arr, module_qn, import_vals, import_count); } - const char *best = best_by_import_distance((const char **)arr->items, qn_test_flags(arr), - arr->count, module_qn); + const char *best = best_by_import_distance(r, (const char **)arr->items, qn_test_flags(arr), + qn_lang_flags(arr), arr->count, module_qn); if (best) { if (!receiver_chain_admits(callee_name, best)) { return empty_result(); @@ -2011,8 +2060,8 @@ cbm_fuzzy_result_t cbm_registry_fuzzy_resolve(const cbm_registry_t *r, const cha if (fcount == 0) { /* No import-reachable — use originals with penalty */ - const char *best = best_by_import_distance((const char **)arr->items, qn_test_flags(arr), - arr->count, module_qn); + const char *best = best_by_import_distance(r, (const char **)arr->items, qn_test_flags(arr), + qn_lang_flags(arr), arr->count, module_qn); if (!best) { return no_match; } @@ -2026,8 +2075,9 @@ cbm_fuzzy_result_t cbm_registry_fuzzy_resolve(const cbm_registry_t *r, const cha {fptr[0], "fuzzy", candidate_count_penalty(CONF_FUZZY_SINGLE, arr->count), arr->count}, true}; } - const char *best = best_by_import_distance( - fptr, fptr == (const char **)arr->items ? qn_test_flags(arr) : NULL, fcount, module_qn); + const bool all = fptr == (const char **)arr->items; + const char *best = best_by_import_distance(r, fptr, all ? qn_test_flags(arr) : NULL, + all ? qn_lang_flags(arr) : NULL, fcount, module_qn); if (!best) { return no_match; } diff --git a/tests/test_registry.c b/tests/test_registry.c index 90ebf5b8a..d581464ec 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -919,9 +919,9 @@ TEST(cross_language_config_caller_drops_unique_name_too) { * to be settled by bucket position, i.e. by the order files were registered: * two indexes of one kernel tree differed by 632 CALLS edges, `dev_name` * landing on any of twenty same-named struct fields or on nothing at all. - * The tie is now a function of the candidate set: the least nested - * definition, then the smaller QN — whichever order the registry was built - * in (O9). */ + * The tie is now a function of the candidate set: the more plainly callable + * candidate (the function over the fields), then the smaller QN, whichever + * order the registry was built in (O9). */ TEST(registry_tie_break_is_independent_of_registration_order) { const char *cands[] = { "proj.drivers.media.cec.i2c.ch7322.ch7322_conn_match.dev_name", /* struct field */ @@ -951,7 +951,7 @@ TEST(registry_tie_break_is_independent_of_registration_order) { ASSERT_STR_EQ(f.strategy, "suffix_match"); ASSERT_STR_EQ(b.strategy, "suffix_match"); - /* Equal depth: the smaller QN, from either order. */ + /* Equal kind: the smaller QN, from either order. */ cbm_registry_t *lex_a = cbm_registry_new(); cbm_registry_t *lex_b = cbm_registry_new(); cbm_registry_add(lex_a, "sg_next", "proj.tools.virtio.scatterlist.sg_next", "Function"); @@ -965,7 +965,7 @@ TEST(registry_tie_break_is_independent_of_registration_order) { ASSERT_STR_EQ(la.qualified_name, "proj.include.linux.scatterlist.sg_next"); ASSERT_STR_EQ(lb.qualified_name, "proj.include.linux.scatterlist.sg_next"); - /* Proximity still outranks depth: the sibling wins over a shallower stranger. */ + /* Proximity outranks the tie order: the sibling wins over a shallower stranger. */ cbm_registry_t *near = cbm_registry_new(); cbm_registry_add(near, "vnic_rq_free", "proj.lib.vnic_rq_free", "Function"); cbm_registry_add(near, "vnic_rq_free", "proj.drivers.scsi.fnic.vnic_rq.vnic_rq_free", @@ -982,6 +982,99 @@ TEST(registry_tie_break_is_independent_of_registration_order) { PASS(); } +/* A call names something callable. When same-named candidates tie, a method + * must not lose to a property or field that merely shares its name. Exposed + * (Kotlin): `cities.selectAll().where { ... }` bound to the property + * `AbstractQuery.where` instead of the jdbc `Query.where` function, because the + * two QNs have the same depth and "exposed-core" sorts first; main moved 785 -> + * 1,648 Kotlin CALLS edges onto Variable/Field targets this way. */ +TEST(registry_tie_prefers_callable_over_property) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add( + r, "where", + "proj.exposed-core.src.main.kotlin.org.jetbrains.exposed.v1.core.AbstractQuery.where", + "Variable"); + cbm_registry_add(r, "where", + "proj.exposed-jdbc.src.main.kotlin.org.jetbrains.exposed.v1.jdbc.Query.where", + "Method"); + /* A deeper method still beats a shallower field: kind decides before order. */ + cbm_registry_add(r, "limit", "proj.exposed-core.Table.limit", "Field"); + cbm_registry_add(r, "limit", "proj.exposed-jdbc.src.main.kotlin.jdbc.Query.limit", "Method"); + + const char *caller = + "proj.exposed-tests.src.test.kotlin.org.jetbrains.exposed.v1.tests.SelectTests"; + cbm_resolution_t w = cbm_registry_resolve(r, "where", caller, NULL, NULL, 0); + cbm_resolution_t l = cbm_registry_resolve(r, "limit", caller, NULL, NULL, 0); + ASSERT_STR_EQ(w.qualified_name, + "proj.exposed-jdbc.src.main.kotlin.org.jetbrains.exposed.v1.jdbc.Query.where"); + ASSERT_STR_EQ(l.qualified_name, "proj.exposed-jdbc.src.main.kotlin.jdbc.Query.limit"); + cbm_registry_free(r); + PASS(); +} + +/* Nesting depth says nothing about which same-named method a call means. + * django: `Model.objects.filter(...)` from tests/ tied between + * `QuerySet.filter` and the template `Library.filter`; "least nested wins" + * picked Library (one segment shallower) for most of the ~5,600 `objects.X` + * calls (QuerySet share 57 % -> 24 %). Equal-kind ties now fall to the QN + * order alone, which is still a pure function of the candidate set (O9). */ +TEST(registry_tie_ignores_nesting_depth) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "filter", "proj.django.template.library.Library.filter", "Method"); + cbm_registry_add(r, "filter", "proj.django.db.models.query.QuerySet.filter", "Method"); + cbm_resolution_t f = + cbm_registry_resolve(r, "filter", "proj.tests.lookup.tests", NULL, NULL, 0); + ASSERT_STR_EQ(f.qualified_name, "proj.django.db.models.query.QuerySet.filter"); + ASSERT_STR_EQ(f.strategy, "suffix_match"); + cbm_registry_free(r); + PASS(); +} + +/* .NET reference assemblies (`src/libraries//ref/.cs`) declare every + * public member of an assembly with a `throw null` body in one shallow file. + * A call that ties between such a stub and the implementation must reach the + * implementation: dotnet/runtime had 14.1 % of its CALLS on ref/ stubs (4.9 % + * in v0.11.0) once ties preferred the shallower, then the smaller ("ref" < + * "src") QN. The preference is C#'s alone: elsewhere a `ref` directory is an + * ordinary name. */ +TEST(registry_tie_prefers_csharp_implementation_over_ref_stub) { + cbm_registry_t *r = cbm_registry_new(); + /* Shallower stub vs deeper implementation (MemoryStream). */ + cbm_registry_add_lang( + r, "MemoryStream", + "proj.src.libraries.System.Runtime.ref.System.Runtime.MemoryStream.MemoryStream", "Method", + CBM_LANG_CSHARP); + cbm_registry_add_lang( + r, "MemoryStream", + "proj.src.libraries.System.Private.CoreLib.src.System.IO.MemoryStream.MemoryStream", + "Method", CBM_LANG_CSHARP); + /* Equal depth, "ref" sorts before "src" (WithCancellation). */ + cbm_registry_add_lang(r, "WithCancellation", + "proj.src.libraries.System.Linq.Parallel.ref.System.Linq." + "ParallelEnumerable.WithCancellation", + "Method", CBM_LANG_CSHARP); + cbm_registry_add_lang(r, "WithCancellation", + "proj.src.libraries.System.Linq.Parallel.src.System.Linq." + "ParallelEnumerable.WithCancellation", + "Method", CBM_LANG_CSHARP); + /* Not C#: a `ref` directory gets no special treatment. */ + cbm_registry_add_lang(r, "resolve", "proj.lib.ref.Resolver.resolve", "Method", CBM_LANG_JAVA); + cbm_registry_add_lang(r, "resolve", "proj.lib.src.Resolver.resolve", "Method", CBM_LANG_JAVA); + + const char *caller = "proj.src.libraries.System.Security.Cryptography.src.CapiHelper"; + cbm_resolution_t ms = cbm_registry_resolve(r, "MemoryStream", caller, NULL, NULL, 0); + cbm_resolution_t wc = cbm_registry_resolve(r, "WithCancellation", caller, NULL, NULL, 0); + cbm_resolution_t jv = cbm_registry_resolve(r, "resolve", "proj.app.Main", NULL, NULL, 0); + ASSERT_STR_EQ( + ms.qualified_name, + "proj.src.libraries.System.Private.CoreLib.src.System.IO.MemoryStream.MemoryStream"); + ASSERT_STR_EQ(wc.qualified_name, "proj.src.libraries.System.Linq.Parallel.src.System.Linq." + "ParallelEnumerable.WithCancellation"); + ASSERT_STR_EQ(jv.qualified_name, "proj.lib.ref.Resolver.resolve"); + cbm_registry_free(r); + PASS(); +} + TEST(lang_resolution_compatible_families) { /* Same language and the interop families the resolvers already honour. */ ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_PYTHON, CBM_LANG_PYTHON)); @@ -1643,6 +1736,9 @@ SUITE(registry) { RUN_TEST(same_module_vetoed_across_languages); RUN_TEST(cross_language_config_caller_drops_unique_name_too); RUN_TEST(registry_tie_break_is_independent_of_registration_order); + RUN_TEST(registry_tie_prefers_callable_over_property); + RUN_TEST(registry_tie_ignores_nesting_depth); + RUN_TEST(registry_tie_prefers_csharp_implementation_over_ref_stub); RUN_TEST(cross_language_ref_drops_go_vs_c); RUN_TEST(go_bare_ref_never_binds_field); RUN_TEST(call_onto_field_follows_language_and_shape);