Skip to content

Commit 8179b59

Browse files
roed314claude
andcommitted
Lattices: sign-aware determinant search and indefinite isometry fix
Two reviewer-flagged bugs in the lattice search, plus test coverage: - common_parse now uses parse_signed_ints for the lat_lattices_new path (det_qfield='det_abs'): det=5 sets det_sign=1 (previously it also matched determinant -5), det=-5 sets det_sign=-1, ranges like 5-20 and -20--5 constrain det_abs with the matching sign, and comma-separated lists with mixed signs (or ranges spanning zero) become an $or over (det_sign, det_abs) clauses. The lat_genera path is unchanged (its det column is signed). test_lattice_searchrank now queries det=-1000: its expected label 3.1.1000.3.3.3b.1 has determinant -1000 and was only returned for det=1000 through the sign-blind bug. - lattice_search_isometric built its genus query with nplus=genus.signature(), but Sage's signature() is p-n, not p; use signature_pair()[0]. Positive-definite lattices masked this (n=0). - ISOM_TIME_LIMIT is now a module-level constant so tests can raise the time budget for deterministic runs. New tests: sidebar submenu links on both index pages (with BETA=1), positive/negative/range/mixed determinant queries (all values verified against devmirror), an indefinite-lattice isometry search (gram of 3.1.1.3.1 under a unimodular basis change must find the lattice, which fails without the signature_pair fix), and the rank-2 isometric search now requires finding 2.2.31.1.2 under an enlarged time budget. The rank-6 large-class-number search remains as a smoke test (HTTP 200, no traceback) since its outcome depends on the 20s budget. Full suite: 40 passed, 1 skipped (expected theta-series skip). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ac95323 commit 8179b59

3 files changed

Lines changed: 123 additions & 30 deletions

File tree

lmfdb/lattice/genus.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from lmfdb.utils import (
1111
flash_error, to_dict, #web_latex_split_on_pm,
1212
SearchArray, EmbeddedSearchArray, TextBox, TextBoxWithSelect, SelectBox, CountBox, #prop_int_pretty,
13-
parse_ints, parse_posints, parse_count,
13+
parse_ints, parse_posints, parse_signed_ints, parse_count,
1414
parse_bracketed_posints, parse_start, parse_noop, #clean_input,
1515
parse_rational_to_list, raw_typeset_qexp,
1616
search_wrap, embed_wrap, redirect_no_cache, Downloader, ParityBox)
@@ -169,18 +169,16 @@ def common_parse(info, query, det_qfield='det'):
169169
# stores det_abs/det_sign, so the lattice search passes det_qfield='det_abs'.
170170
for field, name in [('rank', 'Rank'), ('level', 'Level'), ('class_number', 'Class number')]:
171171
parse_posints(info, query, field, name)
172-
# TODO: fix handling of sign here (e.g. -100..-11 currently fails)
173172
if det_qfield == 'det':
173+
# lat_genera stores a signed det column, so parse_ints handles signs directly
174174
parse_ints(info, query, 'det', 'Determinant')
175175
else:
176-
det = (info.get('det') or '').strip()
177-
if re.fullmatch(r'-\d+', det):
178-
query['det_sign'] = -1
179-
det_info = dict(info)
180-
det_info['det'] = det[1:]
181-
parse_ints(det_info, query, 'det', 'Determinant', qfield=det_qfield)
182-
else:
183-
parse_ints(info, query, 'det', 'Determinant', qfield=det_qfield)
176+
# lat_lattices_new stores (det_sign, det_abs). parse_signed_ints is
177+
# sign-aware: "5" gives det_sign=1, "-5" gives det_sign=-1, ranges such
178+
# as "5-20" or "-20--5" constrain det_abs with the appropriate sign,
179+
# and comma-separated lists (including mixed signs, or ranges spanning
180+
# zero) become an $or over (det_sign, det_abs) clauses.
181+
parse_signed_ints(info, query, 'det', 'Determinant', qfield=('det_sign', 'det_abs'))
184182
parse_ints(info, query, 'disc', 'Discriminant')
185183
parse_bracketed_posints(info, query, 'signature', qfield=('rank','nplus'),exactlength=2, allow0=True, extractor=lambda L: (L[0]+L[1],L[0]))
186184

