Skip to content

Commit 19087b7

Browse files
roed314claude
andcommitted
Make dynamic statistics drill-downs describe what they count
Three ways a count on the dynamic statistics page could link to a search returning different records than the count, all of them in the generic statistics framework rather than the p-adic tables: Cells of a two-dimensional grid with no records were synthesized by KeyedDefaultDict from the formatted row and column headers, so their urls were built from displayed values (TeX, html, "not computed") rather than stored ones. The totaler intersects the urls of a row to find the row's constraint, so a sparse row lost it: the "not nilpotent" total on the abstract groups statistics page linked to /Groups/Abstract/? and returned all 1.5 million groups instead of the 455903 counted. display_data now indexes counts by the stored values and builds every url, empty cells included, from those, so a formatter is free to produce TeX or html. Values that display identically share a row, adding their counts, rather than one silently replacing the other. Drill-down urls were serialized from the parsed query, whose columns are often not parameters the search page accepts: dynamic statistics constrained by Artin slopes produced slopes_tmp=2A, which the p-adic parser ignores, so clicking a count opened a broader search. dynamic_setup now passes the search boxes the user filled in as link_constraint, which reproduce the same query by construction, and a test asserts they re-parse to it. Bucket endpoints for top_slope were compared as text against the fixed-width decimal encoding the column is stored in, so a bucket of 1-2 counted the wrong fields, and topslope_query sliced 12 characters off every endpoint, turning the bucket 1-2 into topslope=-. Buckets are now encoded through a bucket_encoders hook before they reach the backend and decoded for display and links, and an endpoint that is not a rational is rejected with the usual message. Along the way: the sentinel for unsearchable values moves into the framework, so a null bucket is linkless in every table rather than only in p-adic ones; totals that share no constraint are no longer linked; total urls list their parameters in a fixed order rather than set order; invalid input to the dynamic statistics page flashes an error instead of raising a 500; the default p and c buckets are left open above so no field is omitted as the database grows; a not-computed jump set is distinguished from an empty one; and nilp_qformatter maps the stored -1 to nilpotent=no, since nilpotency_class=-1 matches nothing. Every statistics page in LMFDB renders identically to before apart from those fixes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7c40129 commit 19087b7

4 files changed

Lines changed: 686 additions & 133 deletions

File tree

lmfdb/groups/abstract/stats.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ def nilp_formatter(nilp):
3434
return str(nilp)
3535

3636
def nilp_qformatter(nilp):
37-
if nilp == "not":
37+
# A group that is not nilpotent is stored with nilpotency class -1, which the
38+
# search page does not accept; it is searched for with the nilpotent box.
39+
if nilp in (-1, "not"):
3840
return "nilpotent=no"
3941
return f"nilpotency_class={nilp}"
4042

lmfdb/local_fields/main.py

Lines changed: 92 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@
2222
EmbeddedSearchArray, integer_options,
2323
redirect_no_cache, raw_typeset)
2424
from lmfdb.utils.place_code import CodeSnippet
25-
from psycodict.utils import range_formatter
25+
from psycodict.utils import SearchParsingError, range_formatter
26+
from lmfdb.utils.display_stats import NO_SEARCH_QUERY
2627
from lmfdb.utils.interesting import interesting_knowls
2728
from lmfdb.utils.search_columns import SearchColumns, LinkCol, MathCol, ProcessedCol, MultiProcessedCol, RationalListCol, PolynomialCol, eval_rational_list
28-
from lmfdb.utils.search_parsing import search_parser
29+
from lmfdb.utils.search_parsing import QQ_DEC_RE, QQ_RE, search_parser
2930
from lmfdb.api import datapage
3031
from lmfdb.logger import logger
3132
from lmfdb.local_fields import local_fields_page
@@ -1919,14 +1920,6 @@ def galsortkey(gal):
19191920
return [-1, -1]
19201921
return galdata(gal)
19211922

1922-
# Sentinel returned by the query_formatters below for bucket values that have no
1923-
# search representation (e.g. a not-computed Galois group, or an empty/uncomputed
1924-
# list of slopes). LFStats.display_data detects this marker in an assembled
1925-
# drill-down url and blanks the link, so that clicking the count of a "not computed"
1926-
# bucket does not open an unfiltered search (which would return every field). The
1927-
# NUL byte cannot occur in a real url query fragment, making detection unambiguous.
1928-
NO_SEARCH_QUERY = "\x00"
1929-
19301923
def galquery(gal):
19311924
if gal is None:
19321925
# There is no way to search for fields where the Galois group is not computed
@@ -1954,32 +1947,82 @@ def array_sort_key(v):
19541947
return (0, [])
19551948
return (1, v)
19561949

