Skip to content

Commit dabca39

Browse files
roed314claude
andcommitted
Keep the whole number field record in the character search batch (LMFDB#6008)
The batched postprocessing step kept only the label of each matched number field, so formatfield built a WebNumberField holding just that label and field_pretty reloaded the record from the database whenever the pretty name needed more than the label: degree 3 wants disc_abs/disc_sign/coeffs, generic degree 4 and degree 2^k want subfields/subfield_mults. Displaying the kernel field 4.0.2197.1 (the order 4 characters of modulus 13) therefore still made 5 record level lookups, so a cold results page did database work proportional to the number of distinct kernel fields on it. Keep the whole nf_fields record in character_postprocess (one query either way) and hand it to formatfield as the virtual column kernel_field_data. Every column field_pretty consults lives in nf_fields, so no nf_fields_extra batch is needed. Case 5b of field_pretty also looked up the unique quadratic subfield of an imprimitive quartic by coefficients; read the squarefree radicand off the stored subfield polynomial instead, the way cases 5a and 7 already do, which leaves no lucky/lookup call in the render path. Verified: 5 record lookups -> 0 when displaying that kernel field, and 0 over a 50 row page of order <= 12 characters; the new test_field_columns_no_lookups patches nf_fields.lookup, nf_fields.lucky and nf_fields_extra.lookup to raise and fails on the previous commit; rendered results tables and downloads are byte identical before and after on 9 searches and 4 downloads; old and new case 5b radicands agree on the 1450 quartics with a unique quadratic subfield among the 4000 smallest discriminant quartics; cold render of ?modulus=1-500&order=4&showcol=first drops from 11.2s to 0.9s, order=8 from 7.9s to 0.9s and order=3 from 4.5s to 0.9s against devmirror. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 27cedf0 commit dabca39

3 files changed

Lines changed: 101 additions & 51 deletions

File tree

lmfdb/characters/main.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -271,16 +271,15 @@ def display_galois_orbit(modulus, first, last, degree):
271271
disp = r"$, \cdots ,$".join(disp)
272272
return f'<p style="margin-top: 0px;margin-bottom:0px;">\n{disp}\n</p>'
273273

274-
def display_kernel_field(modulus, first, order, kernel_poly=None, kernel_label=None):
275-
# kernel_poly and kernel_label are precomputed in character_postprocess;
276-
# if they are absent we fall back to computing them here, at the cost of
277-
# a database query for each call
274+
def display_kernel_field(modulus, first, order, kernel_poly=None, kernel_field_data=None):
275+
# kernel_poly and kernel_field_data are precomputed in
276+
# character_postprocess; if they are absent we fall back to computing them
277+
# here, at the cost of several database queries for each call
278278
if order > 12:
279279
return "not computed"
280280
if kernel_poly is None:
281281
kernel_poly = [ZZ(x) for x in ConreyCharacter(modulus,first).kernel_field_poly()]
282-
data = {"label": kernel_label} if kernel_label else None
283-
return formatfield(kernel_poly, data=data)
282+
return formatfield(kernel_poly, data=kernel_field_data)
284283

285284
def display_value_field(order, value_field_label=None):
286285
# value_field_label is precomputed in character_postprocess ("N/A" when the
@@ -306,8 +305,8 @@ def character_postprocess(res, info, query):
306305
Each of these columns identifies a number field by its defining polynomial,
307306
which naively requires two database queries per row. Instead we compute all
308307
of the defining polynomials here (the pari computations involved are cheap)
309-
and then find all of the matching number field labels in a single database
310-
query. See https://github.com/LMFDB/lmfdb/issues/6008.
308+
and then find all of the matching number fields in a single database query.
309+
See https://github.com/LMFDB/lmfdb/issues/6008.
311310
"""
312311
R = PolynomialRing(ZZ, "x")
313312
cyclo_coeffs = {} # order2 -> coeffs of the polredabs'ed cyclotomic polynomial
@@ -327,20 +326,26 @@ def character_postprocess(res, info, query):
327326
rec["kernel_poly"] = [int(ZZ(x)) for x in kernel_poly]
328327
all_coeffs = {tuple(c) for c in cyclo_coeffs.values() if c is not None}
329328
all_coeffs.update(tuple(rec["kernel_poly"]) for rec in res if "kernel_poly" in rec)
330-
labels = {}
329+
nf_data = {}
331330
if all_coeffs:
332331
# We use $or rather than $in since psycodict does not apply the
333-
# typecast needed to compare integer arrays with the numeric[] column
332+
# typecast needed to compare integer arrays with the numeric[] column.
333+
# We keep the whole record rather than just the label since
334+
# field_pretty needs more than the label (coeffs, disc_abs, disc_sign,
335+
# subfields and subfield_mults, all of which live in nf_fields) and
336+
# reloads the field from the database when they are missing.
334337
nf_query = {"$or": [{"coeffs": list(c)} for c in sorted(all_coeffs)]}
335-
labels = {tuple(f["coeffs"]): f["label"]
336-
for f in db.nf_fields.search(nf_query, ["coeffs", "label"])}
338+
nf_data = {tuple(f["coeffs"]): f for f in db.nf_fields.search(nf_query)}
337339
for rec in res:
338340
order = rec["order"]
339341
order2 = order if order % 4 != 2 else order // 2
340342
cyclo = cyclo_coeffs[order2]
341-
rec["value_field_label"] = "N/A" if cyclo is None else labels.get(tuple(cyclo), "N/A")
343+
value_field = None if cyclo is None else nf_data.get(tuple(cyclo))
344+
rec["value_field_label"] = "N/A" if value_field is None else value_field["label"]
342345
if "kernel_poly" in rec:
343-
rec["kernel_label"] = labels.get(tuple(rec["kernel_poly"]), "N/A")
346+
# formatfield reads the label "N/A" as "not in the database", so a
347+
# missing field is recorded without another query
348+
rec["kernel_field_data"] = nf_data.get(tuple(rec["kernel_poly"]), {"label": "N/A"})
344349
return res
345350

346351
character_columns = SearchColumns([
@@ -351,7 +356,7 @@ def character_postprocess(res, info, query):
351356
MathCol("conductor", "character.dirichlet.conductor", "Conductor"),
352357
MathCol("order", "character.dirichlet.order", "Order"),
353358
MultiProcessedCol("first", "character.dirichlet.field_cut_out", "Kernel field",
354-
["modulus", "first", "order", "kernel_poly", "kernel_label"],
359+
["modulus", "first", "order", "kernel_poly", "kernel_field_data"],
355360
display_kernel_field, align="center", default=False,
356361
apply_download=lambda modulus, first, order, *args: [modulus, first, order]),
357362
MultiProcessedCol("order", "character.dirichlet.value_field", "Value field",

lmfdb/characters/test_characters.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,62 @@ def test_field_columns(self):
6868
assert 'label=2.0.3.1' in data
6969
# kernel field knowl for Q(zeta_5)
7070
assert 'label=4.0.125.1' in data
71+
# The order 4 characters of modulus 13 cut out 4.0.2197.1, which is
72+
# not the value field of any character, so this pins down the kernel
73+
# field column (and is a quartic field whose pretty name needs more
74+
# than the label)
75+
W = self.tc.get('/Character/Dirichlet/?modulus=13&order=4&search_type=List&showcol=first')
76+
data = W.get_data(as_text=True)
77+
assert 'label=4.0.2197.1' in data
78+
assert r'\(\Q(\sqrt{-26 -6 \sqrt{13}})\)' in data
79+
# kernel fields that are not in the database
80+
W = self.tc.get('/Character/Dirichlet/?modulus=5002&order=12&search_type=List&showcol=first')
81+
assert 'knowl="nf.field.missing"' in W.get_data(as_text=True)
82+
# value fields that are not in the database
83+
W = self.tc.get('/Character/Dirichlet/?order=47&search_type=List')
84+
assert r'$\Q(\zeta_{47})$' in W.get_data(as_text=True)
7185
# kernel fields are not computed for orders larger than 12
7286
W = self.tc.get('/Character/Dirichlet/?order=13-100&search_type=List')
7387
assert 'not computed' in W.get_data(as_text=True)
7488

89+
def test_field_columns_no_lookups(self):
90+
# Once character_postprocess has run, displaying the kernel field must
91+
# not go back to the database: it is the per-field record lookups that
92+
# issue #6008 is about.
93+
from unittest.mock import patch
94+
from lmfdb import db
95+
from lmfdb.characters.main import character_postprocess, display_kernel_field
96+
from lmfdb.number_fields.web_number_field import field_pretty
97+
98+
res = list(db.char_dirichlet.search({'modulus': 13, 'order': 4}))
99+
res = character_postprocess(res, {}, {})
100+
assert [rec['kernel_field_data']['label'] for rec in res] == ['4.0.2197.1']
101+
102+
def no_lookup(*args, **kwargs):
103+
raise AssertionError("number field record lookup while displaying a kernel field")
104+
105+
# field_pretty caches by label, so a lookup made by an earlier test
106+
# would otherwise hide one made here
107+
field_pretty.clear_cache()
108+
with patch.object(db.nf_fields, 'lookup', no_lookup), \
109+
patch.object(db.nf_fields, 'lucky', no_lookup), \
110+
patch.object(db.nf_fields_extra, 'lookup', no_lookup):
111+
displayed = [display_kernel_field(rec['modulus'], rec['first'], rec['order'],
112+
rec['kernel_poly'], rec['kernel_field_data'])
113+
for rec in res]
114+
assert 'label=4.0.2197.1' in displayed[0]
115+
assert r'\(\Q(\sqrt{-26 -6 \sqrt{13}})\)' in displayed[0]
116+
117+
def test_field_columns_download(self):
118+
# The virtual columns added by character_postprocess must not leak into
119+
# downloads: the kernel field column downloads as [modulus, first, order]
120+
# and the value field column as the raw order
121+
url = ('/Character/Dirichlet/?query=%7B%27order%27%3A+4%2C+%27modulus%27%3A+13%7D'
122+
'&Submit=text&download=1&search_type=List&showcol=first')
123+
data = self.tc.get(url).get_data(as_text=True)
124+
assert '[Orbit label, Conrey labels, Modulus, Conductor, Order, Kernel field,' in data
125+
assert '"13.d"\t[13, 5, 8, 2]\t13\t13\t4\t[13, 5, 4]\t' in data
126+
75127
class DirichletTableTest(LmfdbTest):
76128

77129
def test_table(self):

lmfdb/number_fields/web_number_field.py

Lines changed: 29 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -232,13 +232,6 @@ def field_pretty(label, wnf=None):
232232
if d == '1': # Q
233233
return r'\(\Q\)'
234234

235-
# Converts LMFDB label for quadratic field K to the D in K = Q(sqrt(D))
236-
def _quad_label_to_D(quad_label):
237-
parts = str(quad_label).split('.')
238-
z = integer_squarefree_part(ZZ(parts[2])) # Get squarefree part
239-
z *= (-1) ** (1 + int(parts[1]) // 2) # Get correct sign
240-
return ZZ(z)
241-
242235
# Converts D to the latexed form of sqrt(D)
243236
def _sqrt_symbol(z):
244237
return 'i' if z == -1 else r'\sqrt{%d}' % z
@@ -289,35 +282,35 @@ def _wnf_with(*keys):
289282

290283
# Case 5b: Imprimitive quartic fields of type Q(\sqrt(A + B*\sqrt(D)))
291284
if len(subs) == 1:
292-
quad_sub = wnf.from_coeffs(string2list(str(subs[0][0])))
293-
if not quad_sub._data is None:
294-
# Get unique quadratic subfield Q(sqrt(D))
295-
quad_label = str(quad_sub.get_label())
296-
D = _quad_label_to_D(quad_label)
297-
Ksub = QuadraticField(D, 'sqrtD')
298-
sqrtD = Ksub.gen(0)
299-
300-
# Factorise defining polynomial for K over Q(sqrt(D))
301-
Rsub = PolynomialRing(Ksub, 'x')
302-
relative_poly = Rsub(wnf.poly()).factor()[0][0]
303-
304-
# Can extract the first quadratic factor
305-
rel_coeffs = relative_poly.coefficients(sparse=False)
306-
alpha = rel_coeffs[1]**2 - 4*rel_coeffs[0]*rel_coeffs[2]
307-
A, B = sqrtD.coordinates_in_terms_of_powers()(alpha)
308-
309-
# Divide out common square factors
310-
g = gcd(A,B)
311-
g //= g.squarefree_part()
312-
A, B = ZZ(A//g), ZZ(B//g)
313-
314-
# Return final pretty latex
315-
if A == 0:
316-
# Case: Pure quartic field
317-
return r'\(\Q(\sqrt[4]{%d})\)' % (D*B**2)
318-
else:
319-
B_str = "+" if B == 1 else "-" if B == -1 else f"{B:+d}"
320-
return r'\(\Q(\sqrt{%d %s %s})\)' % (A, B_str, _sqrt_symbol(D))
285+
# Get unique quadratic subfield Q(sqrt(D)) from its defining
286+
# polynomial, as in case 5a; looking the subfield up in the
287+
# database instead would cost a query on every call
288+
qs = subs[0][0].split(',')
289+
D = integer_squarefree_part(ZZ(qs[1])**2 - 4*ZZ(qs[0])*ZZ(qs[2]))
290+
Ksub = QuadraticField(D, 'sqrtD')
291+
sqrtD = Ksub.gen(0)
292+
293+
# Factorise defining polynomial for K over Q(sqrt(D))
294+
Rsub = PolynomialRing(Ksub, 'x')
295+
relative_poly = Rsub(wnf.poly()).factor()[0][0]
296+
297+
# Can extract the first quadratic factor
298+
rel_coeffs = relative_poly.coefficients(sparse=False)
299+
alpha = rel_coeffs[1]**2 - 4*rel_coeffs[0]*rel_coeffs[2]
300+
A, B = sqrtD.coordinates_in_terms_of_powers()(alpha)
301+
302+
# Divide out common square factors
303+
g = gcd(A,B)
304+
g //= g.squarefree_part()
305+
A, B = ZZ(A//g), ZZ(B//g)
306+
307+
# Return final pretty latex
308+
if A == 0:
309+
# Case: Pure quartic field
310+
return r'\(\Q(\sqrt[4]{%d})\)' % (D*B**2)
311+
else:
312+
B_str = "+" if B == 1 else "-" if B == -1 else f"{B:+d}"
313+
return r'\(\Q(\sqrt{%d %s %s})\)' % (A, B_str, _sqrt_symbol(D))
321314

322315
# Case 6: Pure cubic fields Q(\sqrt[3]{N})
323316
if d == '3':

0 commit comments

Comments
 (0)