lmfdb/lattice/main.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ def _show_genus(query, info, genus_label):
113113
return db.lat_lattices_new.search(query, limit=count, offset=start, info=info)
114114

115115

116+
# Wall-clock budget (in seconds) for the isometry postprocessor below.
117+
# Module-level so that tests can temporarily raise it for deterministic runs.
118+
ISOM_TIME_LIMIT = 20.0
119+
120+
116121
def lattice_search_isometric(res, info, query):
117122
"""
118123
We check for isometric lattices if the user enters a valid gram matrix
@@ -126,8 +131,6 @@ def lattice_search_isometric(res, info, query):
126131
5. If no match is found (or the time budget is exceeded), show all lattices
127132
in the genus with an informational message.
128133
"""
129-
ISOM_TIME_LIMIT = 20.0 # seconds
130-
131134
if info['number'] == 0 and info.get('gram_matrix'):
132135
A = info['gram_matrix']
133136
query.pop('gram', None)
@@ -142,12 +145,15 @@ def lattice_search_isometric(res, info, query):
142145
except Exception:
143146
return res
144147

145-
# Use genus invariants to narrow the DB search
148+
# Use genus invariants to narrow the DB search. Note that nplus is the
149+
# number of positive eigenvalues p, i.e. signature_pair() = (p, n),
150+
# whereas signature() is the difference p - n (they agree only for
151+
# positive-definite lattices).
146152
genus_query = {
147153
'rank': int(input_genus.rank()),
148154
'det': int(input_genus.det()),
149155
'level': int(input_genus.level()),
150-
'nplus': int(input_genus.signature()),
156+
'nplus': int(input_genus.signature_pair()[0]),
151157
'is_even': bool(input_genus.is_even()),
152158
}
153159

lmfdb/lattice/test_lattice.py

Lines changed: 105 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11

2+
import os
3+
24
from lmfdb.tests import LmfdbTest
35

46
class HomePageTest(LmfdbTest):
@@ -14,6 +16,21 @@ def test_genus(self):
1416
assert 'random' in homepage
1517
assert 'Gram' in homepage
1618

19+
def test_sidebar_submenu(self):
20+
# Navigation between the lattice and genus index pages now lives in the
21+
# collapsible sidebar submenu (sidebar.yaml) instead of the old tab bar.
22+
# The Lattices sidebar entry has status beta, so it only renders when
23+
# the site runs in beta mode; BETA is re-read from the environment on
24+
# every request, so we can switch it on just for this test.
25+
os.environ['BETA'] = '1'
26+
try:
27+
for url in ("/Lattice/", "/Lattice/Genus"):
28+
homepage = self.tc.get(url).get_data(as_text=True)
29+
assert 'href="/Lattice/">lattices</a>' in homepage
30+
assert 'href="/Lattice/Genus">genera</a>' in homepage
31+
finally:
32+
del os.environ['BETA']
33+
1734
def test_lattice_rank(self):
1835
L = self.tc.get("/Lattice/9.9.8.001.76.1").get_data(as_text=True)
1936
assert '1.58740105196819947475170563927' in L #Hermite number
@@ -53,10 +70,56 @@ def test_lattice_search_next(self):
5370
assert '146' in L #search on the next page
5471

5572
def test_lattice_searchrank(self):
56-
# det constraint keeps the expected label on the first page of results
57-
L = self.tc.get("/Lattice/?rank=3&det=1000").get_data(as_text=True)
73+
# det constraint keeps the expected label on the first page of results.
74+
# 3.1.1000.3.3.3b.1 has determinant -1000, so with the sign-aware
75+
# determinant search it is only returned for det=-1000 (previously the
76+
# sign was ignored and det=1000 also matched it).
77+
L = self.tc.get("/Lattice/?rank=3&det=-1000").get_data(as_text=True)
5878
assert '3.1.1000.3.3.3b.1' in L # rank search
5979