1950+
# top_slope is stored as a fixed-width decimal approximation, which makes the
1951+
# database sort text in numerical order, followed by the exact rational; see ratproc.
1952+
TOPSLOPE_PREFIX_LEN = 12
1953+
TOPSLOPE_PREFIX_RE = re.compile(r"\d+\.\d+")
1954+
1955+
def topslope_encoder(endpoint):
1956+
"""
1957+
Encode a top slope, as it is written in the topslope search box, into the
1958+
form stored in the database. Used for the endpoints of statistics buckets,
1959+
which must be compared against the stored values.
1960+
"""
1961+
if not QQ_DEC_RE.match(endpoint):
1962+
raise SearchParsingError("%s is not a non-negative rational number, such as 4/3 or 2.5." % endpoint)
1963+
return ratproc(endpoint)
1964+
1965+
def topslope_decoder(ts):
1966+
"""
1967+
The exact rational underlying a stored top slope, or None if the input is not
1968+
a stored top slope (a bucket typed by a user, for example, is already exact).
1969+
"""
1970+
if not isinstance(ts, str) or len(ts) <= TOPSLOPE_PREFIX_LEN:
1971+
return None
1972+
prefix, rest = ts[:TOPSLOPE_PREFIX_LEN], ts[TOPSLOPE_PREFIX_LEN:]
1973+
if TOPSLOPE_PREFIX_RE.fullmatch(prefix) and QQ_RE.match(rest):
1974+
return rest
1975+
return None
1976+
1977+
def topslope_endpoints(ts):
1978+
"""
1979+
The endpoints of a top slope value or range, as exact rationals: a pair
1980+
(lower, upper), either of which may be None if the range is unbounded on that
1981+
side, and which are equal for a single value.
1982+
1983+
The input is either a value or a range as stored in the database, or a bucket
1984+
as typed into the buckets box on the dynamic statistics page.
1985+
"""
1986+
if isinstance(ts, dict):
1987+
lower = ts.get("$gte", ts.get("$gt"))
1988+
upper = ts.get("$lte", ts.get("$lt"))
1989+
elif isinstance(ts, str) and topslope_decoder(ts) is None and "-" in ts[1:]:
1990+
# a range typed into the buckets box, such as '1-2' or '2-'
1991+
lower, _, upper = ts.partition("-")
1992+
upper = upper or None
1993+
else:
1994+
lower = upper = ts
1995+
return tuple(topslope_decoder(x) or x for x in (lower, upper))
1996+
19571997
def topslope_formatter(ts):
1958-
# top_slope is stored as a fixed-width decimal approximation (making database
1959-
# sorting work) followed by the exact rational; see ratproc above
1960-
if isinstance(ts, str):
1998+
def show(x):
19611999
try:
1962-
return "$%s$" % latex(QQ(ts[12:]))
2000+
return "$%s$" % latex(QQ(x))
19632001
except (TypeError, ValueError):
1964-
return ts
1965-
return range_formatter(ts)
2002+
return str(x)
2003+
lower, upper = topslope_endpoints(ts)
2004+
if lower is None and upper is None:
2005+
return "not computed"
2006+
elif lower == upper:
2007+
return show(lower)
2008+
elif upper is None:
2009+
return "%s-" % show(lower)
2010+
elif lower is None:
2011+
# top slopes are always non-negative
2012+
return "$0$-%s" % show(upper)
2013+
return "%s-%s" % (show(lower), show(upper))
19662014

19672015
def topslope_query(ts):
1968-
def dec(x):
1969-
# Strip the decimal prefix, leaving the exact rational
1970-
return x[12:] if isinstance(x, str) else x
1971-
if isinstance(ts, dict):
1972-
lower = ts.get("$gte", ts.get("$gt"))
1973-
upper = ts.get("$lte", ts.get("$lt"))
1974-
if lower is None and upper is None:
1975-
return NO_SEARCH_QUERY
1976-
elif lower is None:
1977-
# top slopes are always nonnegative
1978-
return "topslope=0-%s" % dec(upper)
1979-
elif upper is None:
1980-
return "topslope=%s-" % dec(lower)
1981-
return "topslope=%s-%s" % (dec(lower), dec(upper))
1982-
return "topslope=%s" % dec(ts)
2016+
lower, upper = topslope_endpoints(ts)
2017+
if lower is None and upper is None:
2018+
return NO_SEARCH_QUERY
2019+
elif lower == upper:
2020+
return "topslope=%s" % lower
2021+
elif upper is None:
2022+
return "topslope=%s-" % lower
2023+
elif lower is None:
2024+
return "topslope=0-%s" % upper
2025+
return "topslope=%s-%s" % (lower, upper)
19832026

