From 5058c8335fe0694324a9717d9a095c3d52636a1d Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Wed, 16 Sep 2026 16:12:16 -0500 Subject: [PATCH] fix(serve): rank resolve_element ties by closeness, not the alphabet A phrase prefixes hundreds of concepts in one score tier, and ties fell to fact count and then the alphabet. A filing's fact counts usually separate them; a taxonomy reports nothing, so US GAAP answered "revenue" with RevenueChangeInJudgment and ranked Revenues past 100. - Within a tier: reported concepts first, then an amount before a heading, a text item or an enumeration, then the shortest name, then the alphabet. - A phrase that is the name or label but for spacing, punctuation or a plural scores as exact: "operating lease liability" is "Operating Lease, Liability", "cash and cash equivalents" is "Cash and Cash Equivalent". --- tests/test_serve.py | 47 ++++++++++++++++++++++++++++++++++++++++++ xbrlkit/serve/tools.py | 30 +++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/tests/test_serve.py b/tests/test_serve.py index 8b3cc73..2673c0a 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -375,6 +375,53 @@ def test_resolve_element_by_qname_and_label(loaded: LoadedFiling) -> None: tools.resolve_element(loaded, " ") +def test_resolve_element_ranks_a_taxonomy_with_no_facts_to_lean_on() -> None: + """A taxonomy reports nothing, so fact counts cannot separate the hundreds + of concepts a phrase prefixes. Before the tie-break, US GAAP answered + "revenue" with RevenueChangeInJudgment and ranked Revenues past 100.""" + lease = "us-gaap:OperatingLeaseLiability" + cash = "us-gaap:CashAndCashEquivalentsAtCarryingValue" + concepts = { + "us-gaap:RevenueChangeInJudgment": _concept( + "RevenueChangeInJudgment", is_numeric=False, item_type="stringItemType" + ), + "us-gaap:RevenueCommissionersIrelandMember": _concept( + "RevenueCommissionersIrelandMember", is_abstract=True, is_numeric=False + ), + "us-gaap:RevenueFromRelatedParties": _concept("RevenueFromRelatedParties"), + "us-gaap:Revenues": _concept("Revenues"), + "us-gaap:IncreaseDecreaseInOperatingLeaseLiability": _concept( + "IncreaseDecreaseInOperatingLeaseLiability", + pref_label="Increase (Decrease) in Operating Lease Liability", + ), + lease: _concept("OperatingLeaseLiability", pref_label="Operating Lease, Liability"), + "us-gaap:CashAndCashEquivalentsFairValueDisclosure": _concept( + "CashAndCashEquivalentsFairValueDisclosure", + pref_label="Cash and Cash Equivalents, Fair Value Disclosure", + ), + cash: _concept( + "CashAndCashEquivalentsAtCarryingValue", pref_label="Cash and Cash Equivalent" + ), + } + model = XbrlModel( + filing=FilingMeta(accession="us-gaap-entryPoint-all-2025", cik=""), + entity=EntityIdentity(cik=""), + concepts=concepts, + ) + lf = LoadedFiling(id="taxonomy", source="memory", model=model, text="", sections=[]) + + def first(query: str) -> str: + return tools.resolve_element(lf, query)["matches"][0]["qname"] + + # The closest name among equal matches, an amount ahead of a text item and + # a heading. + assert first("revenue") == "us-gaap:Revenues" + # Spacing and punctuation do not keep a label from matching whole. + assert first("operating lease liability") == lease + # Nor does a plural. + assert first("cash and cash equivalents") == cash + + # -- fact grid ------------------------------------------------------------------ diff --git a/xbrlkit/serve/tools.py b/xbrlkit/serve/tools.py index 90f5c05..ad67c23 100644 --- a/xbrlkit/serve/tools.py +++ b/xbrlkit/serve/tools.py @@ -234,6 +234,12 @@ def _label_for_role(concept: Concept | None, role: str | None) -> str | None: return _pref_label(concept) +def _squash(text: str) -> str: + """Text with its case, spacing and punctuation gone, for comparing a phrase + with a concept name or label.""" + return re.sub(r"[^a-z0-9]", "", text.lower()) + + def _documentation(concept: Concept) -> str | None: for label in concept.labels: if label.role and label.role.endswith("documentation") and label.value: @@ -895,10 +901,11 @@ def resolve_element(lf: LoadedFiling, query: str, limit: int = 20) -> dict[str, if not q: raise ToolError("query is required") ql = q.lower() + squashed = _squash(ql) tokens = [t for t in re.split(r"[\s_\-]+", ql) if t] limit = max(1, min(int(limit or 20), 100)) - scored: list[tuple[float, int, str]] = [] + scored: list[tuple[tuple[Any, ...], int, str]] = [] for qname, concept in model.concepts.items(): if concept.is_hypercube_item or concept.is_dimension_item: continue @@ -910,6 +917,14 @@ def resolve_element(lf: LoadedFiling, query: str, limit: int = 20) -> dict[str, score = 100 elif local == ql or pref == ql: score = 90 + elif squashed and squashed.removesuffix("s") in ( + local.removesuffix("s"), + _squash(pref).removesuffix("s"), + ): + # The phrase is the name or label but for spacing, punctuation or a + # plural: "operating lease liability" is "Operating Lease, Liability", + # "cash and cash equivalents" is "Cash and Cash Equivalent". + score = 90 elif local.startswith(ql) or pref.startswith(ql): score = 70 elif tokens and all(t in local for t in tokens): @@ -919,11 +934,18 @@ def resolve_element(lf: LoadedFiling, query: str, limit: int = 20) -> dict[str, elif ql in qname.lower(): score = 40 if score: - scored.append((score, len(idx.by_concept.get(qname, ())), qname)) - scored.sort(key=lambda t: (-t[0], -t[1], t[2])) + # Within a tier the concepts the filing reports come first. A tier's + # hundreds of prefix matches all report nothing in a taxonomy, so after + # that: an amount before a heading, a text block or an enumeration, and + # the closest name — `Revenues` before `RevenueChangeInJudgment` — + # before the alphabet decides. + count = len(idx.by_concept.get(qname, ())) + rank = (-score, -count, concept.is_abstract, not concept.is_numeric, len(local)) + scored.append((rank, count, qname)) + scored.sort(key=lambda t: (t[0], t[2])) rows = [] - for score, count, qname in scored[:limit]: + for _, count, qname in scored[:limit]: concept = model.concepts[qname] roles = idx.concept_roles.get(qname, []) names = []