80+
def test_lattice_search_det_sign_positive(self):
81+
# rank 3, det 5: exactly the two determinant +5 lattices, and none of
82+
# the determinant -5 lattices (the search is sign-aware)
83+
L = self.tc.get("/Lattice/?rank=3&det=5").get_data(as_text=True)
84+
assert '3.3.5.77.1' in L
85+
assert '3.3.5.1f.1' in L
86+
assert '3.1.5.1b.1' not in L # det -5
87+
assert '3.1.5.73.1' not in L # det -5
88+
89+
def test_lattice_search_det_sign_negative(self):
90+
# rank 3, det -5: exactly the two determinant -5 lattices, and none of
91+
# the determinant +5 lattices
92+
L = self.tc.get("/Lattice/?rank=3&det=-5").get_data(as_text=True)
93+
assert '3.1.5.1b.1' in L
94+
assert '3.1.5.73.1' in L
95+
assert '3.3.5.77.1' not in L # det +5
96+
assert '3.3.5.1f.1' not in L # det +5
97+
98+
def test_lattice_search_det_range_positive(self):
99+
# positive range: dets +5..+20 (81 rank-3 matches on devmirror), and no
100+
# negative-determinant lattice whose |det| lies in the range
101+
L = self.tc.get("/Lattice/?rank=3&det=5-20&count=100").get_data(as_text=True)
102+
assert '3.3.5.77.1' in L # det 5
103+
assert '3.3.6.77.1' in L # det 6
104+
assert '3.1.7.2f.1' not in L # det -7
105+
assert '3.1.8.001.6.1' not in L # det -8
106+
107+
def test_lattice_search_det_range_negative(self):
108+
# negative range: dets -20..-5 (71 rank-3 matches on devmirror), and no
109+
# positive-determinant lattice whose |det| lies in the range
110+
L = self.tc.get("/Lattice/?rank=3&det=-20--5&count=100").get_data(as_text=True)
111+
assert '3.1.7.2f.1' in L # det -7
112+
assert '3.1.8.001.6.1' in L # det -8
113+
assert '3.3.5.77.1' not in L # det +5
114+
assert '3.3.6.77.1' not in L # det +6
115+
116+
def test_lattice_search_det_mixed_signs(self):
117+
# a comma-separated list mixing signs is handled via an $or over
118+
# (det_sign, det_abs) clauses: det=5,-5 returns lattices of both signs
119+
L = self.tc.get("/Lattice/?rank=3&det=5%2C-5&count=100").get_data(as_text=True)
120+
assert '3.3.5.77.1' in L # det +5
121+
assert '3.1.5.1b.1' in L # det -5
122+
60123
def test_lattice_searchlevel(self):
61124
L = self.tc.get("/Lattice/?start=&rank=&det=&level=90&gram=&minimum=&class_number=&aut_size=").get_data(as_text=True)
62125
assert '1.1.45.01.3b.1' in L #level search
@@ -135,12 +198,37 @@ def test_genus_searchGM(self):
135198
def test_lattice_searchGM_isometric(self):
136199
# [5,7,7,16] is isometric to [5,2,2,7] (label 2.2.31.1.2) via basis change
137200
# U^T * [[5,2],[2,7]] * U with U = [[1,1],[0,1]]
138-
# Not literally stored in the DB, so the genus+isometry postprocessor should find it
139-
L = self.tc.get("/Lattice/?gram=[5%2C7%2C7%2C16]&gram_format=full").get_data(as_text=True)
140-
# The isometry search has a 20s wall-clock budget that includes the
141-
# database queries; on a loaded runner it can fall back to the genus
142-
# display or a plain results page before finding the isometric lattice
143-
assert '2.2.31.1.2' in L or '2.2.31' in L or 'Integral lattices search results' in L
201+
# Not literally stored in the DB, so the genus+isometry postprocessor
202+
# MUST find the isometric lattice. The wall-clock budget includes the
203+
# database queries, so we raise it temporarily to make the test
204+
# deterministic on a loaded runner; the rank-2 computation itself is
205+
# fast (the time is dominated by the devmirror queries).
206+
from lmfdb.lattice import main as lattice_main
207+
old_limit = lattice_main.ISOM_TIME_LIMIT
208+
lattice_main.ISOM_TIME_LIMIT = 600.0
209+
try:
210+
L = self.tc.get("/Lattice/?gram=[5%2C7%2C7%2C16]&gram_format=full").get_data(as_text=True)
211+
finally:
212+
lattice_main.ISOM_TIME_LIMIT = old_limit
213+
assert '2.2.31.1.2' in L
214+
215+
def test_lattice_searchGM_isometric_indefinite(self):
216+
# Indefinite (signature (2,1), determinant -1) lattice 3.1.1.3.1 with
217+
# gram [[0,0,1],[0,1,0],[1,0,1]], transformed by the unimodular basis
218+
# change U = [[1,1,0],[0,1,0],[0,0,1]] into [[0,0,1],[0,1,1],[1,1,1]],
219+
# which is not stored in the DB. The genus lookup must find genus
220+
# 3.1.1.3 (class number 1, so the unique lattice is returned). This
221+
# exercises the nplus computation for indefinite input: nplus is
222+
# signature_pair()[0] = 2, not signature() = 2 - 1 = 1 (a bug formerly
223+
# masked by positive-definite lattices, where the two agree).
224+
from lmfdb.lattice import main as lattice_main
225+
old_limit = lattice_main.ISOM_TIME_LIMIT
226+
lattice_main.ISOM_TIME_LIMIT = 600.0
227+
try:
228+
L = self.tc.get("/Lattice/?gram=[0%2C0%2C1%2C0%2C1%2C1%2C1%2C1%2C1]&gram_format=full").get_data(as_text=True)
229+
finally:
230+
lattice_main.ISOM_TIME_LIMIT = old_limit
231+
assert '3.1.1.3.1' in L
144232