19842027
def content_query(shortname, quantifier):
19852028
# For columns searched via parse_newton_polygon; the quantifier makes the search
@@ -1992,7 +2035,8 @@ def inner(val):
19922035
return inner
19932036

19942037
def bracket_query(shortname):
1995-
# For columns searched via parse_bracketed_posints, which matches exactly
2038+
# For columns searched via parse_bracketed_posints, which matches exactly.
2039+
# The empty list is searchable, unlike a value that is not computed.
19962040
def inner(val):
19972041
if val is None:
19982042
return NO_SEARCH_QUERY
@@ -2061,7 +2105,9 @@ class LFStats(StatsDisplay):
20612105
'top_slope': topslope_formatter,
20622106
'ind_of_insep': formatbracketcol,
20632107
'associated_inertia': formatbracketcol,
2064-
'jump_set': (lambda js: f"${js}$" if js else "undefined"),
2108+
# a field with no jump set has an empty one; distinguishing that from a
2109+
# field where it is not computed keeps the two from sharing a row
2110+
'jump_set': formatbracketcol,
20652111
}
20662112
query_formatters = {
20672113
'galois_label': galquery,
@@ -2075,8 +2121,19 @@ class LFStats(StatsDisplay):
20752121
'associated_inertia': bracket_query('associated_inertia'),
20762122
'jump_set': bracket_query('jump_set'),
20772123
}
2078-
buckets = {'p': ['2', '3', '5', '7', '11-19', '23-97', '101-199'],
2079-
'c': ['0', '1', '2', '3', '4', '5-8', '9-16', '17-32', '33-79']}
2124+
# The public parameters of the search page, where they differ from the column name
2125+
url_params = {'galois_label': ['gal'],
2126+
'top_slope': ['topslope'],
2127+
'slopes': ['slopes', 'slopes_quantifier'],
2128+
'visible': ['visible', 'visible_quantifier'],
2129+
'ind_of_insep': ['ind_of_insep', 'insep_quantifier']}
2130+
# top_slope is stored encoded, so bucket endpoints must be encoded before they
2131+
# are compared against it
2132+
bucket_encoders = {'top_slope': topslope_encoder}
2133+
# The last bucket is left open above, so that fields are not omitted from the
2134+
# table if the database grows beyond the current maximum (p < 200 and c <= 79)
2135+
buckets = {'p': ['2', '3', '5', '7', '11-19', '23-97', '101-'],
2136+
'c': ['0', '1', '2', '3', '4', '5-8', '9-16', '17-32', '33-']}
20802137

20812138
stat_list = [
20822139
ramdisp(2),
@@ -2112,28 +2169,6 @@ def __init__(self):
21122169
self.num_abs_families = db.lf_families.count({"n0":1})
21132170
self.num_rel_families = db.lf_families.count({"n0":{"$gt": 1}})
21142171

2115-
@staticmethod
2116-
def _suppress_null_links(data):
2117-
# Suppress drill-down links for buckets whose value cannot be expressed as a
2118-
# search. Such a bucket (e.g. a not-computed Galois group, or an empty slope
2119-
# content) gets NO_SEARCH_QUERY from its query_formatter; without this the count
2120-
# would link to a url like /padicField/?gal= whose empty parameter the search
2121-
# parser ignores, opening an unfiltered search rather than the counted fields.
2122-
# A blank query is rendered without a link by stat_1d.html / stat_2d.html.
2123-
def suppress(cells):
2124-
for cell in cells:
2125-
if cell.get('query') and NO_SEARCH_QUERY in cell['query']:
2126-
cell['query'] = ''
2127-
if 'counts' in data:
2128-
suppress(data['counts'])
2129-
if 'grid' in data:
2130-
for _row_header, row in data['grid']:
2131-
suppress(row)
2132-
return data
2133-
2134-
def display_data(self, *args, **kwds):
2135-
return self._suppress_null_links(super().display_data(*args, **kwds))
2136-
21372172
@staticmethod
21382173
def dynamic_parse(info, query):
21392174
from .main import common_parse

0 commit comments

Comments
 (0)