Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions tests/test_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------------------------------


Expand Down
30 changes: 26 additions & 4 deletions xbrlkit/serve/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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 = []
Expand Down