145233
def test_lattice_searchGM_isometric_large_class_number(self):
146234
# Rank-6 lattice in genus 6.6.311.61 (class_number=200)
@@ -158,14 +246,15 @@ def test_lattice_searchGM_isometric_large_class_number(self):
158246
U[0,1] = 1
159247
G2 = U.T * G * U
160248
gram = quote('[' + ','.join(str(x) for x in G2.list()) + ']')
161-
L = self.tc.get("/Lattice/?gram=%s&gram_format=full" % gram).get_data(as_text=True)
162-
# The isometry search has a 20s wall-clock budget that includes the
163-
# database queries, so on a loaded runner any of three outcomes is
164-
# legitimate: the exact lattice, its genus page, or a plain no-match
165-
# results page. The deterministic rank-2 test above covers the
166-
# isometry machinery itself; here we just require that the rank-6
167-
# search completes without an error.
168-
assert '6.6.311.61' in L or 'Integral lattices search results' in L
249+
response = self.tc.get("/Lattice/?gram=%s&gram_format=full" % gram)
250+
# Smoke test only: with class number 200 the isometry scan usually hits
251+
# the default 20s time budget, and which fallback is reached depends on
252+
# timing. This test only checks that the endpoint survives the time
253+
# budget (HTTP 200, no traceback); the deterministic tests above pin
254+
# the isometry machinery itself.
255+
assert response.status_code == 200
256+
L = response.get_data(as_text=True)
257+
assert 'Traceback' not in L
169258

170259
#def test_latticeZ2(self):
171260
# L = self.tc.get("/Lattice/2.1.2.1.1").get_data(as_text=True)

0 commit comments

Comments
 (0)